Skip to content

fix(characters): ficha de biblioteca enlazable desde Story Lab (2D y 3D) - #402

Merged
IAnMove merged 5 commits into
developmentfrom
feat/character-sheet-contract-20260912
Sep 12, 2026
Merged

fix(characters): ficha de biblioteca enlazable desde Story Lab (2D y 3D)#402
IAnMove merged 5 commits into
developmentfrom
feat/character-sheet-contract-20260912

Conversation

@IAnMove

@IAnMove IAnMove commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Qué

El personaje de biblioteca (Character Kit) es la ficha reutilizable. Story Lab y Series lo castan, no lo redefinen.

  1. `CharacterKitLink` lista kits 2D y 3D. Video 3D (habla/GLB) sigue filtrando con `requireSpeech3d` y no habilita submit con un recortable 2D.
  2. Story Lab muestra still + TTS del kit. El campo de voz del capítulo es notas de actuación.
  3. Cargar el ejemplo Tijeral inserta Nilo/Berta/Kito (cuerpo+visemas) y stubs de Rami/Paca/Lino en la library del workspace y enlaza `characterKitRef`.
  4. Cada kit tiene un preset local Qwen3 CustomVoice distinto (`dylan`/`serena`/`sohee`/`ryan`/`vivian`/`eric`).
  5. MiniMax Image usa el still del kit enlazado como `subject_reference`. Las rutas `/examples/` se suben antes.

Voces

Los WAV de ejemplo siguen siendo stand-ins. Qwen3 CustomVoice no estaba descargado en este runtime (`is_downloaded: false`; un job de prueba falló al bajar el 1.7B). Para generar las réplicas cuando el modelo esté instalado:

```
HOCUSPOCUS_API=http://127.0.0.1:42006 node ui/scripts/generate_cut_paper_voices.mjs
```

Cómo comprobar

```
cd ui && npx tsx --tsconfig tsconfig.app.json --import ./tests/setupI18n.ts --test tests/cutPaper.test.ts tests/characterKitLink.test.tsx tests/characterSpeechDefinition.test.ts tests/minimaxSubjectReference.test.mjs tests/labsWizardL10.test.mjs
npm run i18n:check
```

No mergear. No toca el checkout compartido.

CharacterKitLink lists every library character, not only speech3d
talkers. Video 3D production still requires a GLB. Story and Series
show the kit still and Qwen voice; acting notes stay on the story row.
lookNotes already persist; docs now say so.
@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown

PR Review — Loreframe Studio

Risk: low
Scope: 36 file(s); +642/-80; React UI, docs

Automated review from scripts/analyze_pr.py. This is a heuristic pass (no LLM) so humans still own the merge decision.

Findings

  • low — UI changed — rebuild before merge
    Run cd ui && npm run build (CI already does this). Pinokio Update rebuilds for end users; keep ui/dist untracked.

Changed files

  • added: ui/scripts/generate_cut_paper_voices.mjs, ui/src/features/characters/CharacterKitSummary.tsx, ui/src/features/characters/useCharacterKitLibrary.ts, ui/src/features/cutPaper/characterKits.ts, ui/tests/characterKitLink.test.tsx, ui/tests/minimaxSubjectReference.test.mjs
  • modified: app/docs/API.md, docs/character-kits/HOWUSEIT.md, ui/public/examples/cut-paper/HOWTO.md, ui/public/examples/cut-paper/PROVENANCE.md, ui/src/features/characters/CharacterKitLink.tsx, ui/src/features/characters/ModelCharacterLink.tsx, ui/src/features/cutPaper/index.ts, ui/src/features/cutPaper/puppet.ts, ui/src/features/cutPaper/storyProject.ts, ui/src/features/scene3d/speech/SpeechProductionEntry.tsx, ui/src/features/scene3d/speech/characterBinding.ts, ui/src/features/series/SeriesVoiceFields.tsx, ui/src/features/stories/CharacterEditor.tsx, ui/src/features/stories/CompactSubjectEditor.tsx, ui/src/features/stories/StoryLabPanel.tsx, ui/src/features/stories/store.ts, ui/src/i18n/locales/en/characters.json, ui/src/i18n/locales/en/scene3dEditor.json, ui/src/i18n/locales/en/seriesLab.json, ui/src/i18n/locales/en/storyLab.json
    … and 10 more

CONTRIBUTING checklist

  • python scripts/verify_clean_repo.py
  • python -m compileall -q app/services app/launch.py scripts
  • cd ui && npm run build if the UI changed
  • No weights, CivitAI sidecars, or generated guides
  • Stays local-first (no required accounts / telemetry)

