diff --git a/docs/development/INTERNATIONALIZATION.md b/docs/development/INTERNATIONALIZATION.md index 8f68eec2..3c998f8e 100644 --- a/docs/development/INTERNATIONALIZATION.md +++ b/docs/development/INTERNATIONALIZATION.md @@ -16,6 +16,7 @@ Typed JSON under `ui/src/i18n/locales//.json`. | `wizard` | Ask to the Wizard chrome | | `activity` | Activity footer, Inbox / Legacy, Extra info inspector, Assets catalog chrome | | `extraInfo` | Video Extra info dialog body (title stays `activity.extraInfo`) | +| `storyLab` | Story Lab section chrome, world/characters/relationships/structure tabs, reference gallery and editors | Add a namespace only when a new product surface needs its own file. Do not grow a single giant JSON. diff --git a/docs/development/SLICE_QUEUE.md b/docs/development/SLICE_QUEUE.md index 10ea868d..de844325 100644 --- a/docs/development/SLICE_QUEUE.md +++ b/docs/development/SLICE_QUEUE.md @@ -15,7 +15,7 @@ Canonical sources in git: Working notes under `comunicaciones/` are session handoff only. They are gitignored and are not canonical. -## Landed on main (as of #85) +## Landed on main (as of #87) Asset-manifest v1 writers: Studio generate (simulated, WGP, H3, SFX), Tools upscale/revoice, Recast/Repaint/Outpaint, MiniMax image, Series assembly, 3D, @@ -24,33 +24,39 @@ recording, Video Editor screenshot/export, comic animatic. Sidecar failure: Hunyuan3D and Rig keep the GLB when provenance write fails. +Domain provenance: `workspace_id` is the collection; `output_folder` is the +physical directory. `GenerationProvenance` / `CommandContext` distinguish +initiator (`origin.actor` / `tool` / `capability`) from provider/model. +Inspector timing reads `queue_ms` / `inference_ms` / `total_ms`. + `useStore` slices (facade kept): theme, settings (includes model-visibility focus), developerMode, sidebar, retake dialog. Slices bind through `bindSlice` without `as never`. `developerModeSlice` no longer writes `mediaFilter`; the facade still leaves `auditdev` when developer mode turns off. -Story Lab: `relationships` and `world` tabs extracted. `StoryWorldTab` is an -intermediate cut (too many props; `LocationEditor` / `ReferenceGallery` still -owned by the panel). Visible copy on extracted tabs is still hardcoded English. +Story Lab: shared `ReferenceGallery`, `LocationEditor`, `CharacterEditor`, +`BeatEditor`, `storyLabVisuals` and `StoryLabVisualsProvider`. World, characters, +relationships and structure tabs import those modules instead of receiving +16 props. Visible copy on those tabs lives in the `storyLab` namespace +(EN+ES). Compact music/trailer/quick-video prep still sits in the panel. i18n: foundation + Extra info inspector + Extra info video dialog (`extraInfo` -namespace) + Assets catalog list chrome. +namespace) + Assets catalog list chrome + Story Lab simple tabs (`storyLab`). ## Next medium PRs 1. **Domain provenance contract** — landed (#86). -2. **Typed Zustand composition** (this track): `bindSlice`, no `as never` at - compose time, settings owns model-visibility focus, developerMode does not - write `mediaFilter`. -3. **Story Lab simple tabs**: extract shared `ReferenceGallery` / - `LocationEditor` / controllers first, then Characters + Structure (and other - purely visual tabs) in one PR, with i18n and tests. Do not pass 16 props. - Music and Productions stay a later PR with Wizard E2E. -4. **Backend by domain**: one complete router + services per PR (Assets, Music, +2. **Typed Zustand composition** — landed (#87). +3. **Story Lab simple tabs** — this PR: shared gallery/editors/controller, + then Characters + Structure (and world/relationships i18n) without 16-prop + drilling. +4. **Story Lab Music + Productions**: keep Wizard E2E with that PR. Do not + fold compact prep into another tiny extract. +5. **Backend by domain**: one complete router + services per PR (Assets, Music, Series, Comics, …). Preserve route-table ordinals. Do not split `_launch_runtime.py` by line count. -5. **Provenance applied by flow** (after 1): Studio+Wizard, Story Lab+videoclip, +6. **Provenance applied by flow** (after 1): Studio+Wizard, Story Lab+videoclip, Series+Comics, 3D+Director. ## Standing rules diff --git a/tests/test_story_lab_audio_ui.py b/tests/test_story_lab_audio_ui.py index e9d5023b..fa2888e8 100644 --- a/tests/test_story_lab_audio_ui.py +++ b/tests/test_story_lab_audio_ui.py @@ -7,6 +7,7 @@ ROOT = Path(__file__).resolve().parents[1] STORY = ROOT / "ui" / "src" / "features" / "stories" / "StoryLabPanel.tsx" +STORY_GALLERY = ROOT / "ui" / "src" / "features" / "stories" / "ReferenceGallery.tsx" ACTIVITY = ROOT / "ui" / "src" / "components" / "ActivityFooter.tsx" STORY_TYPES = ROOT / "ui" / "src" / "features" / "stories" / "types.ts" STORY_MODEL = ROOT / "ui" / "src" / "features" / "stories" / "model.ts" @@ -160,11 +161,12 @@ def test_story_library_can_bulk_remove_only_selected_drafts(): def test_story_reference_images_confirm_removal_and_open_in_a_modal(): + gallery = STORY_GALLERY.read_text(encoding="utf-8") panel = STORY.read_text(encoding="utf-8") - gallery = panel.split("function ReferenceGallery", 1)[1].split( - "function SectionHeader", 1, - )[0] + assert "export function ReferenceGallery" in gallery + assert "function ReferenceGallery" not in panel + assert "from './ReferenceGallery'" in panel assert "window.confirm(" in gallery assert "createPortal(" in gallery assert "Maximize2" in gallery diff --git a/ui/scripts/check-i18n-catalogs.mjs b/ui/scripts/check-i18n-catalogs.mjs index 914e9a2e..2d913ea3 100644 --- a/ui/scripts/check-i18n-catalogs.mjs +++ b/ui/scripts/check-i18n-catalogs.mjs @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') -const NAMESPACES = ['common', 'navigation', 'settings', 'wizard', 'activity', 'extraInfo'] +const NAMESPACES = ['common', 'navigation', 'settings', 'wizard', 'activity', 'extraInfo', 'storyLab'] const LANGUAGES = ['en', 'es'] function load(language, namespace) { @@ -44,6 +44,15 @@ const PILOT_FILES = [ 'src/features/assets/AssetsPanel.tsx', 'src/features/workspaceCollections/WorkspaceCollectionsPanel.tsx', 'src/features/workspaces/WorkspacesPanel.tsx', + 'src/features/stories/storyLabChrome.tsx', + 'src/features/stories/ReferenceGallery.tsx', + 'src/features/stories/LocationEditor.tsx', + 'src/features/stories/CharacterEditor.tsx', + 'src/features/stories/BeatEditor.tsx', + 'src/features/stories/StoryWorldTab.tsx', + 'src/features/stories/StoryCharactersTab.tsx', + 'src/features/stories/StoryStructureTab.tsx', + 'src/features/stories/StoryRelationshipsTab.tsx', ] const FORBIDDEN = [ @@ -54,6 +63,10 @@ const FORBIDDEN = [ 'Extra info', 'Clip information', 'Wait for generation to finish', + 'World bible', + 'Dramatic structure', + 'Generate text', + 'Optional regeneration instruction…', ] export function forbiddenLiterals() { diff --git a/ui/src/features/stories/BeatEditor.tsx b/ui/src/features/stories/BeatEditor.tsx new file mode 100644 index 00000000..bf1751e6 --- /dev/null +++ b/ui/src/features/stories/BeatEditor.tsx @@ -0,0 +1,49 @@ +import { ChevronDown, ChevronUp, Trash2 } from 'lucide-react' +import { useUiTranslation } from '../../i18n' +import { button, panel, Field } from './storyLabChrome' +import { moveItem } from './storyLabEditors' +import type { StoryBeat, StoryProject } from './types' + +export function BeatEditor({ beat, index, total, update }: { + beat: StoryBeat + index: number + total: number + update: (updater: (project: StoryProject) => StoryProject) => void +}) { + const { t } = useUiTranslation('storyLab') + const set = (patch: Partial) => update(current => { + current.beats = current.beats.map(item => item.id === beat.id ? { ...item, ...patch } : item) + return current + }) + return ( +
+
+
{String(index + 1).padStart(2, '0')}
+
+ + +
+
+
+ set({ stage })} /> + set({ title })} /> + set({ summary })} rows={4} /> +
+
+ set({ goal })} rows={2} /> + set({ conflict })} rows={2} /> + set({ turn })} rows={3} /> + +
+
+ ) +} diff --git a/ui/src/features/stories/CharacterEditor.tsx b/ui/src/features/stories/CharacterEditor.tsx new file mode 100644 index 00000000..f7106a4a --- /dev/null +++ b/ui/src/features/stories/CharacterEditor.tsx @@ -0,0 +1,78 @@ +import { Check, ChevronDown, ChevronUp, ImagePlus, Loader2, Trash2, Upload } from 'lucide-react' +import { useUiTranslation } from '../../i18n' +import { button, panel, Field } from './storyLabChrome' +import { moveItem, pruneUnusedAssets } from './storyLabEditors' +import { useStoryLabVisuals } from './storyLabVisuals' +import { ReferenceGallery } from './ReferenceGallery' +import type { StoryCharacter, StoryProject } from './types' + +export function CharacterEditor({ + character, index, total, project, update, +}: { + character: StoryCharacter + index: number + total: number + project: StoryProject + update: (updater: (project: StoryProject) => StoryProject) => void +}) { + const { t } = useUiTranslation('storyLab') + const { imageBusy, generateVisual, requestUpload, removeReference } = useStoryLabVisuals() + const set = (patch: Partial) => update(current => { + current.characters = current.characters.map(item => item.id === character.id ? { ...item, approval: 'draft', ...patch } : item) + return current + }) + return ( +
+
+
+

{character.name}

+ +
+
+ + + +
+
+
+ set({ name })} /> + set({ role })} /> +
+ set({ age })} /> + set({ pronouns })} /> +
+ set({ personality })} rows={3} /> + set({ desire })} rows={3} /> + set({ need })} rows={3} /> + set({ flaw })} rows={3} /> + set({ conflict })} rows={3} /> + set({ arc })} rows={3} /> + set({ voice })} rows={3} /> + set({ appearance })} rows={3} /> + set({ wardrobe })} rows={3} /> + set({ visualPrompt })} rows={4} /> + set({ negativePrompt })} rows={4} /> +
+
+ + +
+ set({ primaryReferenceAssetId: id })} onRemove={id => removeReference('character', character.id, id)} /> +
+ ) +} diff --git a/ui/src/features/stories/LocationEditor.tsx b/ui/src/features/stories/LocationEditor.tsx new file mode 100644 index 00000000..4c308213 --- /dev/null +++ b/ui/src/features/stories/LocationEditor.tsx @@ -0,0 +1,60 @@ +import { ChevronDown, ChevronUp, ImagePlus, Loader2, Trash2, Upload } from 'lucide-react' +import { useUiTranslation } from '../../i18n' +import { button, panel, Field } from './storyLabChrome' +import { moveItem, pruneUnusedAssets } from './storyLabEditors' +import { useStoryLabVisuals } from './storyLabVisuals' +import { ReferenceGallery } from './ReferenceGallery' +import type { StoryLocation, StoryProject } from './types' + +export function LocationEditor({ + location, index, total, project, update, +}: { + location: StoryLocation + index: number + total: number + project: StoryProject + update: (updater: (project: StoryProject) => StoryProject) => void +}) { + const { t } = useUiTranslation('storyLab') + const { imageBusy, generateVisual, requestUpload, removeReference } = useStoryLabVisuals() + const set = (patch: Partial) => update(current => { + current.world.locations = current.world.locations.map(item => item.id === location.id ? { ...item, ...patch } : item) + return current + }) + return ( +
+
+

{location.name}

+
+ + + +
+
+
+ set({ name })} /> + set({ purpose })} /> + set({ description })} rows={4} /> + set({ visualPrompt })} rows={4} /> + set({ negativePrompt })} rows={3} /> +
+
+ + +
+ removeReference('location', location.id, id)} /> +
+ ) +} diff --git a/ui/src/features/stories/ReferenceGallery.tsx b/ui/src/features/stories/ReferenceGallery.tsx new file mode 100644 index 00000000..5e4bcd0d --- /dev/null +++ b/ui/src/features/stories/ReferenceGallery.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { Maximize2, Trash2, X } from 'lucide-react' +import { useUiTranslation } from '../../i18n' +import type { StoryVisualAsset } from './types' + +export function ReferenceGallery({ + ids, assets, primaryId, onPrimary, onRemove, +}: { + ids: string[] + assets: Record + primaryId?: string + onPrimary?: (id: string) => void + onRemove: (id: string) => void +}) { + const { t } = useUiTranslation('storyLab') + const { t: tCommon } = useUiTranslation('common') + const [previewId, setPreviewId] = useState(null) + const previewAsset = previewId ? assets[previewId] : undefined + + useEffect(() => { + if (!previewAsset) return + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setPreviewId(null) + } + window.addEventListener('keydown', closeOnEscape) + return () => { + window.removeEventListener('keydown', closeOnEscape) + document.body.style.overflow = previousOverflow + } + }, [previewAsset]) + + const confirmRemove = (id: string, asset: StoryVisualAsset) => { + const name = asset.name || t('gallery.untitled') + if (!window.confirm(t('gallery.removeConfirm', { name }))) return + onRemove(id) + } + + return ( + <> +
+ {ids.map(id => { + const asset = assets[id] + if (!asset) return null + const displayName = asset.name || t('gallery.untitled') + return ( +
+ {asset.name} + + {asset.approval === 'approved' ? t('status.approved') : t('status.draft')} + +
+ {onPrimary && } + + +
+
+ ) + })} +
+ {previewAsset && createPortal( +
setPreviewId(null)} + role="presentation" + > +
event.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="story-image-preview-title" + > +
+

+ {previewAsset.name || t('gallery.previewFallback')} +

+ +
+
+ {previewAsset.name +
+
+
, + document.body, + )} + + ) +} diff --git a/ui/src/features/stories/StoryCharactersTab.tsx b/ui/src/features/stories/StoryCharactersTab.tsx new file mode 100644 index 00000000..00b8b2e0 --- /dev/null +++ b/ui/src/features/stories/StoryCharactersTab.tsx @@ -0,0 +1,37 @@ +import { Plus } from 'lucide-react' +import { useUiTranslation } from '../../i18n' +import { button, panel, SectionHeader, type StoryLabSectionTabProps } from './storyLabChrome' +import { emptyCharacter } from './storyLabEditors' +import { CharacterEditor } from './CharacterEditor' + +export function StoryCharactersTab({ + project, + update, + busy, + instruction, + setInstruction, + generate, + approve, + isApproved, +}: StoryLabSectionTabProps) { + const { t } = useUiTranslation('storyLab') + return ( + <> +
+ approve('characters')} /> +
+
+ +
+
+ {project.characters.map((character, index) => ( + + ))} + {!project.characters.length &&
{t('characters.empty')}
} +
+ + ) +} diff --git a/ui/src/features/stories/StoryLabPanel.tsx b/ui/src/features/stories/StoryLabPanel.tsx index c209fe64..34704998 100644 --- a/ui/src/features/stories/StoryLabPanel.tsx +++ b/ui/src/features/stories/StoryLabPanel.tsx @@ -1,9 +1,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { createPortal } from 'react-dom' import JSZip from 'jszip' import { BookOpen, Boxes, Check, ChevronDown, ChevronRight, ChevronUp, Copy, Download, ExternalLink, Film, ImagePlus, Loader2, - Languages, Maximize2, Music, Network, Palette, Play, Plus, RefreshCcw, Sparkles, Trash2, Upload, Users, X, + Languages, Music, Network, Palette, Play, Plus, RefreshCcw, Sparkles, Trash2, Upload, Users, } from 'lucide-react' import * as api from '../../api/client' import { getModelMode, resolveResolution, useStore } from '../../stores/useStore' @@ -16,6 +15,12 @@ import { StoryProductionTimeline } from './StoryProductionTimeline' import { StoryLabNavigation } from './StoryLabNavigation' import { StoryRelationshipsTab } from './StoryRelationshipsTab' import { StoryWorldTab } from './StoryWorldTab' +import { StoryCharactersTab } from './StoryCharactersTab' +import { StoryStructureTab } from './StoryStructureTab' +import { ReferenceGallery } from './ReferenceGallery' +import { LocationEditor } from './LocationEditor' +import { StoryLabVisualsProvider } from './StoryLabVisualsProvider' +import { emptyCharacter, moveItem, pruneUnusedAssets } from './storyLabEditors' import { button, input, panel, requiredInput, Field, SectionHeader } from './storyLabChrome' import { readDirectorClipReplacementResult } from './directorClipHandoff' import { AudioRangeSelector } from './AudioRangeSelector' @@ -70,23 +75,6 @@ const CHARACTER_STYLE_PRESETS = [ ['Anime', '2D anime characters, clean expressive linework, consistent cel shading, stylized facial proportions, illustrated skin and hair, never photorealistic'], ] as const -function moveItem(items: T[], from: number, to: number): void { - if (from < 0 || to < 0 || from >= items.length || to >= items.length || from === to) return - const [item] = items.splice(from, 1) - items.splice(to, 0, item) -} - -function pruneUnusedAssets(project: StoryProject): void { - const used = new Set([ - ...project.world.referenceAssetIds, - ...project.world.locations.flatMap(location => location.referenceAssetIds), - ...project.characters.flatMap(character => character.referenceAssetIds), - ]) - Object.keys(project.assets).forEach(id => { - if (!used.has(id)) delete project.assets[id] - }) -} - function stableTextKey(value: string): string { let hash = 2166136261 for (let index = 0; index < value.length; index += 1) { @@ -693,120 +681,6 @@ function ProviderPanel({ ) } -function ReferenceGallery({ - ids, assets, primaryId, onPrimary, onRemove, -}: { - ids: string[] - assets: Record - primaryId?: string - onPrimary?: (id: string) => void - onRemove: (id: string) => void -}) { - const [previewId, setPreviewId] = useState(null) - const previewAsset = previewId ? assets[previewId] : undefined - - useEffect(() => { - if (!previewAsset) return - const previousOverflow = document.body.style.overflow - document.body.style.overflow = 'hidden' - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') setPreviewId(null) - } - window.addEventListener('keydown', closeOnEscape) - return () => { - window.removeEventListener('keydown', closeOnEscape) - document.body.style.overflow = previousOverflow - } - }, [previewAsset]) - - const confirmRemove = (id: string, asset: StoryVisualAsset) => { - if (!window.confirm( - `¿Quitar “${asset.name || 'esta imagen'}” de este bloque? Si no se utiliza en otro lugar, también se eliminará de la biblioteca de Story Lab.`, - )) return - onRemove(id) - } - - return ( - <> -
- {ids.map(id => { - const asset = assets[id] - if (!asset) return null - return ( -
- {asset.name} - - {asset.approval === 'approved' ? 'Approved' : 'Draft'} - -
- {onPrimary && } - - -
-
- ) - })} -
- {previewAsset && createPortal( -
setPreviewId(null)} - role="presentation" - > -
event.stopPropagation()} - role="dialog" - aria-modal="true" - aria-labelledby="story-image-preview-title" - > -
-

- {previewAsset.name || 'Imagen de Story Lab'} -

- -
-
- {previewAsset.name -
-
-
, - document.body, - )} - - ) -} - export function StoryLabPanel() { const project = useStoryStore(state => state.project) const productionProfile = useStore(state => state.productionProfile) @@ -4712,6 +4586,16 @@ export function StoryLabPanel() { : productionIssues return ( + { + setUploadTarget(target) + uploadRef.current?.click() + }, + removeReference, + }}>
@@ -5485,35 +5369,20 @@ export function StoryLabPanel() { generate={generate} approve={approve} isApproved={isApproved} - imageBusy={imageBusy} - referenceBatchBusy={referenceBatchBusy} - generateVisual={generateVisual} - setUploadTarget={setUploadTarget} - uploadRef={uploadRef} - removeReference={removeReference} - ReferenceGallery={ReferenceGallery} - LocationEditor={LocationEditor} /> )} {tab === 'characters' && ( - <> -
- approve('characters')} /> -
-
- -
-
- {project.characters.map((character, index) => ( - { setUploadTarget({ kind: 'character', id: character.id }); uploadRef.current?.click() }} removeReference={id => removeReference('character', character.id, id)} /> - ))} - {!project.characters.length &&
Generate the cast or add the first character manually.
} -
- + )} {tab === 'relationships' && ( @@ -5530,20 +5399,16 @@ export function StoryLabPanel() { )} {tab === 'structure' && ( - <> -
- approve('structure')} /> -
-
- -
-
- {project.beats.map((beat, index) => )} -
- + )} {tab === 'music' && ( @@ -6830,18 +6695,10 @@ export function StoryLabPanel() { void uploadCustomMusic(event.target.files?.[0])} />
+ ) } -function emptyCharacter(): StoryCharacter { - return { - id: storyId('character'), name: 'New character', role: '', age: '', pronouns: '', - personality: '', desire: '', need: '', flaw: '', conflict: '', arc: '', voice: '', - appearance: '', wardrobe: '', visualPrompt: '', negativePrompt: '', - referenceAssetIds: [], approval: 'draft', - } -} - function CompactVideoWorkspace({ project, update, busy, imageBusy, referenceBatchBusy, generateSection, approveSection, isSectionApproved, generateVisual, upload, removeReference, navigate, requiresVisualIdentities, @@ -6938,9 +6795,7 @@ function CompactVideoWorkspace({
{project.world.locations.map((location, index) => ( upload({ kind: 'location', id: location.id })} - removeReference={id => removeReference('location', location.id, id)} /> + project={project} update={update} /> ))}
@@ -7155,176 +7010,3 @@ function CompactBeatEditor({ beat, index, total, update }: { ) } - -function CharacterEditor({ - character, index, total, project, update, imageBusy, generateVisual, upload, removeReference, -}: { - character: StoryCharacter - index: number - total: number - project: StoryProject - update: (updater: (project: StoryProject) => StoryProject) => void - imageBusy: string - generateVisual: (target: { kind: 'character'; id: string }, prompt: string) => void - upload: () => void - removeReference: (id: string) => void -}) { - const set = (patch: Partial) => update(current => { - current.characters = current.characters.map(item => item.id === character.id ? { ...item, approval: 'draft', ...patch } : item) - return current - }) - return ( -
-
-
-

{character.name}

- -
-
- - - -
-
-
- set({ name })} /> - set({ role })} /> -
- set({ age })} /> - set({ pronouns })} /> -
- set({ personality })} rows={3} /> - set({ desire })} rows={3} /> - set({ need })} rows={3} /> - set({ flaw })} rows={3} /> - set({ conflict })} rows={3} /> - set({ arc })} rows={3} /> - set({ voice })} rows={3} /> - set({ appearance })} rows={3} /> - set({ wardrobe })} rows={3} /> - set({ visualPrompt })} rows={4} /> - set({ negativePrompt })} rows={4} /> -
-
- - -
- set({ primaryReferenceAssetId: id })} onRemove={removeReference} /> -
- ) -} - -function LocationEditor({ - location, index, total, project, update, imageBusy, generateVisual, upload, removeReference, -}: { - location: StoryLocation - index: number - total: number - project: StoryProject - update: (updater: (project: StoryProject) => StoryProject) => void - imageBusy: string - generateVisual: (target: { kind: 'location'; id: string }, prompt: string) => void - upload: () => void - removeReference: (id: string) => void -}) { - const set = (patch: Partial) => update(current => { - current.world.locations = current.world.locations.map(item => item.id === location.id ? { ...item, ...patch } : item) - return current - }) - return ( -
-
-

{location.name}

-
- - - -
-
-
- set({ name })} /> - set({ purpose })} /> - set({ description })} rows={4} /> - set({ visualPrompt })} rows={4} /> - set({ negativePrompt })} rows={3} /> -
-
- - -
- -
- ) -} - -function BeatEditor({ - beat, index, total, update, -}: { - beat: StoryBeat - index: number - total: number - update: (updater: (project: StoryProject) => StoryProject) => void -}) { - const set = (patch: Partial) => update(current => { - current.beats = current.beats.map(item => item.id === beat.id ? { ...item, ...patch } : item) - return current - }) - return ( -
-
-
{String(index + 1).padStart(2, '0')}
-
- - -
-
-
- set({ stage })} /> - set({ title })} /> - set({ summary })} rows={4} /> -
-
- set({ goal })} rows={2} /> - set({ conflict })} rows={2} /> - set({ turn })} rows={3} /> - -
-
- ) -} diff --git a/ui/src/features/stories/StoryLabVisualsProvider.tsx b/ui/src/features/stories/StoryLabVisualsProvider.tsx new file mode 100644 index 00000000..47b4375e --- /dev/null +++ b/ui/src/features/stories/StoryLabVisualsProvider.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from 'react' +import { StoryLabVisualsContext, type StoryLabVisuals } from './storyLabVisuals' + +export function StoryLabVisualsProvider({ + value, + children, +}: { + value: StoryLabVisuals + children: ReactNode +}) { + return {children} +} diff --git a/ui/src/features/stories/StoryRelationshipsTab.tsx b/ui/src/features/stories/StoryRelationshipsTab.tsx index 75fc652f..7cddb721 100644 --- a/ui/src/features/stories/StoryRelationshipsTab.tsx +++ b/ui/src/features/stories/StoryRelationshipsTab.tsx @@ -1,7 +1,8 @@ import { Plus, Trash2 } from 'lucide-react' -import { button, input, panel, Field, SectionHeader } from './storyLabChrome' +import { useUiTranslation } from '../../i18n' +import { button, input, panel, Field, SectionHeader, type StoryLabSectionTabProps } from './storyLabChrome' import { storyId } from './model' -import type { StoryGenerationScope, StoryProject, StoryRelationship } from './types' +import type { StoryProject, StoryRelationship } from './types' export function StoryRelationshipsTab({ project, @@ -12,26 +13,18 @@ export function StoryRelationshipsTab({ generate, approve, isApproved, -}: { - project: StoryProject - update: (updater: (project: StoryProject) => StoryProject) => void - busy: StoryGenerationScope | null - instruction: string - setInstruction: (value: string) => void - generate: (scope: StoryGenerationScope) => void - approve: (key: keyof StoryProject['approvals']) => void - isApproved: (key: keyof StoryProject['approvals']) => boolean -}) { +}: StoryLabSectionTabProps) { + const { t } = useUiTranslation('storyLab') return ( <>
- approve('relationships')} /> + approve('relationships')} />
+ })}> {t('relationships.add')}
{project.relationships.map(relationship => ( @@ -49,29 +42,30 @@ function RelationshipEditor({ project: StoryProject update: (updater: (project: StoryProject) => StoryProject) => void }) { + const { t } = useUiTranslation('storyLab') const set = (patch: Partial) => update(current => { current.relationships = current.relationships.map(item => item.id === relationship.id ? { ...item, ...patch } : item) return current }) return (
-
) } diff --git a/ui/src/features/stories/StoryStructureTab.tsx b/ui/src/features/stories/StoryStructureTab.tsx new file mode 100644 index 00000000..319f3950 --- /dev/null +++ b/ui/src/features/stories/StoryStructureTab.tsx @@ -0,0 +1,34 @@ +import { Plus } from 'lucide-react' +import { useUiTranslation } from '../../i18n' +import { button, SectionHeader, type StoryLabSectionTabProps } from './storyLabChrome' +import { BeatEditor } from './BeatEditor' +import { storyId } from './model' + +export function StoryStructureTab({ + project, + update, + busy, + instruction, + setInstruction, + generate, + approve, + isApproved, +}: StoryLabSectionTabProps) { + const { t } = useUiTranslation('storyLab') + return ( + <> +
+ approve('structure')} /> +
+
+ +
+
+ {project.beats.map((beat, index) => )} +
+ + ) +} diff --git a/ui/src/features/stories/StoryWorldTab.tsx b/ui/src/features/stories/StoryWorldTab.tsx index cce22672..86f41d71 100644 --- a/ui/src/features/stories/StoryWorldTab.tsx +++ b/ui/src/features/stories/StoryWorldTab.tsx @@ -1,10 +1,17 @@ -import type { ComponentType, RefObject } from 'react' import { ImagePlus, Loader2, Plus, Upload } from 'lucide-react' -import { button, panel, Field, SectionHeader } from './storyLabChrome' +import { useUiTranslation } from '../../i18n' +import { button, panel, Field, SectionHeader, type StoryLabSectionTabProps } from './storyLabChrome' import { storyId } from './model' -import type { StoryGenerationScope, StoryLocation, StoryProject } from './types' +import { LocationEditor } from './LocationEditor' +import { ReferenceGallery } from './ReferenceGallery' +import { useStoryLabVisuals } from './storyLabVisuals' -type WorldUploadTarget = { kind: 'world' | 'character' | 'location'; id?: string } +const WORLD_FIELDS = [ + ['period', 'world.period'], + ['geography', 'world.geography'], + ['society', 'world.society'], + ['technology', 'world.technology'], +] as const export function StoryWorldTab({ project, @@ -16,79 +23,43 @@ export function StoryWorldTab({ generate, approve, isApproved, - imageBusy, - referenceBatchBusy, - generateVisual, - setUploadTarget, - uploadRef, - removeReference, - ReferenceGallery, - LocationEditor, -}: { - project: StoryProject - patch: (patch: Partial) => void - update: (updater: (project: StoryProject) => StoryProject) => void - busy: StoryGenerationScope | null - instruction: string - setInstruction: (value: string) => void - generate: (scope: StoryGenerationScope) => void - approve: (key: keyof StoryProject['approvals']) => void - isApproved: (key: keyof StoryProject['approvals']) => boolean - imageBusy: string - referenceBatchBusy: boolean - generateVisual: (target: { kind: 'world' | 'character' | 'location'; id?: string }, prompt: string) => void - setUploadTarget: (target: WorldUploadTarget) => void - uploadRef: RefObject - removeReference: (target: 'world' | 'character' | 'location', targetId: string | undefined, assetId: string) => void - ReferenceGallery: ComponentType<{ - ids: string[] - assets: StoryProject['assets'] - onRemove: (id: string) => void - }> - LocationEditor: ComponentType<{ - location: StoryLocation - index: number - total: number - project: StoryProject - update: (updater: (project: StoryProject) => StoryProject) => void - imageBusy: string - generateVisual: (target: { kind: 'location'; id: string }, prompt: string) => void - upload: () => void - removeReference: (id: string) => void - }> +}: StoryLabSectionTabProps & { + patch: (patch: Partial) => void }) { + const { t } = useUiTranslation('storyLab') + const { imageBusy, referenceBatchBusy, generateVisual, requestUpload, removeReference } = useStoryLabVisuals() return ( <>
- approve('world')} /> + approve('world')} />
-
patch({ world: { ...project.world, summary } })} rows={5} />
- {(['period', 'geography', 'society', 'technology'] as const).map(key => ( - patch({ world: { ...project.world, [key]: value } })} rows={2} /> +
patch({ world: { ...project.world, summary } })} rows={5} />
+ {WORLD_FIELDS.map(([key, labelKey]) => ( + patch({ world: { ...project.world, [key]: value } })} rows={2} /> ))} -
patch({ world: { ...project.world, rules: value.split('\n').filter(Boolean) } })} rows={4} />
-
patch({ world: { ...project.world, visualLanguage } })} rows={3} />
- patch({ world: { ...project.world, visualPrompt } })} rows={4} /> - patch({ world: { ...project.world, negativePrompt } })} rows={4} /> +
patch({ world: { ...project.world, rules: value.split('\n').filter(Boolean) } })} rows={4} />
+
patch({ world: { ...project.world, visualLanguage } })} rows={3} />
+ patch({ world: { ...project.world, visualPrompt } })} rows={4} /> + patch({ world: { ...project.world, negativePrompt } })} rows={4} />
- - +
removeReference('world', undefined, id)} />
-

