From 37cee0f71d3b53b344dbbc62b1daa908b326b571 Mon Sep 17 00:00:00 2001 From: IAnMove <216241348+IAnMove@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:00:19 +0200 Subject: [PATCH] refactor: extract Story Lab assembly tab and library chrome i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move assembly and leftover library chrome out of StoryLabPanel. Handlers stay in the panel; tab labels, project types and chrome copy live in storyLab EN/ES catalogs. New files stay at complexity ≤ 25. Does not touch useStore.ts or _launch_runtime.py. --- docs/development/SLICE_QUEUE.md | 26 +- tests/test_series_lab_ui.py | 12 +- tests/test_story_lab_trailer_ui.py | 21 +- ui/scripts/check-i18n-catalogs.mjs | 9 + ui/src/features/stories/StoryAssemblyTab.tsx | 48 ++++ .../stories/StoryLabLibraryChrome.tsx | 240 ++++++++++++++++ .../features/stories/StoryLabNavigation.tsx | 4 +- ui/src/features/stories/StoryLabPanel.tsx | 265 +++++------------- ui/src/features/stories/storyLabTabs.ts | 96 +++++++ ui/src/i18n/locales/en/storyLab.json | 94 +++++++ ui/src/i18n/locales/es/storyLab.json | 94 +++++++ ui/tests/i18nFoundation.test.tsx | 4 + ui/tests/storyLabResponsive.test.tsx | 22 +- 13 files changed, 718 insertions(+), 217 deletions(-) create mode 100644 ui/src/features/stories/StoryAssemblyTab.tsx create mode 100644 ui/src/features/stories/StoryLabLibraryChrome.tsx create mode 100644 ui/src/features/stories/storyLabTabs.ts diff --git a/docs/development/SLICE_QUEUE.md b/docs/development/SLICE_QUEUE.md index a7c0ab85..b2d6daf1 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 #98) +## Landed on main (as of #100) Asset-manifest v1 writers: Studio generate (simulated, WGP, H3, SFX), Tools upscale/revoice, Recast/Repaint/Outpaint, MiniMax image, Series assembly, 3D, @@ -36,7 +36,8 @@ publish so Wizard→Studio→asset can share one durable identity. 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. +off. A parallel gallery/workspace slice PR may be in flight; it has not +landed on main. Story Lab UI extracts: @@ -46,7 +47,8 @@ Story Lab UI extracts: - #91 Music, Trailer, Productions and Compact workspace, with `storyLab` EN+ES. - #97 split those extracted tabs into smaller panels and added the code-health PR table (`scripts/code_health.py --check --markdown`). -- #98 Overview + generation-agent panel, EN+ES. Assets remains in +- #98 Overview + generation-agent panel, EN+ES. +- #100 Assets tab extracted with EN/ES. Assets is no longer remaining in `StoryLabPanel`. i18n: foundation + Extra info inspector + Extra info video dialog (`extraInfo` @@ -58,8 +60,8 @@ Recipe audio duration: generated audio is sized for its consumers (#93). Character Kits / Face Rig / cutout dialogue HOWUSEIT: #94 on `main`. #26 was the older Cursor docs pass and is closed as superseded. -`--markdown` without `--check` prints **Ratchet not evaluated.** CI uses -`--check --markdown`. +`--markdown` without `--check` prints **Ratchet not evaluated.** (#99). CI +uses `--check --markdown`. ## Next medium PRs @@ -68,16 +70,18 @@ the older Cursor docs pass and is closed as superseded. 3. **Story Lab simple tabs** — landed (#88). 4. **Story Lab Music + Productions** — landed (#91, split further in #97). 5. **Story Lab Overview** — landed (#98). -6. **Story Lab Assets tab** — remaining chrome in `StoryLabPanel` (importer, - style conversion, visual library), EN+ES, no `useStore.ts` / - `_launch_runtime.py`. -7. **`useStore` slice** — one moderate cohesive extract with the public facade +6. **Story Lab Assets tab** — landed (#100). +7. **Story Lab assembly + library chrome** — remaining: extract Assembly tab + and leftover library chrome (header, tab labels, project types, prepare + buttons, nav notes) with EN/ES. No `useStore.ts` / `_launch_runtime.py`. +8. **`useStore` slice** — one moderate cohesive extract with the public facade kept and `architectureSlices.test.mjs` extended. Do not move all of `startGeneration` in one PR. At most one open PR may touch `useStore.ts`. -8. **Backend by domain**: one complete router + services per PR (Assets, Music, + A gallery/workspace slice may already be in flight; do not claim it landed. +9. **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. -9. **Provenance applied by flow**: Studio+Wizard landed (#95). Remaining: +10. **Provenance applied by flow**: Studio+Wizard landed (#95). Remaining: Story Lab+videoclip, Series+Comics. 3D+Director already has folder vs Workspace provenance (#89). Do not start Director Paso 5 until a human decides release order; that work must begin with 5.0 PipelineRuntime. diff --git a/tests/test_series_lab_ui.py b/tests/test_series_lab_ui.py index de84de71..83aba5bc 100644 --- a/tests/test_series_lab_ui.py +++ b/tests/test_series_lab_ui.py @@ -77,11 +77,15 @@ def test_review_is_thumbnail_first_and_exposes_ordered_editable_attempt_history( def test_story_productions_have_an_in_place_ordered_clip_timeline(): - story = (ROOT / "ui" / "src" / "features" / "stories" / "StoryLabPanel.tsx").read_text(encoding="utf-8") + tabs = (ROOT / "ui" / "src" / "features" / "stories" / "storyLabTabs.ts").read_text(encoding="utf-8") + assembly = (ROOT / "ui" / "src" / "features" / "stories" / "StoryAssemblyTab.tsx").read_text(encoding="utf-8") timeline = (ROOT / "ui" / "src" / "features" / "stories" / "StoryProductionTimeline.tsx").read_text(encoding="utf-8") - assert "StoryProductionTimeline" in story - assert "{ id: 'assembly', label: 'Montaje', icon: Play }" in story - assert "initiallyOpen={index === 0}" in story + catalog_es = (ROOT / "ui" / "src" / "i18n" / "locales" / "es" / "storyLab.json").read_text(encoding="utf-8") + assert "id: 'assembly'" in tabs + assert "icon: Play" in tabs + assert "StoryProductionTimeline" in assembly + assert "initiallyOpen={index === 0}" in assembly + assert '"assembly": "Montaje"' in catalog_es assert "View ordered clips" in timeline and "Play all" in timeline assert "Edit/regenerate clips" in timeline and "Join clips" in timeline assert "fetchSavedPipeline" in timeline diff --git a/tests/test_story_lab_trailer_ui.py b/tests/test_story_lab_trailer_ui.py index 1abaea7d..f4e6491c 100644 --- a/tests/test_story_lab_trailer_ui.py +++ b/tests/test_story_lab_trailer_ui.py @@ -6,6 +6,7 @@ ROOT = Path(__file__).resolve().parents[1] STORIES = ROOT / "ui" / "src" / "features" / "stories" PANEL = STORIES / "StoryLabPanel.tsx" +TABS = STORIES / "storyLabTabs.ts" TRAILER = STORIES / "StoryTrailerTab.tsx" COMPACT = STORIES / "CompactVideoWorkspace.tsx" VIDEO_FORMAT = STORIES / "storyLabVideoFormat.ts" @@ -22,7 +23,7 @@ def story_lab_trailer_ui() -> str: extracted = sorted(STORIES.glob("StoryTrailer*.tsx")) + sorted(STORIES.glob("Compact*.tsx")) return "\n".join(path.read_text(encoding="utf-8") for path in ( - PANEL, TRAILER, COMPACT, VIDEO_FORMAT, VIDEO_CONTROLS, CATALOG_EN, CATALOG_ES, *extracted, + PANEL, TABS, TRAILER, COMPACT, VIDEO_FORMAT, VIDEO_CONTROLS, CATALOG_EN, CATALOG_ES, *extracted, )) @@ -38,13 +39,19 @@ def test_trailer_is_a_standalone_story_project_type_without_music(): types = TYPES.read_text(encoding="utf-8") model = MODEL.read_text(encoding="utf-8") panel = PANEL.read_text(encoding="utf-8") + tabs = TABS.read_text(encoding="utf-8") + catalog_en = CATALOG_EN.read_text(encoding="utf-8") + catalog_es = CATALOG_ES.read_text(encoding="utf-8") backend = ROOT.joinpath("app", "_launch_runtime.py").read_text(encoding="utf-8") assert "'full_story' | 'music_video' | 'trailer' | 'quick_video'" in types assert "projectType === 'trailer' ? 60" in model - assert "{ id: 'trailer', label: 'Tráiler cinematográfico'" in panel - assert "{ id: 'trailer', label: 'Crear tráiler'" in panel - assert "No escribirá ni exigirá una canción" in CATALOG_ES.read_text(encoding="utf-8") + assert "id: 'trailer'" in tabs + assert '"label": "Cinematic trailer"' in catalog_en + assert '"label": "Tráiler cinematográfico"' in catalog_es + assert '"createTrailer": "Create trailer"' in catalog_en + assert '"createTrailer": "Crear tráiler"' in catalog_es + assert "No escribirá ni exigirá una canción" in catalog_es assert "musicVideoGenerationMode: 'image_guided' as const" in panel assert "project.projectType === 'trailer' ? 'trailer' : 'productions'" in panel assert 'if project_type in {"trailer", "quick_video"}' in backend @@ -66,10 +73,12 @@ def test_trailer_adapter_enforces_a_story_arc_without_revealing_the_ending(): def test_story_lab_exposes_editable_trailer_controls_and_timed_preview(): source = story_lab_trailer_ui() - panel = PANEL.read_text(encoding="utf-8") + tabs = TABS.read_text(encoding="utf-8") trailer = source - assert "{ id: 'trailer', label: 'Tráiler'" in panel + assert "id: 'trailer'" in tabs + assert '"trailer": "Trailer"' in CATALOG_EN.read_text(encoding="utf-8") + assert '"trailer": "Tráiler"' in CATALOG_ES.read_text(encoding="utf-8") assert "Creador de tráileres cinematográficos" in source assert "TRAILER_ARC.map" in trailer assert "setTrailerDuration" in trailer diff --git a/ui/scripts/check-i18n-catalogs.mjs b/ui/scripts/check-i18n-catalogs.mjs index 27083152..20f43b9b 100644 --- a/ui/scripts/check-i18n-catalogs.mjs +++ b/ui/scripts/check-i18n-catalogs.mjs @@ -45,6 +45,10 @@ const PILOT_FILES = [ 'src/features/workspaceCollections/WorkspaceCollectionsPanel.tsx', 'src/features/workspaces/WorkspacesPanel.tsx', 'src/features/stories/storyLabChrome.tsx', + 'src/features/stories/StoryLabNavigation.tsx', + 'src/features/stories/storyLabTabs.ts', + 'src/features/stories/StoryLabLibraryChrome.tsx', + 'src/features/stories/StoryAssemblyTab.tsx', 'src/features/stories/StoryOverviewTab.tsx', 'src/features/stories/StoryAssetsTab.tsx', 'src/features/stories/StoryAssetsImporter.tsx', @@ -107,6 +111,11 @@ const FORBIDDEN = [ 'Cinematic trailer creator', 'Generate / refresh Lyria prompt', 'Import custom MP3', + 'Montaje de producciones', + 'Smart assets', + 'Guided · approve stages', + 'Preparar historia completa · solo texto', + 'Desliza para más secciones', ] export function forbiddenLiterals() { diff --git a/ui/src/features/stories/StoryAssemblyTab.tsx b/ui/src/features/stories/StoryAssemblyTab.tsx new file mode 100644 index 00000000..88136bf4 --- /dev/null +++ b/ui/src/features/stories/StoryAssemblyTab.tsx @@ -0,0 +1,48 @@ +import { useUiTranslation } from '../../i18n' +import { button, panel } from './storyLabChrome' +import { StoryProductionTimeline } from './StoryProductionTimeline' +import type { StoryProject } from './types' + +export function StoryAssemblyTab({ + project, reopenProduction, restoreProductionSource, +}: { + project: StoryProject + reopenProduction: (id: string) => void + restoreProductionSource: (id: string) => void +}) { + const { t } = useUiTranslation('storyLab') + const productions = [...project.productions].reverse() + return ( +
+
+