Posted by the repo PR review workflow. Re-runs on each push to the PR.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: 2D links pass 3D speech gate
    • Speech production now counts a linked kit as complete only when that kit is in the speech3d library, so 2D Story/Series refs no longer enable submit.
  • ✅ Fixed: Kit fetch errors now hidden
    • Story Lab and Series now pass the library hook error into CharacterKitLink, so a failed kit fetch shows its status again instead of an empty dropdown.

Create PR

Or push these changes by commenting:

@cursor push d38d341d01
Preview (d38d341d01)
diff --git a/ui/src/features/characters/CharacterKitLink.tsx b/ui/src/features/characters/CharacterKitLink.tsx
--- a/ui/src/features/characters/CharacterKitLink.tsx
+++ b/ui/src/features/characters/CharacterKitLink.tsx
@@ -6,7 +6,7 @@
 
 /** Stores an id, not a display-name match or a duplicate character definition. */
 export function CharacterKitLink({
-  value, onChange, workspace: scope, disabled, requireSpeech3d = false, kits: kitsOverride,
+  value, onChange, workspace: scope, disabled, requireSpeech3d = false, kits: kitsOverride, error: errorOverride,
 }: {
   value?: CharacterKitRef
   onChange: (ref: CharacterKitRef | undefined) => void
@@ -15,13 +15,14 @@
   /** Video 3D talkers need a GLB. Story/Series cast lists 2D cutouts too. */
   requireSpeech3d?: boolean
   kits?: CharacterKit[]
+  error?: string
 }) {
   const active = useStore(s => s.activeWorkspace)
   const workspace = scope ?? active
   const { t } = useUiTranslation('scene3dEditor')
   const fetched = useCharacterKitLibrary(workspace, requireSpeech3d, !kitsOverride)
   const kits = kitsOverride ? listCharacterKitsFrom(kitsOverride, { requireSpeech3d }) : fetched.kits
-  const error = kitsOverride ? undefined : fetched.error
+  const error = kitsOverride ? errorOverride : fetched.error
   const selected = value?.workspace === workspace ? value.id : ''
   return (
     <label className="block space-y-1 text-xs">

diff --git a/ui/src/features/scene3d/speech/SpeechProductionEntry.tsx b/ui/src/features/scene3d/speech/SpeechProductionEntry.tsx
--- a/ui/src/features/scene3d/speech/SpeechProductionEntry.tsx
+++ b/ui/src/features/scene3d/speech/SpeechProductionEntry.tsx
@@ -8,7 +8,9 @@
 import type { SpeechProductionInput } from './production'
 import { speechInput, SpeechNumber } from './FaceControls'
 import { CharacterKitLink } from '../../characters/CharacterKitLink'
+import { useCharacterKitLibrary } from '../../characters/useCharacterKitLibrary'
 import { fetchCharacterKitLibrary } from '../../../api/characters'
+import type { CharacterKit } from '../../../lib/characterKit'
 import type { CharacterKitRef } from '../../../lib/characterVoice'
 import { characterSlotPatch } from './characterBinding'
 
@@ -23,6 +25,7 @@
 }
 function ScopedSpeechProductionEntry({ kind, title, sourceId, audio, cast: initialCast = [{ id: 'speaker', name: '' }], castOptions, lines, workspace }: ProductionEntryProps & { workspace: string }) {
   const { t } = useUiTranslation('scene3dEditor')
+  const { kits: speech3dKits } = useCharacterKitLibrary(workspace, true)
   const [open, setOpen] = useState(false), [items, setItems] = useState<ApiOutput[]>([])
   const [models, setModels] = useState<Record<string, ApiOutput | undefined>>({})
   const [links, setLinks] = useState<Record<string, CharacterKitRef | undefined>>({})
@@ -65,7 +68,7 @@
       <p className="text-text-muted">{t('speech.phoneticHint')}</p>
       {!phonetic && <p className="text-amber-200">{t('speech.amplitudeHint')}</p>}
       {!source && <p className="text-text-muted">{t('speech.textOnlyProduction')}</p>}
-      <button type="button" className={speechInput} disabled={busy || !hasCompleteCast(cast, models, links)}
+      <button type="button" className={speechInput} disabled={busy || !hasCompleteCast(cast, models, links, speech3dKits)}
         onClick={() => {
           setBusy(true); setError(''); job.current = new AbortController()
           const captured = job.current
@@ -100,6 +103,9 @@
     {options.map(character => <option key={character.id} value={character.id}>{character.name}</option>)}
   </select></label>
 }
-function hasCompleteCast(cast: NonNullable<ProductionEntryProps['cast']>, models: Record<string, ApiOutput | undefined>, links: Record<string, CharacterKitRef | undefined>) {
-  return cast.length > 0 && cast.length <= 2 && cast.every(c => models[c.id] || (Object.hasOwn(links, c.id) ? links[c.id] : c.characterKitRef))
+function linkedSpeech3d(ref: CharacterKitRef | undefined, speech3dKits: CharacterKit[]) {
+  return Boolean(ref && speech3dKits.some(kit => kit.id === ref.id))
 }
+function hasCompleteCast(cast: NonNullable<ProductionEntryProps['cast']>, models: Record<string, ApiOutput | undefined>, links: Record<string, CharacterKitRef | undefined>, speech3dKits: CharacterKit[]) {
+  return cast.length > 0 && cast.length <= 2 && cast.every(c => models[c.id] || linkedSpeech3d(Object.hasOwn(links, c.id) ? links[c.id] : c.characterKitRef, speech3dKits))
+}

diff --git a/ui/src/features/series/SeriesVoiceFields.tsx b/ui/src/features/series/SeriesVoiceFields.tsx
--- a/ui/src/features/series/SeriesVoiceFields.tsx
+++ b/ui/src/features/series/SeriesVoiceFields.tsx
@@ -16,7 +16,7 @@
 }) {
   const { t } = useUiTranslation('seriesLab')
   const workspace = useStore(s => s.activeWorkspace)
-  const { kits } = useCharacterKitLibrary(workspace)
+  const { kits, error } = useCharacterKitLibrary(workspace)
   return (
     <div className="space-y-3">
       {series.characters.map((character, index) => {
@@ -29,6 +29,7 @@
           <CharacterKitLink
             value={character.voiceProfile?.characterKitRef}
             kits={kits}
+            error={error}
             onChange={characterKitRef => {
               const next = kits.find(item => item.id === characterKitRef?.id)
               onPatchVoice(index, {

diff --git a/ui/src/features/stories/CharacterEditor.tsx b/ui/src/features/stories/CharacterEditor.tsx
--- a/ui/src/features/stories/CharacterEditor.tsx
+++ b/ui/src/features/stories/CharacterEditor.tsx
@@ -22,7 +22,7 @@
   const { t } = useUiTranslation('storyLab')
   const { imageBusy, generateVisual, requestUpload, removeReference } = useStoryLabVisuals()
   const workspace = useStore(s => s.activeWorkspace)
-  const { kits } = useCharacterKitLibrary(workspace)
+  const { kits, error } = useCharacterKitLibrary(workspace)
   const set = (patch: Partial<StoryCharacter>) => update(current => {
     current.characters = current.characters.map(item => item.id === character.id ? { ...item, approval: 'draft', ...patch } : item)
     return current
@@ -82,7 +82,7 @@
         <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)} />
-      <CharacterKitLink value={character.characterKitRef} onChange={characterKitRef => set({ characterKitRef })} kits={kits} />
+      <CharacterKitLink value={character.characterKitRef} onChange={characterKitRef => set({ characterKitRef })} kits={kits} error={error} />
       <CharacterKitSummary kit={character.characterKitRef ? kits.find(kit => kit.id === character.characterKitRef?.id) : undefined} />
     </div>
   )

diff --git a/ui/src/features/stories/CompactSubjectEditor.tsx b/ui/src/features/stories/CompactSubjectEditor.tsx
--- a/ui/src/features/stories/CompactSubjectEditor.tsx
+++ b/ui/src/features/stories/CompactSubjectEditor.tsx
@@ -23,7 +23,7 @@
   const { t } = useUiTranslation('storyLab')
   const { imageBusy, generateVisual, requestUpload, removeReference } = useStoryLabVisuals()
   const workspace = useStore(s => s.activeWorkspace)
-  const { kits } = useCharacterKitLibrary(workspace)
+  const { kits, error } = useCharacterKitLibrary(workspace)
   const set = (change: Partial<StoryCharacter>) => update(current => {
     current.characters = current.characters.map(item => item.id === character.id
       ? { ...item, approval: 'draft', ...change } : item)
@@ -66,7 +66,7 @@
       <details className="rounded border border-border px-2 py-1.5 text-[10px] text-text-muted">
         <summary className="cursor-pointer text-text-secondary">{t('compact.optionalVoice')}</summary>
         <div className="mt-2 space-y-2">
-          <CharacterKitLink value={character.characterKitRef} onChange={characterKitRef => set({ characterKitRef })} kits={kits} />
+          <CharacterKitLink value={character.characterKitRef} onChange={characterKitRef => set({ characterKitRef })} kits={kits} error={error} />
           <CharacterKitSummary kit={character.characterKitRef ? kits.find(kit => kit.id === character.characterKitRef?.id) : undefined} />
         </div>
         <div className="mt-2 grid gap-2 sm:grid-cols-2">

You can send follow-ups to the cloud agent here.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 3800005. Configure here.

onChange={id => { setStoryCharacter(id); setLinks({}); setModels({}) }} />
{cast.length > 2 ? <p role="alert">{t('speech.twoSpeakers')}</p> : cast.map(character => <div key={character.id} className="space-y-2">
<CharacterKitLink workspace={workspace} value={Object.hasOwn(links, character.id) ? links[character.id] : character.characterKitRef}
<CharacterKitLink workspace={workspace} requireSpeech3d value={Object.hasOwn(links, character.id) ? links[character.id] : character.characterKitRef}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2D links pass 3D speech gate

Medium Severity

Story Lab and Series can now store a 2D characterKitRef. Video 3D speech production still treats any ref as a complete speaker, so trailer, song, and series-shot entry stay submittable. The requireSpeech3d dropdown marks that kit unavailable, then submit fails because the kit has no GLB.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3800005. Configure here.

const { t } = useUiTranslation('storyLab')
const { imageBusy, generateVisual, requestUpload, removeReference } = useStoryLabVisuals()
const workspace = useStore(s => s.activeWorkspace)
const { kits } = useCharacterKitLibrary(workspace)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kit fetch errors now hidden

Medium Severity

Story Lab and Series load kits in the parent and pass only kits into CharacterKitLink. That override skips the child fetch and clears its error slot. A failed useCharacterKitLibrary call therefore becomes an empty dropdown with no status, and an already-linked id looks missing.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3800005. Configure here.

@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown

Code health

Quality score: 63.7/100

Higher is better. The score is a trend dashboard; the independent ratchet below remains the CI gate.

Component Weight Current Change
Cyclomatic health 45% 59.2 +0.0
File concentration 25% 73.6 +0.1
Oversized-file debt 20% 49.3 +0.1
Modularity 10% 87.9 +0.1

Change vs PR base: +0.1 points.

Metric Value
Production LOC 313,124
Production files 1,042
Test LOC 127,224
Functions measured 21,308
Functions complexity ≥ 15 901
Maximum complexity 674
Policy code-health-policy-v1
HEAD 0796c63c38f088ae10b8164577c83e56bc7c9adc
Base 991c1910c941d70c99c1d69c92ac2611a5ded3ae
UI measurement complete

Markdown, JSON catalogs and tests are out of this table. Only app/ runtime + ui/src TS/JS count.

Most complex functions

Complexity Where
674 app/wgp.py:7230 generate_video
382 ui/src/stores/useStore.ts:4129 Async method 'startGeneration'
355 app/_launch_runtime.py:23671 _run_generation
308 app/wgp.py:12372 generate_video_tab
269 ui/src/components/Sidebar/SceneAnimatorPanel.tsx:508 Function 'SceneAnimatorPanel'
264 ui/src/stores/useStore.ts:8684 Async method 'loadSettingsFromOutput'
258 app/services/director/planners/short_film.py:3433 ShortFilmPlanner._plan_story_driven
248 app/services/director_pipeline.py:13612 _run_video_generation
245 app/services/director_pipeline.py:7712 _run_pipeline
233 ui/src/features/agent/agentActions.ts:1152 Function 'parseAction'
226 app/services/director_pipeline.py:6541 update_comic_preview
225 ui/src/features/agent/agentActions.ts:2998 Async function 'executeAgentActions'

Trend vs baseline

Metric Δ
Production LOC +300
Test LOC +170
Functions ≥ 15 +0
Maximum complexity +0

Warnings

  • production LOC increased by +300
  • complexity hotspot ui/src/features/series/SeriesVoiceFields.tsx rose 19 -> 24
  • complexity hotspot ui/src/features/stories/CompactSubjectEditor.tsx rose 18 -> 19
  • hotspot ui/src/features/stories/StoryLabPanel.tsx increased by +19 lines

Ratchet passed.

L10 still requires the Series voice bible to say H3 is not driven by
those fields. The Spanish copy now starts with TTS, so the capital N
is no longer at the start of the sentence.
Speech production only enables submit when the linked kit has speech3d
or the user picked a GLB. Story/Series still pass library fetch errors
into CharacterKitLink so a failed load is not an empty silent dropdown.
Loading the example inserts Nilo/Berta/Kito (bodies+visemes) and
Rami/Paca/Lino stubs into the workspace Character Kit library and
links Story Lab rows via characterKitRef. Each kit stores a distinct
local Qwen3 CustomVoice. MiniMax Image uses the linked kit still;
bundled /examples paths are uploaded first.
tsc rejected the ternary that left CharacterKit.anchors.base as
undefined for Rami/Paca/Lino. Speaking puppets still get bodies,
mouths and pose-local anchors.
@IAnMove
IAnMove merged commit 18a444f into development Sep 12, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant