fix(characters): ficha de biblioteca enlazable desde Story Lab (2D y 3D) - #402
Conversation
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.
PR Review — Loreframe StudioRisk: low Automated review from Findings
Changed files
CONTRIBUTING checklist
Posted by the repo PR review workflow. Re-runs on each push to the PR. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
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.
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} |
There was a problem hiding this comment.
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)
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) |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 3800005. Configure here.
Code healthQuality score: 63.7/100Higher is better. The score is a trend dashboard; the independent ratchet below remains the CI gate.
Change vs PR base: +0.1 points.
Markdown, JSON catalogs and tests are out of this table. Only Most complex functions
Trend vs baseline
Warnings
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.



Qué
El personaje de biblioteca (Character Kit) es la ficha reutilizable. Story Lab y Series lo castan, no lo redefinen.
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.