{t('assembly.title')}

+

{t('assembly.description')}

+
+ {productions.length ? productions.map((item, index) => ( +
+
+
+ + {item.kind === 'music_video' ? t('assembly.musicVideo') : item.kind} · {item.targetName || item.title} + + + {t('assembly.sourceMeta', { version: item.sourceVersion, when: new Date(item.createdAt).toLocaleString() })} + + {item.sourceSnapshot?.sectionVersions + && JSON.stringify(item.sourceSnapshot.sectionVersions) !== JSON.stringify(project.sectionVersions) && ( + {t('assembly.sourceChanged')} + )} +
+
+ + {item.sourceSnapshot && ( + + )} +
+
+ +
+ )) :

{t('assembly.empty')}

} +
+ ) +} diff --git a/ui/src/features/stories/StoryLabLibraryChrome.tsx b/ui/src/features/stories/StoryLabLibraryChrome.tsx new file mode 100644 index 00000000..9e7f0a7b --- /dev/null +++ b/ui/src/features/stories/StoryLabLibraryChrome.tsx @@ -0,0 +1,240 @@ +import { useRef } from 'react' +import { + BookOpen, Download, ImagePlus, Loader2, Plus, Sparkles, Trash2, Upload, +} from 'lucide-react' +import { useUiTranslation } from '../../i18n' +import { StoryLibraryConflictNotice } from './StoryLibraryConflictNotice' +import { button, input, requiredPreparationButton } from './storyLabChrome' +import { storyProjectTypes } from './storyLabTabs' +import type { StoryLibraryConflict } from './library' +import type { StoryGenerationScope, StoryProject, StoryProjectType } from './types' + +const PREPARE_TEXT = { + full_story: 'library.prepareTextFullStory', + music_video: 'library.prepareTextMusicVideo', + trailer: 'library.prepareTextTrailer', + quick_video: 'library.prepareTextQuickVideo', +} as const + +const PREPARE_IMAGES = { + full_story: 'library.prepareImagesFullStory', + music_video: 'library.prepareImagesMusicVideo', + trailer: 'library.prepareImagesTrailer', + quick_video: 'library.prepareImagesQuickVideo', +} as const + +function StoryLabSaveStatus({ + loading, saveError, dirty, hydrated, +}: { + loading: boolean + saveError: string | null + dirty: boolean + hydrated: boolean +}) { + const { t } = useUiTranslation('storyLab') + if (loading) return {t('library.saveLoading')} + if (saveError) return {t('library.saveLocalFallback')} + if (dirty) return {t('library.saveSaving')} + if (hydrated) return {t('library.saveSaved')} + return {t('library.saveCached')} +} + +function StoryLabPrepareButtons({ + projectType, needsPrep, busy, referenceBatchBusy, jobProgress, onPrepareText, onPrepareImages, +}: { + projectType: StoryProjectType + needsPrep: boolean + busy: StoryGenerationScope | null + referenceBatchBusy: boolean + jobProgress: string + onPrepareText: () => void + onPrepareImages: () => void +}) { + const { t } = useUiTranslation('storyLab') + const className = `${button} ${needsPrep ? requiredPreparationButton : ''}` + const preparing = Boolean(busy || referenceBatchBusy) + return ( + <> + + + + ) +} + +function StoryLabNewProjectMenu({ + types, disabled, onNewProject, +}: { + types: Array<{ id: StoryProjectType; label: string; description: string }> + disabled: boolean + onNewProject: (type: StoryProjectType) => void +}) { + const { t } = useUiTranslation('storyLab') + return ( +
+ {t('library.new')} +
+ {types.map(item => ( + + ))} +
+
+ ) +} + +export function StoryLabLibraryChrome({ + project, projects, activeWorkspace, progress, foundationTotal, + storyLoading, storySaveError, dirty, storyHydrated, storyLibraryConflicts, + resolveStoryLibraryConflict, busy, imageBusy, projectOperationBusy, referenceBatchBusy, + jobProgress, showCancel, showResume, recoveryJobId, smartAssetBusy, + onOpenProject, onProjectTypeChange, onWorkflowModeChange, onPrepareText, onPrepareImages, + onCancel, onResume, onExportStorypack, onImport, onSmartAssets, onNewProject, onDuplicate, onDelete, +}: { + project: StoryProject + projects: Record + activeWorkspace: string + progress: number + foundationTotal: number + storyLoading: boolean + storySaveError: string | null + dirty: boolean + storyHydrated: boolean + storyLibraryConflicts: StoryLibraryConflict[] + resolveStoryLibraryConflict: (id: string, resolution: 'local' | 'remote') => void + busy: StoryGenerationScope | null + imageBusy: string + projectOperationBusy: boolean + referenceBatchBusy: boolean + jobProgress: string + showCancel: boolean + showResume: boolean + recoveryJobId: string + smartAssetBusy: boolean + onOpenProject: (id: string) => void + onProjectTypeChange: (type: StoryProjectType) => void + onWorkflowModeChange: (mode: StoryProject['workflowMode']) => void + onPrepareText: () => void + onPrepareImages: () => void + onCancel: () => void + onResume: () => void + onExportStorypack: () => void + onImport: (file?: File) => void + onSmartAssets: () => void + onNewProject: (type: StoryProjectType) => void + onDuplicate: () => void + onDelete: () => void +}) { + const { t } = useUiTranslation('storyLab') + const importRef = useRef(null) + const types = storyProjectTypes(t) + const description = types.find(item => item.id === project.projectType)?.description + const libraryLocked = Boolean(busy || imageBusy || projectOperationBusy) + const progressUnit = project.projectType === 'full_story' ? t('library.foundations') : t('library.requirements') + return ( +
+
+
+ + {t('library.title')} + + {t('library.revisionProgress', { + revision: project.revision, progress, total: foundationTotal, unit: progressUnit, + })} + + +
+

{description}

+ +
+ + {t('library.legendRequired')} + + + {t('library.legendComplete')} + +
+
+ + + + + {showCancel && ( + + )} + {showResume && ( + + )} + + + + + + + { + onImport(event.target.files?.[0]) + event.target.value = '' + }} /> +
+ ) +} diff --git a/ui/src/features/stories/StoryLabNavigation.tsx b/ui/src/features/stories/StoryLabNavigation.tsx index c69e7a8a..6ed0684a 100644 --- a/ui/src/features/stories/StoryLabNavigation.tsx +++ b/ui/src/features/stories/StoryLabNavigation.tsx @@ -1,5 +1,6 @@ import type { LucideIcon } from 'lucide-react' import type { ReactNode } from 'react' +import { useUiTranslation } from '../../i18n' export interface StoryLabNavigationTab { id: T @@ -15,6 +16,7 @@ interface StoryLabNavigationProps { } export function StoryLabNavigation({ tabs, activeTab, onChange, notes }: StoryLabNavigationProps) { + const { t } = useUiTranslation('storyLab') return (