Locations

+

{t('world.locations')}

+ })}> {t('world.addLocation')}
{project.world.locations.map((location, index) => ( - { setUploadTarget({ kind: 'location', id: location.id }); uploadRef.current?.click() }} removeReference={id => removeReference('location', location.id, id)} /> + ))}
diff --git a/ui/src/features/stories/storyLabChrome.tsx b/ui/src/features/stories/storyLabChrome.tsx index acdbcae7..cd70670b 100644 --- a/ui/src/features/stories/storyLabChrome.tsx +++ b/ui/src/features/stories/storyLabChrome.tsx @@ -1,11 +1,23 @@ import { Check, Loader2, Sparkles } from 'lucide-react' -import type { StoryGenerationScope } from './types' +import { useUiTranslation } from '../../i18n' +import type { StoryGenerationScope, StoryProject } from './types' export const button = 'inline-flex items-center justify-center gap-1.5 rounded-md border border-border bg-bg-tertiary px-2.5 py-1.5 text-xs text-text-secondary hover:text-text-primary hover:bg-bg-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors' export const input = 'w-full rounded-md border border-border bg-bg-tertiary px-2 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent-blue' export const panel = 'rounded-xl border border-border bg-bg-secondary p-3 md:p-4' export const requiredInput = 'border-violet-400/70 bg-violet-500/5 shadow-[0_0_14px_rgba(139,92,246,0.22)] focus:border-violet-300 focus:shadow-[0_0_18px_rgba(139,92,246,0.32)]' +export type StoryLabSectionTabProps = { + project: StoryProject + update: (updater: (project: StoryProject) => StoryProject) => void + busy: StoryGenerationScope | null + instruction: string + setInstruction: (value: string) => void + generate: (scope: StoryGenerationScope) => void + approve: (key: keyof StoryProject['approvals']) => void + isApproved: (key: keyof StoryProject['approvals']) => boolean +} + export function Field({ label, value, onChange, rows = 1, placeholder = '', required = false, }: { @@ -16,10 +28,11 @@ export function Field({ placeholder?: string required?: boolean }) { + const { t } = useUiTranslation('storyLab') const requiredClass = required ? requiredInput : '' return (