Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/development/INTERNATIONALIZATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Typed JSON under `ui/src/i18n/locales/<lng>/<namespace>.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.

Expand Down
34 changes: 20 additions & 14 deletions docs/development/SLICE_QUEUE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions tests/test_story_lab_audio_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion ui/scripts/check-i18n-catalogs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = [
Expand All @@ -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() {
Expand Down
49 changes: 49 additions & 0 deletions ui/src/features/stories/BeatEditor.tsx
Original file line number Diff line number Diff line change
@@ -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<StoryBeat>) => update(current => {
current.beats = current.beats.map(item => item.id === beat.id ? { ...item, ...patch } : item)
return current
})
return (
<div className={`${panel} grid md:grid-cols-[60px_1fr_1fr] gap-3`}>
<div className="space-y-2">
<div className="text-2xl font-bold text-text-muted/40">{String(index + 1).padStart(2, '0')}</div>
<div className="flex gap-1">
<button className={button} disabled={index === 0} title={t('structure.moveUp')} onClick={() => update(current => {
moveItem(current.beats, index, index - 1)
return current
})}><ChevronUp size={12} /></button>
<button className={button} disabled={index === total - 1} title={t('structure.moveDown')} onClick={() => update(current => {
moveItem(current.beats, index, index + 1)
return current
})}><ChevronDown size={12} /></button>
</div>
</div>
<div className="space-y-3">
<Field label={t('structure.fields.stage')} value={beat.stage} onChange={stage => set({ stage })} />
<Field label={t('structure.fields.title')} value={beat.title} onChange={title => set({ title })} />
<Field label={t('structure.fields.summary')} value={beat.summary} onChange={summary => set({ summary })} rows={4} />
</div>
<div className="space-y-3">
<Field label={t('structure.fields.goal')} value={beat.goal} onChange={goal => set({ goal })} rows={2} />
<Field label={t('structure.fields.conflict')} value={beat.conflict} onChange={conflict => set({ conflict })} rows={2} />
<Field label={t('structure.fields.turn')} value={beat.turn} onChange={turn => set({ turn })} rows={3} />
<button className="text-red-400 text-xs flex items-center gap-1" onClick={() => update(current => {
current.beats = current.beats.filter(item => item.id !== beat.id)
return current
})}><Trash2 size={12} /> {t('structure.remove')}</button>
</div>
</div>
)
}
78 changes: 78 additions & 0 deletions ui/src/features/stories/CharacterEditor.tsx
Original file line number Diff line number Diff line change
@@ -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<StoryCharacter>) => update(current => {
current.characters = current.characters.map(item => item.id === character.id ? { ...item, approval: 'draft', ...patch } : item)
return current
})
return (
<div id={`story-review-character-${character.id}`} className={`${panel} scroll-mt-4 space-y-3`}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-text-primary">{character.name}</h3>
<button className={`${button} ${character.approval === 'approved' ? 'border-emerald-500 text-emerald-400' : ''}`} onClick={() => set({ approval: character.approval === 'approved' ? 'draft' : 'approved' })}>
<Check size={12} /> {character.approval === 'approved' ? t('status.approved') : t('status.draft')}
</button>
</div>
<div className="flex items-center gap-1">
<button className={button} disabled={index === 0} title={t('characters.moveUp')} onClick={() => update(current => {
moveItem(current.characters, index, index - 1)
return current
})}><ChevronUp size={13} /></button>
<button className={button} disabled={index === total - 1} title={t('characters.moveDown')} onClick={() => update(current => {
moveItem(current.characters, index, index + 1)
return current
})}><ChevronDown size={13} /></button>
<button className="text-red-400 p-1" onClick={() => update(current => {
current.characters = current.characters.filter(item => item.id !== character.id)
current.relationships = current.relationships.filter(item => item.fromCharacterId !== character.id && item.toCharacterId !== character.id)
pruneUnusedAssets(current)
return current
})}><Trash2 size={14} /></button>
</div>
</div>
<div className="grid md:grid-cols-3 gap-3">
<Field label={t('characters.fields.name')} value={character.name} onChange={name => set({ name })} />
<Field label={t('characters.fields.role')} value={character.role} onChange={role => set({ role })} />
<div className="grid grid-cols-2 gap-2">
<Field label={t('characters.fields.age')} value={character.age} onChange={age => set({ age })} />
<Field label={t('characters.fields.pronouns')} value={character.pronouns} onChange={pronouns => set({ pronouns })} />
</div>
<Field label={t('characters.fields.personality')} value={character.personality} onChange={personality => set({ personality })} rows={3} />
<Field label={t('characters.fields.desire')} value={character.desire} onChange={desire => set({ desire })} rows={3} />
<Field label={t('characters.fields.need')} value={character.need} onChange={need => set({ need })} rows={3} />
<Field label={t('characters.fields.flaw')} value={character.flaw} onChange={flaw => set({ flaw })} rows={3} />
<Field label={t('characters.fields.conflict')} value={character.conflict} onChange={conflict => set({ conflict })} rows={3} />
<Field label={t('characters.fields.arc')} value={character.arc} onChange={arc => set({ arc })} rows={3} />
<Field label={t('characters.fields.voice')} value={character.voice} onChange={voice => set({ voice })} rows={3} />
<Field label={t('characters.fields.appearance')} value={character.appearance} onChange={appearance => set({ appearance })} rows={3} />
<Field label={t('characters.fields.wardrobe')} value={character.wardrobe} onChange={wardrobe => set({ wardrobe })} rows={3} />
<Field label={t('characters.fields.visualPrompt')} value={character.visualPrompt} onChange={visualPrompt => set({ visualPrompt })} rows={4} />
<Field label={t('characters.fields.negativePrompt')} value={character.negativePrompt} onChange={negativePrompt => set({ negativePrompt })} rows={4} />
</div>
<div className="flex flex-wrap gap-2">
<button className={button} disabled={Boolean(imageBusy) || !character.visualPrompt.trim()} onClick={() => void generateVisual({ kind: 'character', id: character.id }, character.visualPrompt)}>
{imageBusy === `character:${character.id}` ? <Loader2 size={13} className="animate-spin" /> : <ImagePlus size={13} />} {character.primaryReferenceAssetId ? t('characters.generateVariation') : t('characters.generateFirst')}
</button>
<button className={button} onClick={() => requestUpload({ kind: 'character', id: character.id })}><Upload size={13} /> {t('characters.upload')}</button>
</div>
<ReferenceGallery ids={character.referenceAssetIds} assets={project.assets} primaryId={character.primaryReferenceAssetId} onPrimary={id => set({ primaryReferenceAssetId: id })} onRemove={id => removeReference('character', character.id, id)} />
</div>
)
}
60 changes: 60 additions & 0 deletions ui/src/features/stories/LocationEditor.tsx
Original file line number Diff line number Diff line change
@@ -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<StoryLocation>) => update(current => {
current.world.locations = current.world.locations.map(item => item.id === location.id ? { ...item, ...patch } : item)
return current
})
return (
<div className={`${panel} space-y-3`}>
<div className="flex justify-between gap-2">
<h4 className="text-sm font-semibold text-text-primary">{location.name}</h4>
<div className="flex items-center gap-1">
<button className={button} disabled={index === 0} title={t('location.moveUp')} onClick={() => update(current => {
moveItem(current.world.locations, index, index - 1)
return current
})}><ChevronUp size={13} /></button>
<button className={button} disabled={index === total - 1} title={t('location.moveDown')} onClick={() => update(current => {
moveItem(current.world.locations, index, index + 1)
return current
})}><ChevronDown size={13} /></button>
<button className="text-red-400 p-1" onClick={() => update(current => {
current.world.locations = current.world.locations.filter(item => item.id !== location.id)
pruneUnusedAssets(current)
return current
})}><Trash2 size={14} /></button>
</div>
</div>
<div className="grid md:grid-cols-2 gap-3">
<Field label={t('location.name')} value={location.name} onChange={name => set({ name })} />
<Field label={t('location.purpose')} value={location.purpose} onChange={purpose => set({ purpose })} />
<Field label={t('location.description')} value={location.description} onChange={description => set({ description })} rows={4} />
<Field label={t('location.conceptPrompt')} value={location.visualPrompt} onChange={visualPrompt => set({ visualPrompt })} rows={4} />
<Field label={t('location.negativePrompt')} value={location.negativePrompt} onChange={negativePrompt => set({ negativePrompt })} rows={3} />
</div>
<div className="flex gap-2">
<button className={button} disabled={Boolean(imageBusy) || !location.visualPrompt.trim()} onClick={() => void generateVisual({ kind: 'location', id: location.id }, location.visualPrompt)}>
{imageBusy === `location:${location.id}` ? <Loader2 size={13} className="animate-spin" /> : <ImagePlus size={13} />} {location.referenceAssetIds.length ? t('location.generateAnother') : t('location.generate')}
</button>
<button className={button} onClick={() => requestUpload({ kind: 'location', id: location.id })}><Upload size={13} /> {t('location.addReference')}</button>
</div>
<ReferenceGallery ids={location.referenceAssetIds} assets={project.assets} onRemove={id => removeReference('location', location.id, id)} />
</div>
)
}
Loading