- {m.resource_requirements && (
-
- {m.resource_requirements.vram_gb != null
- ? `~${m.resource_requirements.vram_gb} GB VRAM`
- : m.resource_requirements.storage_gb != null
- ? `~${m.resource_requirements.storage_gb} GB`
- : 'info'}
+ {vramGb != null && (
+
+ {t('modelCatalog.vramBadge', { vram: vramGb })}
)}
@@ -426,7 +442,7 @@ function ModelVisibilitySection() {
)}
-
+
{alsoDeletes.length > 0 && (
Shared weights — also deletes: {alsoDeletes.join(', ')}
@@ -1009,6 +1025,7 @@ export function SystemSettingsPanel() {
const updateConfig = useStore(s => s.updateSystemConfig)
const servicesConfig = useStore(s => s.servicesConfig)
const updateServicesConfig = useStore(s => s.updateServicesConfig)
+ const cudaControls = showsCudaControls(usePlatformCapabilities())
// Detected VRAM is used in the VRAM coefficient subtext (see below)
// so the "Max VRAM target: ~X GB of Y GB" line shows real numbers
// instead of a hardcoded 24 GB. AutoPerformanceCard populates this
@@ -1206,14 +1223,12 @@ export function SystemSettingsPanel() {
- {/* Auto-tune card always visible. The fields below are
- conditionally hidden based on autoOn. */}
-
+ {cudaControls &&
}
{/* Auto ON: collapse the advanced fields under an expander.
The expander defaults closed — power users who want to peek
at what auto picked can open it without leaving the page. */}
- {autoOn ? (
+ {cudaControls && (autoOn ? (
setAdvancedOpen(o => !o)}
@@ -1229,13 +1244,10 @@ export function SystemSettingsPanel() {
)}
) : (
- // Auto OFF: show fields directly + a "Reset to auto-tune"
- // affordance below them. The Reset button just toggles auto
- // back ON, which triggers the apply endpoint via the card.
<>
{renderAdvancedFields()}
>
- )}
+ ))}
diff --git a/ui/src/components/Sidebar/DirectorChat.tsx b/ui/src/components/Sidebar/DirectorChat.tsx
index d396384df..d0044bd88 100644
--- a/ui/src/components/Sidebar/DirectorChat.tsx
+++ b/ui/src/components/Sidebar/DirectorChat.tsx
@@ -1,6 +1,6 @@
import { DirectorModelPicker } from './DirectorModelPicker'
import { lazy, Suspense, useState, useCallback, useRef, useMemo, useEffect } from 'react'
-import { Upload, Loader2, Music, RotateCcw, Check, X, ChevronRight, ChevronDown, ImageIcon, Play, Film, Mic, Sparkles, Send, Users, FileText, Clock, BookOpen, Zap } from 'lucide-react'
+import { Upload, Loader2, Music, RotateCcw, Check, X, ChevronRight, ChevronDown, ImageIcon, Play, Film, Mic, Sparkles, Send, Users, FileText, Clock, BookOpen, Zap, Download } from 'lucide-react'
import { useStore, resolveResolution } from '../../stores/useStore'
import { fetchModelOptions } from '../../api/client'
import { MINIMAX_IMAGE_API_MODEL } from '../../lib/externalModels'
@@ -2134,6 +2134,15 @@ function AnalysisSummary({
const speakerCount = new Set(
(analysis.lyrics || []).map(l => l.speaker).filter(Boolean)
).size
+ const downloadSrt = () => {
+ if (!analysis.lyrics_srt) return
+ const url = URL.createObjectURL(new Blob([analysis.lyrics_srt], { type: 'application/x-subrip;charset=utf-8' }))
+ const anchor = document.createElement('a')
+ anchor.href = url
+ anchor.download = 'lyrics-timeline.srt'
+ anchor.click()
+ URL.revokeObjectURL(url)
+ }
return (
@@ -2145,6 +2154,18 @@ function AnalysisSummary({
{warning}
))}
+ {analysis.lyric_timing && !isShortFilm && (
+
+
+ Source-audio lyric timeline · {Math.round(analysis.lyric_timing.coverage * 100)}% word coverage
+
+ {analysis.lyrics_srt && (
+
+ SRT
+
+ )}
+
+ )}
setShowDetails(v => !v)}
className="flex items-center gap-3 text-[11px] text-text-muted w-full hover:text-text-secondary transition-colors"
@@ -2179,7 +2200,9 @@ function AnalysisSummary({
{analysis.lyrics && analysis.lyrics.length > 0 && (
- Lyrics {analysis.song_structure?.length ? '(LLM Structure)' : '(Whisper)'}
+ Lyrics {analysis.lyric_timing?.method === 'authoritative_lyrics_word_alignment'
+ ? '(Source audio alignment)'
+ : analysis.song_structure?.length ? '(Structured)' : '(Whisper)'}
{analysis.song_structure && analysis.song_structure.length > 0 ? (
diff --git a/ui/src/components/Sidebar/GenerateButton.tsx b/ui/src/components/Sidebar/GenerateButton.tsx
index f8a8a7786..427973578 100644
--- a/ui/src/components/Sidebar/GenerateButton.tsx
+++ b/ui/src/components/Sidebar/GenerateButton.tsx
@@ -6,13 +6,14 @@ import { splitPromptSchedule } from '../../lib/promptScheduler'
import { newUserGenerationContext } from '../../features/studio/generationProvenance'
import { useViggleGenerationGuard } from '../../lib/useViggleGenerationGuard'
import { isGenerationJobActive } from '../../lib/generationJobState'
+import { usePlatformCapabilities } from '../../lib/usePlatformCapabilities'
+import { generateBlockedCopy, isRemoteMiniMaxImage } from '../../lib/generateButtonGate'
export function GenerateButton() {
const { t } = useUiTranslation('studio')
const { t: tCommon } = useUiTranslation('common')
const jobs = useStore(s => s.jobs)
const startGeneration = useStore(s => s.startGeneration)
- const setSidebarOpen = useStore(s => s.setSidebarOpen)
const [submitting, setSubmitting] = useState(false)
const [submissionError, setSubmissionError] = useState('')
const submissionPending = useRef(false)
@@ -51,7 +52,11 @@ export function GenerateButton() {
const schedulerApplies = promptSchedulerEnabled && generationMode === 'video' && imageMode === 0
const scheduledVideoCount = schedulerApplies ? splitPromptSchedule(prompt).length : 0
const needsScheduledPrompts = schedulerApplies && scheduledVideoCount === 0
- const blocked = needsImage || needsReference || needsOutpaintSource || needsOutpaintArea || needsScheduledPrompts
+ const modelType = useStore(s => s.params.model_type)
+ const imageProvider = useStore(s => s.productionProfile?.image?.provider)
+ const localUnavailable = usePlatformCapabilities()?.capabilities.wangp_local?.state === 'hidden'
+ && !isRemoteMiniMaxImage(generationMode, modelType, imageProvider)
+ const blocked = localUnavailable || needsImage || needsReference || needsOutpaintSource || needsOutpaintArea || needsScheduledPrompts
const handleClick = async () => {
if (blocked || submissionPending.current) return
@@ -63,7 +68,6 @@ export function GenerateButton() {
// Keep the visible command panel mounted through preparation/admission.
// A click or a resolved legacy return value is not a queue receipt.
await startGeneration(undefined, newUserGenerationContext())
- setSidebarOpen(false)
} catch (error) {
setSubmissionError(error instanceof Error ? error.message : tCommon('status.failed'))
} finally {
@@ -75,20 +79,9 @@ export function GenerateButton() {
const queueCount = jobs.filter(job => isGenerationJobActive(job.status)).length
if (blocked) {
- const label = needsImage
- ? t('generate.needImage')
- : needsReference
- ? t('generate.needReference')
- : needsOutpaintSource
- ? t('generate.needSource')
- : needsOutpaintArea
- ? t('generate.chooseCanvas')
- : t('generate.addPrompt')
- const title = needsOutpaintArea
- ? t('generate.outpaintAreaHint')
- : needsReference
- ? t('generate.referenceHint')
- : undefined
+ const { label, title } = generateBlockedCopy({
+ localUnavailable, needsImage, needsReference, needsOutpaintSource, needsOutpaintArea, t,
+ })
return (
+ }
+ const entry = resolveModelCatalog(model)
+ const { requirements } = entry
+ return (
+
+
{t(`modelCatalog.${entry.variant}Hint`)}
+
{t(`modelCatalog.capability.${entry.capability}`)}
+
+ {t('modelCatalog.requirements')}
+ {requirements.vram_gb != null && (
+ {t('modelCatalog.vram', { vram: requirements.vram_gb })}
+ )}
+ {requirements.comfortable_vram_gb != null
+ && requirements.comfortable_vram_gb !== requirements.vram_gb && (
+ {t('modelCatalog.vramComfort', { vram: requirements.comfortable_vram_gb })}
+ )}
+ {requirements.ram_gb != null && (
+ {t('modelCatalog.ram', { ram: requirements.ram_gb })}
+ )}
+ {requirements.storage_gb != null && (
+ {t('modelCatalog.storage', { storage: requirements.storage_gb })}
+ )}
+ {t('modelCatalog.limit')}
+
+
+ )
+}
diff --git a/ui/src/components/Sidebar/ModelSelector.tsx b/ui/src/components/Sidebar/ModelSelector.tsx
index d2b5d48cf..acea12cbf 100644
--- a/ui/src/components/Sidebar/ModelSelector.tsx
+++ b/ui/src/components/Sidebar/ModelSelector.tsx
@@ -1,10 +1,13 @@
import { ChevronDown, Check, Plus } from 'lucide-react'
import { useState, useRef, useEffect } from 'react'
+import type { TFunction } from 'i18next'
import { useStore, getFamiliesForMode, getModelsForFamily } from '../../stores/useStore'
import { useUiTranslation } from '../../i18n'
+import { h3CatalogEntry } from '../../lib/h3Catalog'
+import { catalogVramGb, resolveModelCatalog } from '../../lib/modelCatalog'
+import type { ModelDef } from '../../types'
import { InfoTooltip } from './InfoTooltip'
import { H3ModelName } from './H3ModelInfo'
-import { modelRequirementsText } from '../../lib/minimaxMusicCatalog'
export function ModelSelector() {
const { t } = useUiTranslation('studio')
@@ -74,7 +77,7 @@ export function ModelSelector() {
{/* Trigger button */}
setOpen(!open)}
- title={currentModel?.selector_help || currentModel?.description}
+ title={currentModel ? selectorModelHelp(currentModel, t) : undefined}
className="w-full flex items-center gap-1.5 bg-bg-tertiary border border-border rounded-lg px-2.5 py-2 text-left hover:border-border-light transition-colors"
>
@@ -108,8 +111,8 @@ export function ModelSelector() {
{/* Models in family */}
{famModels.map(model => {
const isSelected = model.model_type === currentModelType
- const requirements = modelRequirementsText(model.resource_requirements)
- const help = [model.selector_help, requirements].filter(Boolean).join('\n\n')
+ const help = selectorModelHelp(model, t)
+ const vramGb = catalogVramGb(model)
return (
- {model.resource_requirements?.vram_gb != null && (
-
- ~{model.resource_requirements.vram_gb} GB VRAM
+ {vramGb != null && (
+
+ {t('modelCatalog.vramBadge', { vram: vramGb })}
)}
-
+
{isSelected && }
{help && (
@@ -155,6 +158,24 @@ export function ModelSelector() {
)
}
+function selectorModelHelp(model: ModelDef, t: TFunction<'studio'>): string {
+ const h3 = h3CatalogEntry(model.model_type)
+ if (h3) {
+ return [t(`h3Catalog.${h3.variant}Hint`), t('h3Catalog.memory')].join('\n\n')
+ }
+ const catalog = resolveModelCatalog(model)
+ return [
+ t(`modelCatalog.${catalog.variant}Hint`),
+ t(`modelCatalog.capability.${catalog.capability}`),
+ catalog.requirements.vram_gb != null ? t('modelCatalog.vram', { vram: catalog.requirements.vram_gb }) : '',
+ catalog.requirements.ram_gb != null ? t('modelCatalog.ram', { ram: catalog.requirements.ram_gb }) : '',
+ catalog.requirements.storage_gb != null
+ ? t('modelCatalog.storage', { storage: catalog.requirements.storage_gb })
+ : '',
+ t('modelCatalog.limit'),
+ ].filter(Boolean).join('\n\n')
+}
+
function ModelBadges({ model }: {
model: {
model_type: string
diff --git a/ui/src/components/Sidebar/SceneAnimatorPanel.tsx b/ui/src/components/Sidebar/SceneAnimatorPanel.tsx
index d2d72ec49..79ab9b6a6 100644
--- a/ui/src/components/Sidebar/SceneAnimatorPanel.tsx
+++ b/ui/src/components/Sidebar/SceneAnimatorPanel.tsx
@@ -1,11 +1,13 @@
import { sceneAudioWav, supportsSceneAac } from '../../features/sceneFx/audioExport'
import { paintSceneFx } from '../../features/sceneFx/paint'
+import { waitForSceneImages } from '../../lib/sceneMediaReady'
import { mixFxAudio } from '../../features/sceneFx/mix'
import { encodeSpeechAudio } from '../../features/scene3d/speech/encodeAudio'
import { presentSceneDocument, useSceneDocumentHandoff } from '../../features/sceneFx/handoff'
import { galleryWorkspaceEpoch, galleryWorkspaceName } from '../../stores/gallerySlice'
import { SceneFxControls } from '../../features/sceneFx/SceneFxControls'
import { SceneFxOverlay } from '../../features/sceneFx/SceneFxOverlay'
+import { isRetroLook } from '../../features/sceneFx/retroPaint'
import { adoptPreparedSceneDocument, withFxShowcase } from '../../features/sceneFx/showcase'
import { KineticTextControls } from '../common/KineticTextControls'
import { KineticTextOverlay } from '../common/KineticTextOverlay'
@@ -41,6 +43,7 @@ import { TemplateComposerDialog } from '../../features/sceneTemplates/TemplateCo
import type { SceneRecipe } from '../../lib/sceneRecipe'
import { sceneToRecipe } from '../../lib/sceneToRecipe'
import { parseSceneFile, sceneFileName, serializeSceneFile } from '../../lib/sceneFile'
+import { SceneHandoffRecovery } from '../../lib/sceneRecovery'
import { SceneLibraryDialog } from './SceneLibraryDialog'
import { PENDING_SCENE_KEY } from '../../lib/sceneOutput'
import { loadAgentLibraryScene } from '../../lib/agentSceneOpen'
@@ -49,7 +52,7 @@ import { assessNarrativeAsset } from '../../lib/assetSuitability'
import { getSceneClipTime } from '../../lib/sceneClip'
import { sanitizeSceneMotion } from '../../lib/sceneMotion'
import { applySceneRhythmToLayer, buildSceneRhythmMap, type SceneRhythmCueSource, type SceneRhythmProfile } from '../../lib/sceneRhythm'
-import { applyCutoutDialogue, bindCutoutFaceToPose, ensureCutoutFacePlayback, findCutoutMouthLayers, isCutoutFaceLayer, normalizeFaceBinding, planCutoutDialogue, rebuildCutoutDialogueLayers, type SceneDialogueBeat } from '../../lib/cutoutDialogue'
+import { applyCutoutDialogue, bindCutoutFaceToPose, ensureCutoutFacePlayback, findCutoutMouthLayers, isCutoutFaceLayer, normalizeAlignedCutoutUnits, normalizeFaceBinding, planAlignedCutoutDialogue, planCutoutDialogue, rebuildCutoutDialogueLayers, type SceneDialogueBeat } from '../../lib/cutoutDialogue'
import { captureCharacterFaceAnchor, characterKitAssetFromLayer, claimUnusedCharacterKitId, createCharacterKit, emptyCharacterKitLibrary, mountCharacterKitLayers, syncMountedCharacterKitLayers, syncSceneCharacterKits, type CharacterKit, type CharacterKitAlphaStatus, type CharacterMouthState } from '../../lib/characterKit'
import { consumeFaceRigHandoff, FACE_RIG_HANDOFF_EVENT, kitFromFaceRigHandoff } from '../../lib/characterKitHandoff'
import { rememberCharacterKitLibrary, rememberVideo3dScene } from '../../features/agent/wizardLabSession'
@@ -58,6 +61,7 @@ import { applySceneCopilotProposal, buildSceneCopilotSystemPrompt, buildSceneSco
import { evaluateSceneLayer, getSceneEvents, getSceneKeyframes, getSceneLayerTiming, mapSceneAnimationPoints, normalizeSceneEvents, normalizeSceneKeyframes, sceneLayerMotionProgress, sceneProgressFromSeconds, sceneTimeToLayerTime, withNormalizedSceneTiming, withSceneKeyframes } from '../../lib/sceneTimeline'
import { normalizeSeamOccluder, paintSeamOccluder, seamOccluderDataUri, type SeamOccluderKind } from '../../lib/seamOccluder'
import type { AudioAnalysisResult, Scene, SceneAnimationEvent, SceneAtmosphereKind, SceneBlendMode, SceneCurve, SceneFrameRate, SceneKeyframe, SceneLayer, SceneLayerType, SceneMask } from '../../types'
+import { canonicalSceneFps } from '../../lib/sceneFps.ts'
import { SceneTimeline } from './SceneTimeline'
import { CylinderPanoramaComparison } from './CylinderPanoramaComparison'
import { CharacterKitLibraryPanel } from '../../features/characters/CharacterKitLibraryPanel'
@@ -511,10 +515,12 @@ export function SceneAnimatorPanel() {
const workspace = useStore(s => s.activeWorkspace)
const setGenerationMode = useStore(s => s.setGenerationMode)
const setSidebarMode = useStore(s => s.setSidebarMode)
+ const setMediaFilter = useStore(s => s.setMediaFilter)
const setSidebarOpen = useStore(s => s.setSidebarOpen)
const selectedSpeechModel = useStore(s => s.selectedModelPerAudioSubMode.speech ?? 'kugelaudio_0_open')
const [scene, setScene] = useState(blankScene)
const sceneRef = useRef(scene)
+ const sceneRecovery = useRef(new SceneHandoffRecovery())
const [selectedId, setSelectedId] = useState(null)
const [addOpen, setAddOpen] = useState(false)
const [templateComposerOpen, setTemplateComposerOpen] = useState(false)
@@ -605,6 +611,7 @@ export function SceneAnimatorPanel() {
const [clipDurationsByLayer, setClipDurationsByLayer] = useState>({})
const canvasRef = useRef(null)
+ const retroCanvasRef = useRef(null)
const animationRef = useRef(null)
const recordingAnimationRef = useRef(null)
const mediaRecorderRef = useRef(null)
@@ -644,7 +651,7 @@ export function SceneAnimatorPanel() {
return [t('animator.suggestionDefault1'), t('animator.suggestionDefault2')]
})() : []
const composition = { ...DEFAULT_COMPOSITION, ...scene.composition }
- const fps: SceneFrameRate = scene.fps === 60 ? 60 : 30
+ const fps: SceneFrameRate = canonicalSceneFps(scene.fps)
const snapCoordinate = (value: number) => composition.snap ? Math.round(value / Math.max(1, composition.gridSize)) * Math.max(1, composition.gridSize) : value
const generatedModels = outputs.filter(output => output.type === 'model3d' && /\.glb$/i.test(output.name))
const generatedMedia = outputs.filter(output => output.type === 'image' || output.type === 'video')
@@ -1780,7 +1787,7 @@ export function SceneAnimatorPanel() {
const previousObjectUrls = new Set(sceneRef.current.layers.flatMap(layer => [layer.source, layer.thumbnail].filter((value): value is string => Boolean(value?.startsWith('blob:')))))
previousObjectUrls.forEach(url => URL.revokeObjectURL(url))
const missingAssets = layers.filter(layer => layer.type !== 'camera' && layer.missingAsset).length
- generationRef.current += 1; pendingBindRef.current = null; localFilesRef.current = {}; pastScenesRef.current = []; futureScenesRef.current = []; lastHistoryAtRef.current = 0; replaceScene({ ...blankScene(), ...incoming, texts: parseKineticTexts(incoming.texts), name: typeof incoming.name === 'string' && incoming.name.trim() ? incoming.name : 'Imported scene', width, height, fps: incoming.fps === 60 ? 60 : 30, duration, layers, composition }); setHistoryRevision(value => value + 1); setSelectedId(layers[0]?.id ?? null); setSelectedKeyframeId(null); setSelectedEventId(null); setProgress(0); setMessage(successMessage ?? `${t('animator.imported', { count: layers.length })}${missingAssets ? t('animator.reassignMissing', { count: missingAssets }) : ''}`); setJsonOpen(false)
+ generationRef.current += 1; pendingBindRef.current = null; localFilesRef.current = {}; pastScenesRef.current = []; futureScenesRef.current = []; lastHistoryAtRef.current = 0; replaceScene({ ...blankScene(), ...incoming, texts: parseKineticTexts(incoming.texts), name: typeof incoming.name === 'string' && incoming.name.trim() ? incoming.name : 'Imported scene', width, height, fps: canonicalSceneFps(incoming.fps), duration, layers, composition }); setHistoryRevision(value => value + 1); setSelectedId(layers[0]?.id ?? null); setSelectedKeyframeId(null); setSelectedEventId(null); setProgress(0); setMessage(successMessage ?? `${t('animator.imported', { count: layers.length })}${missingAssets ? t('animator.reassignMissing', { count: missingAssets }) : ''}`); setJsonOpen(false)
return true
} catch (error) { setMessage(error instanceof Error ? error.message : t('animator.invalidSceneJson')); return false }
}
@@ -1906,6 +1913,16 @@ export function SceneAnimatorPanel() {
paintKineticTexts(context, canvas.width, canvas.height, sceneSeconds, current.texts)
return true
}
+ const sceneSecondsNow = progress * scene.duration
+ const retroLive = (scene.sfx ?? []).some(cue => isRetroLook(cue.kind) && sceneSecondsNow >= cue.start && sceneSecondsNow < cue.end)
+ useEffect(() => {
+ if (!retroLive) return
+ const canvas = retroCanvasRef.current
+ if (!canvas) return
+ canvas.width = scene.width
+ canvas.height = scene.height
+ paintScene(canvas, progress)
+ })
// Compatibility fallback for browsers without WebCodecs. Chromium uses the
// deterministic MP4 path below so slow WebGL frames never change timing.
const recordCompatibilityWebm = (): Promise => new Promise((resolve, reject) => {
@@ -1913,7 +1930,7 @@ export function SceneAnimatorPanel() {
if (playing) { const error = new Error(t('animator.waitPreview')); setMessage(error.message); reject(error); return }
prepareFacePlayback()
const current = sceneRef.current
- const currentFps: SceneFrameRate = current.fps === 60 ? 60 : 30
+ const currentFps: SceneFrameRate = canonicalSceneFps(current.fps)
if (!current.sfx?.length && !current.layers.some(layer => layer.visible && isVisualLayer(layer))) { const error = new Error(t('animator.addVisibleLayer')); setMessage(error.message); reject(error); return }
if (!('MediaRecorder' in window)) { const error = new Error(t('animator.cannotRecord')); setMessage(error.message); reject(error); return }
const canvas = document.createElement('canvas'); canvas.width = current.width; canvas.height = current.height; const context = canvas.getContext('2d'); if (!context) { reject(new Error('Could not create a recording canvas.')); return }
@@ -2118,7 +2135,7 @@ export function SceneAnimatorPanel() {
return recordCompatibilityWebm()
}
const current = sceneRef.current
- const fps: SceneFrameRate = current.fps === 60 ? 60 : 30
+ const fps: SceneFrameRate = canonicalSceneFps(current.fps)
if (!current.sfx?.length && !current.layers.some(layer => layer.visible && isVisualLayer(layer))) throw new Error(t('animator.addVisibleLayer'))
const canvas = document.createElement('canvas')
canvas.width = current.width
@@ -2137,11 +2154,12 @@ export function SceneAnimatorPanel() {
throw new Error('This browser cannot encode a deterministic H.264 MP4 at the selected resolution.')
}
- const fxAudio = await supportsSceneAac() ? await mixFxAudio(current.sfx, current.duration) : undefined
+ const mixedFx = await mixFxAudio(current.sfx, current.duration)
+ const fxAudio = mixedFx && (await supportsSceneAac(mixedFx.numberOfChannels >= 2 ? 2 : 1)) ? mixedFx : undefined
const target = new ArrayBufferTarget()
const muxer = new Muxer({
target,
- ...(fxAudio ? { audio: { codec: 'aac' as const, sampleRate: fxAudio.sampleRate, numberOfChannels: 1 } } : {}),
+ ...(fxAudio ? { audio: { codec: 'aac' as const, sampleRate: fxAudio.sampleRate, numberOfChannels: Math.min(2, Math.max(1, fxAudio.numberOfChannels || 1)) } } : {}),
video: { codec: 'avc', width: current.width, height: current.height, frameRate: fps },
fastStart: 'in-memory',
firstTimestampBehavior: 'strict',
@@ -2192,12 +2210,13 @@ export function SceneAnimatorPanel() {
updateScene(() => adopted.document)
return
}
- sessionStorage.setItem('hocuspocus:scene-before-command:' + Date.now(), JSON.stringify(sceneRef.current))
+ sceneRecovery.current.backup(sessionStorage, workspace, sceneRef.current)
if (!importScene(JSON.stringify(adopted.document))) throw new Error('The prepared 2D scene could not be opened.')
})
const publishRecording = async (blob: Blob, current: Scene) => {
const context = recipeContextRef.current
- const buffer = !(await supportsSceneAac()) ? await mixFxAudio(current.sfx, current.duration) : undefined
+ const mixedPublish = await mixFxAudio(current.sfx, current.duration)
+ const buffer = mixedPublish && !(await supportsSceneAac(mixedPublish.numberOfChannels >= 2 ? 2 : 1)) ? mixedPublish : undefined
const serverAudio = buffer ? sceneAudioWav(buffer) : undefined
const saved = await saveSceneRecording(blob, {
scene: current,
@@ -2225,6 +2244,7 @@ export function SceneAnimatorPanel() {
.finally(() => setPublishing(false))
}
const waitForModelViewers = async () => {
+ await waitForSceneImages(canvasRef.current, sceneRef.current.layers)
const root = canvasRef.current
if (!root) return
const deadline = Date.now() + 25000
@@ -2296,6 +2316,7 @@ export function SceneAnimatorPanel() {
}))
const persisted = { ...current, layers }
const saved = await saveSceneOutput(persisted, preview.toDataURL('image/png'), workspace)
+ sceneRecovery.current.markSaved(workspace, persisted)
replaceScene(persisted); localFilesRef.current = {}; await loadOutputs()
setMessage(t('animator.sceneSaved', { name: saved.name }))
return saved.name
@@ -2453,7 +2474,7 @@ export function SceneAnimatorPanel() {
const sendImageToPanoramaLoop = () => {
if (!selected || selected.type !== 'image' || !selected.source) return
window.sessionStorage.setItem('hocuspocus:panorama-loop-source', JSON.stringify({ url: selected.source, name: selected.name }))
- setGenerationMode('image'); setSidebarMode('studio'); setSidebarOpen(true)
+ setGenerationMode('image'); setSidebarMode('studio'); setMediaFilter('images'); setSidebarOpen(true)
}
const attachSceneAudio = (filename: string, name = filename, kind: 'speech' | 'music' | 'sfx' | 'audio' = 'audio', prompt?: string, model?: string) => {
if (!filename) return
@@ -2856,24 +2877,20 @@ export function SceneAnimatorPanel() {
if (!segments.length) throw new Error('No spoken regions were found in this track.')
// Use actual word boundaries whenever Whisper provides them. Older
// analyses remain valid: they fall back to one plan per segment.
- const units = segments.flatMap(segment => segment.words?.length
+ const units = normalizeAlignedCutoutUnits(segments.flatMap(segment => segment.words?.length
? segment.words.map(word => ({ text: word.text, start: word.start, end: word.end }))
- : [{ text: segment.text, start: segment.start, end: segment.end }])
- .filter(unit => unit.end > unit.start && unit.start + track.startTime < scene.duration)
- const plans = units.map(unit => planCutoutDialogue(unit.text, Math.max(0, unit.start + track.startTime), Math.min(scene.duration, unit.end + track.startTime), fps))
- const framesByLayer: Record = {}
- for (const plan of plans) {
- const next = applyCutoutDialogue(mouthLayers, plan)
- for (const [layerId, frames] of Object.entries(next)) framesByLayer[layerId] = [...(framesByLayer[layerId] ?? []), ...frames]
- }
- const beatIds = plans.map(() => uid())
+ : [{ text: segment.text, start: segment.start, end: segment.end }]), track.startTime, scene.duration)
+ if (!units.length) throw new Error('No spoken regions were found in this track.')
+ const plan = planAlignedCutoutDialogue(units, fps)
+ const framesByLayer = applyCutoutDialogue(mouthLayers, plan)
+ const beatIds = units.map(() => uid())
updateScene(current => ({
...current,
layers: current.layers.map(layer => framesByLayer[layer.id] ? { ...layer, animation: { ...layer.animation, keyframes: framesByLayer[layer.id], duration: current.duration, curve: 'hold' } } : layer),
- dialogueBeats: [...(current.dialogueBeats ?? []).filter(beat => !beat.mouthLayerIds.some(id => Object.keys(framesByLayer).includes(id))), ...plans.map((plan, index) => ({ id: beatIds[index], text: units[index].text, start: plan.start, end: plan.end, mouthLayerIds: Object.keys(framesByLayer), audioTrackId: track.id, confidence: 'aligned-audio' as const }))],
+ dialogueBeats: [...(current.dialogueBeats ?? []).filter(beat => !beat.mouthLayerIds.some(id => Object.keys(framesByLayer).includes(id))), ...units.map((unit, index) => ({ id: beatIds[index], ...unit, mouthLayerIds: Object.keys(framesByLayer), audioTrackId: track.id, confidence: 'aligned-audio' as const }))],
}))
- setCutoutDialogueText(segments.map(segment => segment.text).join(' ')); setCutoutDialogueStart(plans[0].start); setCutoutDialogueEnd(plans.at(-1)!.end)
- setSelectedId(primary.id); setProgress(plans[0].start / scene.duration)
+ setCutoutDialogueText(segments.map(segment => segment.text).join(' ')); setCutoutDialogueStart(plan.start); setCutoutDialogueEnd(plan.end)
+ setSelectedId(primary.id); setProgress(plan.start / scene.duration)
setMessage(t('animator.detectedSpeech', { count: units.length, name: track.name }))
} catch (error) {
setMessage(error instanceof Error ? error.message : t('animator.speechAnalyzeFailed'))
@@ -3058,7 +3075,7 @@ export function SceneAnimatorPanel() {
{lastAutosaveAt ? t('animator.autosaved', { time: new Date(lastAutosaveAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }) : t('animator.autosaveWaiting')}
- {RESOLUTIONS.map(([label, width, height]) => updateScene(current => ({ ...current, width, height }))} className={`rounded border px-1.5 py-1 text-[9px] disabled:opacity-40 ${scene.width === width && scene.height === height ? 'border-accent-blue bg-accent-blue/15 text-accent-blue' : 'border-border bg-bg-primary text-text-muted'}`}>{t(`resolutions.${label === 'HD landscape' ? 'hdLandscape' : label === 'Full HD landscape' ? 'fullHdLandscape' : label === '4K landscape' ? 'fourKLandscape' : label === 'Square' ? 'square' : label === 'HD portrait' ? 'hdPortrait' : label === 'Full HD portrait' ? 'fullHdPortrait' : 'fourKPortrait'}`)} )}{t('animator.frameRate')}{([30, 60] as SceneFrameRate[]).map(rate => updateScene(current => ({ ...current, fps: rate }))} className={`rounded border px-1.5 py-1 text-[9px] disabled:opacity-40 ${fps === rate ? 'border-purple-300 bg-purple-400/10 text-purple-200' : 'border-border bg-bg-primary text-text-muted'}`}>{t('animator.fps', { rate })} )}
+ {RESOLUTIONS.map(([label, width, height]) => updateScene(current => ({ ...current, width, height }))} className={`rounded border px-1.5 py-1 text-[9px] disabled:opacity-40 ${scene.width === width && scene.height === height ? 'border-accent-blue bg-accent-blue/15 text-accent-blue' : 'border-border bg-bg-primary text-text-muted'}`}>{t(`resolutions.${label === 'HD landscape' ? 'hdLandscape' : label === 'Full HD landscape' ? 'fullHdLandscape' : label === '4K landscape' ? 'fourKLandscape' : label === 'Square' ? 'square' : label === 'HD portrait' ? 'hdPortrait' : label === 'Full HD portrait' ? 'fullHdPortrait' : 'fourKPortrait'}`)} )}{t('animator.frameRate')}{([24, 30, 60] as SceneFrameRate[]).map(rate => updateScene(current => ({ ...current, fps: rate }))} className={`rounded border px-1.5 py-1 text-[9px] disabled:opacity-40 ${fps === rate ? 'border-purple-300 bg-purple-400/10 text-purple-200' : 'border-border bg-bg-primary text-text-muted'}`}>{t('animator.fps', { rate })} )}
updateScene(current => ({ ...current, composition: { ...composition, showGrid: !composition.showGrid } }))} className={`flex items-center gap-1 rounded border px-1.5 py-1 text-[9px] ${composition.showGrid ? 'border-accent-blue bg-accent-blue/10 text-accent-blue' : 'border-border text-text-muted'}`}> {t('animator.grid')}
updateScene(current => ({ ...current, composition: { ...composition, snap: !composition.snap } }))} className={`flex items-center gap-1 rounded border px-1.5 py-1 text-[9px] ${composition.snap ? 'border-purple-300 bg-purple-400/10 text-purple-200' : 'border-border text-text-muted'}`}> {t('animator.snap')}
@@ -3075,8 +3092,9 @@ export function SceneAnimatorPanel() {
{(composition.safeArea === 'action' || composition.safeArea === 'all') &&
{t('animator.actionSafeBadge')}
}
{(composition.safeArea === 'title' || composition.safeArea === 'all') &&
{t('animator.titleSafeBadge')}
}
{(composition.safeArea === 'vertical' || composition.safeArea === 'all') &&
{t('animator.verticalBadge')}
}
-
-
+ {retroLive &&
}
+ {!retroLive &&
}
+ {!retroLive &&
}
{activeCamera &&
{activeCamera.name}
}
{orbitPivot &&
}
{flash &&
}
diff --git a/ui/src/components/Sidebar/Sidebar.tsx b/ui/src/components/Sidebar/Sidebar.tsx
index 645dbab99..96b4f4f14 100644
--- a/ui/src/components/Sidebar/Sidebar.tsx
+++ b/ui/src/components/Sidebar/Sidebar.tsx
@@ -1,5 +1,5 @@
-import { lazy, Suspense, useEffect, useRef, useState } from 'react'
-import { X, Globe, BookMarked, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
+import { lazy, Suspense, useEffect, useRef } from 'react'
+import { Globe, BookMarked } from 'lucide-react'
import { useStore } from '../../stores/useStore'
import { useIsMobile } from '../../lib/useIsMobile'
import { InputsPanel } from './InputsPanel'
@@ -33,30 +33,22 @@ import { HardwareStatusBar } from './HardwareStatusBar'
import { H3PromptControls } from './H3PromptControls'
import { MiniMaxH3TurboToggle } from './MiniMaxH3TurboToggle'
import { PanoramaLoopPanel } from './PanoramaLoopPanel'
-import { BrandIdentity } from '../BrandIdentity'
-import { DirectorChat } from './DirectorChat'
+
import { useUiTranslation } from '../../i18n'
import { StudioCommandPanels } from '../../features/studio/StudioCommandPanels'
const ViggleControls = lazy(() => import('./ViggleControls').then(module => ({ default: module.ViggleControls })))
const ToolsPanel = lazy(() => import('./ToolsPanel').then(module => ({ default: module.ToolsPanel })))
-export function Sidebar() {
+export function DirectGenerationWorkspace() {
const { t } = useUiTranslation('navigation')
const { t: tCommon } = useUiTranslation('common')
- const [toolsCollapsed, setToolsCollapsed] = useState(() =>
- window.localStorage.getItem('hocuspocus-tools-sidebar-collapsed') === 'true')
+ const { t: tStudio } = useUiTranslation('studio')
const generationMode = useStore(s => s.generationMode)
const imageMode = useStore(s => s.params.image_mode)
const modelOptions = useStore(s => s.modelOptions)
const sidebarOpen = useStore(s => s.sidebarOpen)
- const mediaFilter = useStore(s => s.mediaFilter)
- const appVersion = useStore(s => s.systemConfig?.app_version)
- const setSidebarOpen = useStore(s => s.setSidebarOpen)
- const setSidebarMode = useStore(s => s.setSidebarMode)
const sidebarMode = useStore(s => s.sidebarMode)
- const setSettingsOpen = useStore(s => s.setSettingsOpen)
- const setDashboardOpen = useStore(s => s.setDashboardOpen)
const editSubMode = useStore(s => s.editSubMode)
const modelType = useStore(s => s.params.model_type)
const workspace = useStore(s => s.activeWorkspace)
@@ -95,57 +87,9 @@ export function Sidebar() {
const panelTitle = isDirector ? t('panel.director') : `${t('panel.directGeneration')} · ${directModeLabel}`
const previousToolContext = useRef(`${generationMode}:${editSubMode}`)
const setToolsSidebarCollapsed = (collapsed: boolean) => {
- setToolsCollapsed(collapsed)
window.localStorage.setItem('hocuspocus-tools-sidebar-collapsed', String(collapsed))
}
- useEffect(() => {
- const openImageSubmission = () => {
- setToolsCollapsed(false)
- window.localStorage.setItem('hocuspocus-tools-sidebar-collapsed', 'false')
- setSidebarOpen(true)
- }
- const openSpeechSubmission = () => {
- setToolsCollapsed(false)
- window.localStorage.setItem('hocuspocus-tools-sidebar-collapsed', 'false')
- setSidebarOpen(true)
- }
- window.addEventListener('hocuspocus:studio-image-open', openImageSubmission)
- window.addEventListener('hocuspocus:studio-speech-open', openSpeechSubmission)
- return () => {
- window.removeEventListener('hocuspocus:studio-image-open', openImageSubmission)
- window.removeEventListener('hocuspocus:studio-speech-open', openSpeechSubmission)
- }
- }, [setSidebarOpen])
-
- useEffect(() => {
- const openStudio = () => {
- setSidebarMode('studio')
- setToolsSidebarCollapsed(false)
- setSidebarOpen(true)
- }
- const openSettings = () => {
- setDashboardOpen(false)
- setSidebarOpen(false)
- setSettingsOpen(true)
- }
- const openDirector = () => {
- setSidebarMode('director')
- setToolsSidebarCollapsed(false)
- setSidebarOpen(true)
- }
- window.addEventListener('hocuspocus:studio-open', openStudio)
- window.addEventListener('hocuspocus:settings-open', openSettings)
- window.addEventListener('maestro:director-open', openDirector)
- return () => {
- window.removeEventListener('hocuspocus:studio-open', openStudio)
- window.removeEventListener('hocuspocus:settings-open', openSettings)
- window.removeEventListener('maestro:director-open', openDirector)
- }
- // The event bridge deliberately tracks stable Zustand actions only.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [])
-
useEffect(() => {
const context = `${generationMode}:${editSubMode}`
if (context !== previousToolContext.current) setToolsSidebarCollapsed(false)
@@ -206,12 +150,14 @@ export function Sidebar() {
added + hardware bar expanded), instead of letting flex-shrink
crush sections into each other. */}
+
{tStudio('groups.help')}
{/* Tools mode: standalone post-processing (upscale / revoice) on any
existing clip. Renders in place of the generation controls. */}
{isTools ?
{tCommon('status.loading')} }>
: isModel3d ?
: (
<>
+
{tStudio('groups.input')}
{/* Edit mode: sub-mode toggle + sub-controls */}
{isEdit &&
}
{isEdit && editControls}
@@ -258,6 +204,7 @@ export function Sidebar() {
{isAudio && audioSubMode === 'mixer' &&
}
{isAudio && audioSubMode === 'music' &&
}
+
{tStudio('groups.instructions')}
{/* Prompt area (non-edit modes, skip for SFX/Mixer/Music which have their own UI) */}
{!isEdit && !(isAudio && (audioSubMode === 'sfx' || audioSubMode === 'mixer' || audioSubMode === 'music')) && (isMultiClip ?
:
)}
+ {tStudio('groups.model')} · {tStudio('groups.run')}
)
- // This workspace owns its controls in the central area, including on mobile.
- if (mediaFilter === 'character-replacement') return null
-
- // Mobile: overlay drawer
- if (isMobile) {
- return (
- <>
- {sidebarOpen && (
-
setSidebarOpen(false)}
- />
- )}
-
- >
- )
- }
-
- if (toolsCollapsed) {
- return (
-
- setToolsSidebarCollapsed(false)}
- className="m-1.5 p-2 rounded-lg hover:bg-bg-hover text-text-secondary hover:text-text-primary transition-colors"
- title="Expand Studio tools"
- aria-label="Expand Studio tools"
- >
-
-
-
- {panelTitle}
-
-
- )
- }
-
- // Desktop: static sidebar
return (
-
- {/* Header */}
-
-
-
-
{panelTitle}
-
setToolsSidebarCollapsed(true)}
- className="p-1.5 rounded-lg hover:bg-bg-hover text-text-secondary hover:text-text-primary transition-colors"
- title="Collapse Studio tools"
- aria-label="Collapse Studio tools"
- >
-
-
-
-
- {isDirector ? : studioControls}
+
+
)
}
+
+export function Sidebar() {
+ return
+}
diff --git a/ui/src/components/Sidebar/WorkspaceEventBridge.tsx b/ui/src/components/Sidebar/WorkspaceEventBridge.tsx
new file mode 100644
index 000000000..ab1ecba13
--- /dev/null
+++ b/ui/src/components/Sidebar/WorkspaceEventBridge.tsx
@@ -0,0 +1,61 @@
+import { useEffect } from 'react'
+import { useStore } from '../../stores/useStore'
+import { revealDirectorWorkspace } from '../../lib/navigationCategories'
+
+function setToolsSidebarCollapsed(collapsed: boolean) {
+ window.localStorage.setItem('hocuspocus-tools-sidebar-collapsed', String(collapsed))
+}
+
+/** Always-mounted host for navigation events. Direct generation and Director
+ * only mount while their workspace is visible, so these listeners cannot
+ * live in those panels. */
+export function WorkspaceEventBridge() {
+ const setSidebarOpen = useStore(s => s.setSidebarOpen)
+ const setSidebarMode = useStore(s => s.setSidebarMode)
+ const setSettingsOpen = useStore(s => s.setSettingsOpen)
+ const setDashboardOpen = useStore(s => s.setDashboardOpen)
+
+ useEffect(() => {
+ const openImageSubmission = () => {
+ setToolsSidebarCollapsed(false)
+ setSidebarOpen(true)
+ }
+ const openSpeechSubmission = () => {
+ setToolsSidebarCollapsed(false)
+ setSidebarOpen(true)
+ }
+ window.addEventListener('hocuspocus:studio-image-open', openImageSubmission)
+ window.addEventListener('hocuspocus:studio-speech-open', openSpeechSubmission)
+ return () => {
+ window.removeEventListener('hocuspocus:studio-image-open', openImageSubmission)
+ window.removeEventListener('hocuspocus:studio-speech-open', openSpeechSubmission)
+ }
+ }, [setSidebarOpen])
+
+ useEffect(() => {
+ const openStudio = () => {
+ setSidebarMode('studio')
+ setToolsSidebarCollapsed(false)
+ setSidebarOpen(true)
+ }
+ const openSettings = () => {
+ setDashboardOpen(false)
+ setSidebarOpen(false)
+ setSettingsOpen(true)
+ }
+ const openDirector = () => {
+ revealDirectorWorkspace(useStore.getState())
+ setToolsSidebarCollapsed(false)
+ }
+ window.addEventListener('hocuspocus:studio-open', openStudio)
+ window.addEventListener('hocuspocus:settings-open', openSettings)
+ window.addEventListener('maestro:director-open', openDirector)
+ return () => {
+ window.removeEventListener('hocuspocus:studio-open', openStudio)
+ window.removeEventListener('hocuspocus:settings-open', openSettings)
+ window.removeEventListener('maestro:director-open', openDirector)
+ }
+ }, [setDashboardOpen, setSettingsOpen, setSidebarMode, setSidebarOpen])
+
+ return null
+}
diff --git a/ui/src/components/common/KineticTextControls.tsx b/ui/src/components/common/KineticTextControls.tsx
index ea2558526..6d0814f73 100644
--- a/ui/src/components/common/KineticTextControls.tsx
+++ b/ui/src/components/common/KineticTextControls.tsx
@@ -1,5 +1,6 @@
import { useUiTranslation } from '../../i18n'
import { KINETIC_PRESETS, parseKineticTexts, type KineticText } from '../../lib/kineticText'
+import { randomUuid } from '../../lib/uuid'
export function KineticTextControls({ cues = [], duration, disabled, onChange }: {
cues?: KineticText[]; duration: number; disabled?: boolean; onChange: (cues: KineticText[]) => void
@@ -22,7 +23,7 @@ export function KineticTextControls({ cues = [], duration, disabled, onChange }:
onChange(cues.filter(item => item.id !== cue.id))} className="min-h-9 text-xs text-red-300">{t('remove')}
)}
- = 12} onClick={() => onChange([...cues, ...parseKineticTexts([{ id: crypto.randomUUID(), text: t('defaultText'), start: 0, end: Math.min(3, duration), preset: 'impact' }])])} className="min-h-10 rounded border border-cyan-400/40 px-3 text-xs text-cyan-200 disabled:opacity-40">{t('add')}
+ = 12} onClick={() => onChange([...cues, ...parseKineticTexts([{ id: randomUuid(), text: t('defaultText'), start: 0, end: Math.min(3, duration), preset: 'impact' }])])} className="min-h-10 rounded border border-cyan-400/40 px-3 text-xs text-cyan-200 disabled:opacity-40">{t('add')}
}
diff --git a/ui/src/features/activity/ActivityCompactBar.tsx b/ui/src/features/activity/ActivityCompactBar.tsx
new file mode 100644
index 000000000..6da5867d1
--- /dev/null
+++ b/ui/src/features/activity/ActivityCompactBar.tsx
@@ -0,0 +1,243 @@
+import { AlertCircle, CheckCircle2, ChevronDown, ChevronUp, CircleSlash2, ListVideo, Loader2 } from 'lucide-react'
+import type { RefObject } from 'react'
+import type { CanonicalTask } from '../../api/client'
+import { canonicalTaskVisualState } from '../../lib/canonicalTaskEvents'
+import { useStore } from '../../stores/useStore'
+import { useUiTranslation } from '../../i18n'
+import type { ActivityGroup, ActivityTaskLike } from './lineage'
+import { isLiveStatus, taskProgressPercent } from './lineage'
+import type { TaskControlAction } from './executionDetail'
+import {
+ estimatedRemainingSeconds,
+ formatElapsed,
+ formatEta,
+ generationInitiator,
+ generationPrompt,
+ generationRecipe,
+ truncatePrompt,
+} from './taskPresentation'
+import { translatedPhase as phaseText } from './taskPresentation'
+
+interface ActivityCompactBarProps {
+ detailsOpen: boolean
+ liveCount: number
+ clock: number
+ primary?: CanonicalTask
+ primaryGroup: ActivityGroup | null
+ busyIds: Set
+ toggleRef: RefObject
+ onToggle: () => void
+ onCopyPrompt: (task: CanonicalTask) => void
+ onControl: (task: CanonicalTask, action: TaskControlAction) => void
+}
+
+function ToggleIcon({ isActive, hasError, visual }: { isActive: boolean; hasError: boolean; visual: string }) {
+ if (isActive) return
+ if (hasError) return
+ if (visual === 'cancelled') return
+ return
+}
+
+type Translate = (key: string, options?: object) => string
+
+function asTranslate(t: unknown): Translate {
+ return t as Translate
+}
+
+function CompactSubtask({ child, clock, t }: { child?: ActivityTaskLike; clock: number; t: Translate }) {
+ if (!child) return null
+ const eta = formatEta(estimatedRemainingSeconds(child, clock))
+ return (
+
+ {t('subtask', { phase: phaseText(t, child) })}
+ {eta ? ` · ${t('eta', { value: eta })}` : ''}
+
+ )
+}
+
+function CompactToggle({
+ detailsOpen,
+ liveCount,
+ isActive,
+ hasError,
+ visual,
+ toggleRef,
+ onToggle,
+ t,
+}: {
+ detailsOpen: boolean
+ liveCount: number
+ isActive: boolean
+ hasError: boolean
+ visual: string
+ toggleRef: RefObject
+ onToggle: () => void
+ t: Translate
+}) {
+ return (
+
+
+ {t('title')}
+ {liveCount > 0 ? {liveCount} : null}
+ {detailsOpen ? : }
+
+ )
+}
+
+function CompactSummary({
+ primary,
+ clock,
+ t,
+ onCopyPrompt,
+}: {
+ primary?: CanonicalTask
+ clock: number
+ t: Translate
+ onCopyPrompt: (task: CanonicalTask) => void
+}) {
+ if (!primary) return null
+ const eta = formatEta(estimatedRemainingSeconds(primary, clock))
+ const prompt = generationPrompt(primary)
+ const initiator = generationInitiator(primary)
+ return (
+ <>
+ {phaseText(t, primary)}
+ {formatElapsed(primary, clock)}
+ {eta ? {t('eta', { value: eta })} : null}
+ {primary.model ? {primary.model} : null}
+ {initiator ? {initiator} : null}
+ {prompt ? (
+ onCopyPrompt(primary)} className="hidden xl:block min-w-0 max-w-80 truncate text-left text-text-secondary hover:text-text-primary" title={t('copyPromptTitle', { prompt })} aria-label={t('copyBarPrompt', { title: primary.title })}>
+ “{truncatePrompt(prompt, 100)}”
+
+ ) : null}
+ >
+ )
+}
+
+function CompactProgress({ primary }: { primary?: CanonicalTask }) {
+ if (!primary) return null
+ const percent = taskProgressPercent(primary)
+ const label = primary.total > 0 ? `${primary.current}/${primary.total}` : `${Math.round(percent)}%`
+ return (
+
+
+
0 ? 2 : 0)}%` }} />
+
+
{label}
+
+ )
+}
+
+function CompactCancel({
+ primary,
+ busyIds,
+ onControl,
+ t,
+ tCommon,
+}: {
+ primary?: CanonicalTask
+ busyIds: Set
+ onControl: (task: CanonicalTask, action: TaskControlAction) => void
+ t: Translate
+ tCommon: Translate
+}) {
+ if (!primary) return null
+ if (!isLiveStatus(primary.status)) return null
+ if (!primary.cancelable) return null
+ const busy = busyIds.has(primary.id)
+ return (
+ onControl(primary, 'cancel')} className="flex shrink-0 items-center gap-1 rounded-md border border-red-400/40 px-2 py-1 text-red-300 disabled:opacity-50">
+ {busy ? : null}
+ {busy ? t('cancelling') : tCommon('actions.cancel')}
+
+ )
+}
+
+function compactHasError(isActive: boolean, primary?: CanonicalTask, primaryGroup: ActivityGroup | null = null): boolean {
+ if (isActive) return false
+ if (primary?.status === 'failed') return true
+ if (primary?.status === 'interrupted') return true
+ return primaryGroup?.readingState === 'failed'
+}
+
+function compactMessage(primary: CanonicalTask | undefined, fallback: string): string {
+ if (primary?.error?.message) return primary.error.message
+ if (primary?.detail) return primary.detail
+ if (primary?.message) return primary.message
+ return fallback
+}
+
+function compactVisual(primary?: CanonicalTask): string {
+ if (!primary) return 'neutral'
+ return canonicalTaskVisualState(primary.status)
+}
+
+function liveChild(group: ActivityGroup | null, primary?: CanonicalTask): ActivityTaskLike | undefined {
+ if (!group) return undefined
+ return group.jobs.map(job => job.task).find(child => isLiveStatus(child.status) && child.id !== primary?.id)
+}
+
+function CompactWorkspaces({ t }: { t: Translate }) {
+ const setVideoWorkflowsOpen = useStore(state => state.setDashboardOpen)
+ return (
+ {
+ useStore.getState().setMediaFilter('runs')
+ setVideoWorkflowsOpen(false)
+ }} className="flex items-center gap-1 rounded-md border border-border px-2 py-1 text-text-secondary hover:border-accent-blue/50 hover:text-accent-blue transition-colors shrink-0" title={t('workspacesTitle')}>
+ {t('workspaces')}
+
+ )
+}
+
+export function ActivityCompactBar({
+ detailsOpen,
+ liveCount,
+ clock,
+ primary,
+ primaryGroup,
+ busyIds,
+ toggleRef,
+ onToggle,
+ onCopyPrompt,
+ onControl,
+}: ActivityCompactBarProps) {
+ const { t: tCommonRaw } = useUiTranslation('common')
+ const { t: tActivityRaw } = useUiTranslation('activity')
+ const tCommon = asTranslate(tCommonRaw)
+ const tActivity = asTranslate(tActivityRaw)
+ const isActive = liveCount > 0
+ const hasError = compactHasError(isActive, primary, primaryGroup)
+ const message = compactMessage(primary, tActivity('ready'))
+ const messageClass = hasError ? 'text-red-400' : isActive ? 'text-text-secondary' : 'text-text-muted'
+ return (
+ <>
+
+
+
+
+ {message}
+
+ {isActive ? : null}
+
+
+ >
+ )
+}
diff --git a/ui/src/features/activity/ActivityDetailsPanel.tsx b/ui/src/features/activity/ActivityDetailsPanel.tsx
new file mode 100644
index 000000000..c12ed94ba
--- /dev/null
+++ b/ui/src/features/activity/ActivityDetailsPanel.tsx
@@ -0,0 +1,111 @@
+import { Eraser } from 'lucide-react'
+import { createPortal } from 'react-dom'
+import type { RefObject } from 'react'
+import type { CanonicalTask } from '../../api/client'
+import { useUiTranslation } from '../../i18n'
+import { ActivityExecutionDetail, type TaskControlAction, type TaskControlFailure } from './executionDetail'
+import type { ActivityGroup } from './lineage'
+import { openActivityArtifact, openActivityProject } from './openTargets'
+
+interface ActivityDetailsPanelProps {
+ open: boolean
+ groups: ActivityGroup[]
+ liveCount: number
+ historicalCount: number
+ clock: number
+ selectedGroupId: string | null
+ expandedGroupIds: Set
+ inspectedAttemptByGroup: Record
+ busyIds: Set
+ controlFailures: Record
+ panelNode: RefObject
+ onClose: () => void
+ onClearHistory: () => void
+ onSelect: (groupId: string) => void
+ onToggleExpand: (groupId: string) => void
+ onInspectPrevious: (group: ActivityGroup) => void
+ onControl: (task: CanonicalTask, action: TaskControlAction) => void
+ onCopyId: (task: CanonicalTask) => void
+ onCopyPrompt: (task: CanonicalTask) => void
+}
+
+export function ActivityDetailsPanel({
+ open,
+ groups,
+ liveCount,
+ historicalCount,
+ clock,
+ selectedGroupId,
+ expandedGroupIds,
+ inspectedAttemptByGroup,
+ busyIds,
+ controlFailures,
+ panelNode,
+ onClose,
+ onClearHistory,
+ onSelect,
+ onToggleExpand,
+ onInspectPrevious,
+ onControl,
+ onCopyId,
+ onCopyPrompt,
+}: ActivityDetailsPanelProps) {
+ const { t: tCommon } = useUiTranslation('common')
+ const { t: tActivity } = useUiTranslation('activity')
+ if (!open) return null
+ if (!groups.length) return null
+ return createPortal(
+
+
+
{tActivity('panelTitle')}
+
+ {historicalCount > 0 ? (
+
+ {tActivity('clearHistory')}
+
+ ) : null}
+ {tActivity('activeDurable', { count: liveCount })}
+ {tCommon('actions.close')}
+
+
+
+ {groups.map(group => (
+
onSelect(group.id)}
+ onToggleExpand={() => onToggleExpand(group.id)}
+ onInspectPrevious={() => onInspectPrevious(group)}
+ onControl={onControl}
+ onCopyId={onCopyId}
+ onCopyPrompt={onCopyPrompt}
+ onOpenArtifact={name => { openActivityArtifact(name) }}
+ onOpenProject={() => { if (group.project) openActivityProject(group.project) }}
+ />
+ ))}
+
+
,
+ document.body,
+ )
+}
diff --git a/ui/src/features/activity/activityHistory.ts b/ui/src/features/activity/activityHistory.ts
new file mode 100644
index 000000000..4642a4850
--- /dev/null
+++ b/ui/src/features/activity/activityHistory.ts
@@ -0,0 +1,27 @@
+const HIDDEN_HISTORY_STORAGE_PREFIX = 'maestro-activity-hidden-v1:'
+
+export function hiddenHistoryStorageKey(workspace: string): string {
+ return `${HIDDEN_HISTORY_STORAGE_PREFIX}${workspace}`
+}
+
+export function readHiddenHistory(workspace: string): Set {
+ try {
+ const raw = window.localStorage.getItem(hiddenHistoryStorageKey(workspace))
+ const parsed = raw ? JSON.parse(raw) : []
+ if (!Array.isArray(parsed)) return new Set()
+ return new Set(parsed.filter(value => typeof value === 'string'))
+ } catch {
+ return new Set()
+ }
+}
+
+export function writeHiddenHistory(workspace: string, ids: Set): void {
+ try {
+ const key = hiddenHistoryStorageKey(workspace)
+ if (ids.size) window.localStorage.setItem(key, JSON.stringify([...ids]))
+ else window.localStorage.removeItem(key)
+ } catch {
+ // Hiding history is still useful for the current session if storage is
+ // blocked (private browsing, disabled cookies, or a quota error).
+ }
+}
diff --git a/ui/src/features/activity/executionDetail.tsx b/ui/src/features/activity/executionDetail.tsx
new file mode 100644
index 000000000..f087cb0d2
--- /dev/null
+++ b/ui/src/features/activity/executionDetail.tsx
@@ -0,0 +1,574 @@
+import { AlertCircle, CheckCircle2, CircleSlash2, Copy, Loader2 } from 'lucide-react'
+import { canResumeCanonicalTask, canonicalTaskVisualState } from '../../lib/canonicalTaskEvents'
+import { formatAppAction, formatAppTimestamp } from '../../lib/locale'
+import { useUiTranslation } from '../../i18n'
+import type { CanonicalTask } from '../../api/client'
+import {
+ isLiveStatus,
+ taskProgressPercent,
+ type ActivityAttempt,
+ type ActivityGroup,
+ type ActivityJob,
+ type ActivityReadingState,
+ type ActivityTaskLike,
+} from './lineage'
+import {
+ estimatedRemainingSeconds,
+ formatElapsed,
+ formatEta,
+ generationInitiator,
+ generationPrompt,
+ generationRecipe,
+ resourceSummary,
+ translatedPhase,
+ truncatePrompt,
+} from './taskPresentation'
+
+export type TaskControlAction = 'cancel' | 'resume' | 'dismiss'
+export interface TaskControlFailure {
+ action: TaskControlAction
+ message: string
+}
+
+interface ActivityExecutionDetailProps {
+ group: ActivityGroup
+ clock: number
+ selected: boolean
+ expanded: boolean
+ inspectedAttemptId?: string
+ busyIds: Set
+ controlFailures: Record
+ onSelect: () => void
+ onToggleExpand: () => void
+ onInspectPrevious: () => void
+ onControl: (task: CanonicalTask, action: TaskControlAction) => void
+ onCopyId: (task: CanonicalTask) => void
+ onCopyPrompt: (task: CanonicalTask) => void
+ onOpenArtifact: (name: string) => void
+ onOpenProject: () => void
+}
+
+type Translate = (key: string, options?: object) => string
+
+function asTranslate(t: unknown): Translate {
+ return t as Translate
+}
+
+function readingClass(state: ActivityReadingState): string {
+ if (state === 'failed') return 'text-red-400'
+ if (state === 'running') return 'text-accent-blue'
+ if (state === 'partial') return 'text-amber-300'
+ if (state === 'completed') return 'text-emerald-400'
+ if (state === 'admitted') return 'text-violet-300'
+ if (state === 'prepared') return 'text-violet-300'
+ return 'text-text-muted'
+}
+
+function StatusIcon({ status }: { status: string }) {
+ const visual = canonicalTaskVisualState(status)
+ if (visual === 'active') return
+ if (visual === 'error') return
+ if (visual === 'cancelled') return
+ return
+}
+
+function phaseText(t: Translate, task: ActivityTaskLike): string {
+ return translatedPhase(t, task)
+}
+
+function AttemptRow({ attempt, inspected }: { attempt: ActivityAttempt; inspected: boolean }) {
+ const { t: tRaw } = useUiTranslation('activity')
+ const t = asTranslate(tRaw)
+ const extra = attempt.error || attempt.message
+ return (
+
+ {t('lineage.previousAttempt', { n: attempt.attempt })}
+ {extra ? ` · ${extra}` : ''}
+ {attempt.resultRefs.length ? ` · ${attempt.resultRefs.join(', ')}` : ''}
+
+ )
+}
+
+function EtaSuffix({ task, clock, t }: { task: ActivityTaskLike; clock: number; t: Translate }) {
+ if (!isLiveStatus(task.status)) return null
+ const eta = formatEta(estimatedRemainingSeconds(task, clock))
+ if (!eta) return null
+ return <>{` · ${t('eta', { value: eta })}`}>
+}
+
+function TokenSpan({ task, t }: { task: ActivityTaskLike; t: Translate }) {
+ if (!task.token_usage?.total) return null
+ return (
+
+ {t('tokens', {
+ total: task.token_usage.total.toLocaleString(),
+ prompt: task.token_usage.prompt || 0,
+ completion: task.token_usage.completion || 0,
+ })}
+
+ )
+}
+
+function JobHeadline({ job, clock, t }: { job: ActivityJob; clock: number; t: Translate }) {
+ const child = job.task
+ return (
+
+ {t(`lineage.reading.${job.readingState}`)}
+ {' · '}
+ {phaseText(t, child)}
+ {' · '}
+ {formatElapsed(child, clock)}
+ {' · '}
+ {child.message}
+
+
+ )
+}
+
+function JobMeta({
+ child,
+ t,
+ onCopyId,
+}: {
+ child: ActivityTaskLike
+ t: Translate
+ onCopyId: (task: CanonicalTask) => void
+}) {
+ const recipe = generationRecipe(child)
+ const resources = resourceSummary(child)
+ const initiator = generationInitiator(child)
+ return (
+
+ {recipe ? {recipe} : null}
+ {initiator ? {t('startedBy', { name: initiator })} : null}
+ {child.server_origin ? {t('server', { origin: child.server_origin })} : null}
+ {resources ? {t(`resources.${resources.kind}`, { value: resources.value })} : null}
+ {t('attempt', { current: child.attempt || 1, max: child.max_attempts || 1 })}
+
+ onCopyId(child as CanonicalTask)} className="font-mono hover:text-text-primary" title={t('copyChildTaskId')}>
+ {child.id}
+
+
+ )
+}
+
+function JobPrompt({
+ child,
+ t,
+ onCopyPrompt,
+}: {
+ child: ActivityTaskLike
+ t: Translate
+ onCopyPrompt: (task: CanonicalTask) => void
+}) {
+ const prompt = generationPrompt(child)
+ if (!prompt) return null
+ return (
+ onCopyPrompt(child as CanonicalTask)}
+ className="block max-w-full truncate text-left text-[8px] text-text-secondary hover:text-text-primary"
+ title={t('copyPromptTitle', { prompt })}
+ aria-label={t('copyPrompt', { title: child.title || child.id })}
+ >
+ {t('prompt')}: {truncatePrompt(prompt, 140)}
+
+ )
+}
+
+function JobRow({
+ job,
+ clock,
+ inspectedAttemptId,
+ onCopyId,
+ onCopyPrompt,
+}: {
+ job: ActivityJob
+ clock: number
+ inspectedAttemptId?: string
+ onCopyId: (task: CanonicalTask) => void
+ onCopyPrompt: (task: CanonicalTask) => void
+}) {
+ const { t: tRaw } = useUiTranslation('activity')
+ const t = asTranslate(tRaw)
+ const child = job.task
+ const previous = job.attempts.filter(attempt => attempt.id !== `${child.id}:${child.attempt || 1}`)
+ return (
+
+
+
+
+ {previous.map(attempt => (
+
+ ))}
+
+ )
+}
+
+function GroupTaskControls({
+ task,
+ active,
+ busyIds,
+ onControl,
+ t,
+ tCommon,
+}: {
+ task: CanonicalTask
+ active: boolean
+ busyIds: Set
+ onControl: (task: CanonicalTask, action: TaskControlAction) => void
+ t: Translate
+ tCommon: Translate
+}) {
+ if (active && task.cancelable) {
+ return (
+ onControl(task, 'cancel')} className="rounded border border-red-400/40 px-1.5 py-0.5 text-[9px] text-red-300">
+ {busyIds.has(task.id) ? t('cancelling') : tCommon('actions.cancel')}
+
+ )
+ }
+ if (!active && canResumeCanonicalTask(task)) {
+ return (
+ onControl(task, 'resume')} className="rounded border border-border px-1.5 py-0.5 text-[9px] text-accent-blue">{tCommon('actions.resume')}
+ )
+ }
+ if (!active) {
+ return (
+ onControl(task, 'dismiss')} className="rounded border border-border px-1.5 py-0.5 text-[9px] text-text-muted">{t('dismiss')}
+ )
+ }
+ return null
+}
+
+function GroupTitleRow({
+ group,
+ task,
+ clock,
+ active,
+ busyIds,
+ onSelect,
+ onControl,
+ t,
+ tCommon,
+}: {
+ group: ActivityGroup
+ task: CanonicalTask
+ clock: number
+ active: boolean
+ busyIds: Set
+ onSelect: () => void
+ onControl: (task: CanonicalTask, action: TaskControlAction) => void
+ t: Translate
+ tCommon: Translate
+}) {
+ const updatedAt = formatAppTimestamp(task.updated_at)
+ const taskEta = formatEta(estimatedRemainingSeconds(task, clock))
+ return (
+
+
+ {task.title}
+
+
+ {t(`lineage.reading.${group.readingState}`)}
+ {formatElapsed(task, clock)}
+ {active && taskEta ? {t('eta', { value: taskEta })} : null}
+ {updatedAt ? {updatedAt} : null}
+ {phaseText(t, task)}
+
+
+
+ )
+}
+
+function GroupPrompt({ task, t, onCopyPrompt }: { task: CanonicalTask; t: Translate; onCopyPrompt: (task: CanonicalTask) => void }) {
+ const prompt = generationPrompt(task)
+ if (!prompt) return null
+ return (
+
+ {t('prompt')}
+ onCopyPrompt(task)}
+ className="min-w-0 flex-1 truncate text-left text-text-secondary hover:text-text-primary"
+ title={t('copyPromptTitle', { prompt })}
+ aria-label={t('copyPrompt', { title: task.title })}
+ >
+ {truncatePrompt(prompt)}
+
+ onCopyPrompt(task)} className="shrink-0 text-text-muted hover:text-text-primary" title={t('copyPromptIcon', { title: task.title })} aria-label={t('copyPromptIcon', { title: task.title })}>
+
+
+
+ )
+}
+
+function GroupActiveChild({ child, clock, t }: { child?: ActivityTaskLike; clock: number; t: Translate }) {
+ if (!child) return null
+ const eta = formatEta(estimatedRemainingSeconds(child, clock))
+ return (
+
+ {t('activeSubtask', { phase: phaseText(t, child) })}
+ {eta ? ` · ${t('eta', { value: eta })}` : ''}
+
+ )
+}
+
+function GroupControlFailure({
+ task,
+ failure,
+ busyIds,
+ onControl,
+ t,
+ tCommon,
+}: {
+ task: CanonicalTask
+ failure?: TaskControlFailure
+ busyIds: Set
+ onControl: (task: CanonicalTask, action: TaskControlAction) => void
+ t: Translate
+ tCommon: Translate
+}) {
+ if (!failure) return null
+ return (
+
+ {t('controlFailed', { action: failure.action[0].toUpperCase() + failure.action.slice(1), message: failure.message })}
+ onControl(task, failure.action)}
+ className="shrink-0 rounded border border-red-300/50 px-1.5 py-0.5 font-medium disabled:opacity-50"
+ aria-label={t('retryAction', { action: failure.action })}
+ >
+ {tCommon('actions.retry')}
+
+
+ )
+}
+
+function GroupIdentity({ task, t, onCopyId }: { task: CanonicalTask; t: Translate; onCopyId: (task: CanonicalTask) => void }) {
+ return (
+
+ {task.server_origin ? {t('server', { origin: task.server_origin })} : null}
+ {t('attempt', { current: task.attempt, max: task.max_attempts })}
+
+ onCopyId(task)} className="font-mono hover:text-text-primary" title={t('copyTaskId')}>{task.id}
+
+ )
+}
+
+function GroupActions({
+ group,
+ inspected,
+ expanded,
+ onOpenArtifact,
+ onOpenProject,
+ onInspectPrevious,
+ onToggleExpand,
+ t,
+}: {
+ group: ActivityGroup
+ inspected?: ActivityAttempt
+ expanded: boolean
+ onOpenArtifact: (name: string) => void
+ onOpenProject: () => void
+ onInspectPrevious: () => void
+ onToggleExpand: () => void
+ t: Translate
+}) {
+ const canToggle = group.jobs.length > 1 || Boolean(group.previousAttempt) || group.artifacts.length > 0
+ return (
+
+ {group.artifacts.map(name => (
+ onOpenArtifact(name)} className="rounded border border-emerald-400/30 px-1.5 py-0.5 text-[9px] text-emerald-300">
+ {t('lineage.openArtifact', { name })}
+
+ ))}
+ {group.readingState === 'admitted' && !group.hasArtifact ? {t('lineage.admittedWaiting')} : null}
+ {group.project ? (
+
+ {t('lineage.openProject')}
+
+ ) : null}
+ {group.previousAttempt ? (
+
+ {t('lineage.inspectPrevious')}
+
+ ) : null}
+ {canToggle ? (
+
+ {expanded ? t('lineage.hideDetails') : t('lineage.showDetails')}
+
+ ) : null}
+
+ )
+}
+
+function GroupProgressBar({ task, active }: { task: CanonicalTask; active: boolean }) {
+ if (!active) return null
+ const percent = taskProgressPercent(task)
+ const label = task.total > 0 ? `${task.current}/${task.total}` : `${Math.round(percent)}%`
+ return (
+
+
+
0 ? 2 : 0)}%` }} />
+
+
{label}
+
+ )
+}
+
+function inspectedAttempt(group: ActivityGroup, inspectedAttemptId?: string): ActivityAttempt | undefined {
+ if (group.previousAttempt && group.previousAttempt.id === inspectedAttemptId) return group.previousAttempt
+ return group.jobs.flatMap(job => job.attempts).find(attempt => attempt.id === inspectedAttemptId)
+}
+
+function GroupCopy({
+ recipe,
+ initiator,
+ resources,
+ t,
+}: {
+ recipe: string
+ initiator: string
+ resources: ReturnType
+ t: Translate
+}) {
+ return (
+ <>
+ {recipe ? {recipe}
: null}
+ {initiator ? {t('startedBy', { name: initiator })}
: null}
+ {resources ? {t(`resources.${resources.kind}`, { value: resources.value })}
: null}
+ >
+ )
+}
+
+function GroupChildren({
+ jobs,
+ clock,
+ inspectedAttemptId,
+ onCopyId,
+ onCopyPrompt,
+}: {
+ jobs: ActivityJob[]
+ clock: number
+ inspectedAttemptId?: string
+ onCopyId: (task: CanonicalTask) => void
+ onCopyPrompt: (task: CanonicalTask) => void
+}) {
+ if (!jobs.length) return null
+ return (
+
+ {jobs.map(job => (
+
+ ))}
+
+ )
+}
+
+function GroupPrevious({ expanded, inspected }: { expanded: boolean; inspected?: ActivityAttempt }) {
+ if (!expanded) return null
+ if (!inspected) return null
+ return (
+
+ )
+}
+
+function GroupBody(props: ActivityExecutionDetailProps & { task: CanonicalTask; t: Translate; tCommon: Translate }) {
+ const { task, t, tCommon } = props
+ const children = props.group.jobs.filter(job => job.id !== task.id)
+ const activeChild = props.group.jobs.map(job => job.task).find(child => isLiveStatus(child.status) && child.id !== task.id)
+ const active = isLiveStatus(task.status)
+ const inspected = inspectedAttempt(props.group, props.inspectedAttemptId)
+ const failed = task.status === 'failed' || task.status === 'interrupted'
+ return (
+
+
+
+ {t('lineage.progressLabel')} {Math.round(props.group.progress)}%
+ {' · '}
+ {t('lineage.resultLabel')} {t(`lineage.reading.${props.group.readingState}`)}
+ {props.group.jobs.length > 1 ? ` · ${t('lineage.jobs', { count: props.group.jobs.length })}` : ''}
+
+
+ {task.error?.message || task.detail || task.message}
+
+
+
+ {active ?
: null}
+ {props.group.recoveryReason ?
{t('lineage.recoveryReason', { reason: props.group.recoveryReason })}
: null}
+
+
+
+
+
+
+
+ )
+}
+
+export function ActivityExecutionDetail(props: ActivityExecutionDetailProps) {
+ const { t: tRaw } = useUiTranslation('activity')
+ const { t: tCommonRaw } = useUiTranslation('common')
+ const t = asTranslate(tRaw)
+ const tCommon = asTranslate(tCommonRaw)
+ const task = props.group.primary as CanonicalTask
+ const border = props.selected ? 'border-accent-blue/70' : 'border-border'
+ return (
+
+ )
+}
diff --git a/ui/src/features/activity/lineage.ts b/ui/src/features/activity/lineage.ts
new file mode 100644
index 000000000..9cdb678a7
--- /dev/null
+++ b/ui/src/features/activity/lineage.ts
@@ -0,0 +1,506 @@
+export const LIVE_TASK_STATUSES = new Set(['created', 'queued', 'waiting_resource', 'running'])
+export const FAILED_TASK_STATUSES = new Set(['failed', 'interrupted', 'cancelled'])
+
+export type ActivityReadingState =
+ | 'prepared'
+ | 'admitted'
+ | 'running'
+ | 'failed'
+ | 'partial'
+ | 'completed'
+
+export interface ActivityTaskLike {
+ id: string
+ root_id: string
+ parent_id?: string | null
+ kind?: string
+ title?: string
+ workflow?: string
+ status: string
+ phase?: string
+ message?: string
+ detail?: string
+ current?: number
+ total?: number
+ progress?: number
+ created_at: number
+ queued_at?: number | null
+ started_at?: number | null
+ updated_at: number
+ completed_at?: number | null
+ attempt?: number
+ max_attempts?: number
+ backend_job_id?: string
+ pipeline_id?: string
+ result_refs?: string[]
+ error?: { message?: string; retryable?: boolean } | null
+ metadata?: Record
+ workspace?: string
+ resumable?: boolean
+ recoverable?: boolean
+ cancelable?: boolean
+ provider?: string
+ model?: string
+ resource_requirements?: string[]
+ acquired_resources?: string[]
+ server_origin?: string
+ token_usage?: { prompt?: number; completion?: number; total?: number; calls?: number }
+}
+
+export interface ActivityAttempt {
+ id: string
+ taskId: string
+ attempt: number
+ readingState: ActivityReadingState
+ status: string
+ message: string
+ error: string
+ resultRefs: string[]
+ createdAt: number
+ updatedAt: number
+}
+
+export interface ActivityJob {
+ id: string
+ title: string
+ readingState: ActivityReadingState
+ task: ActivityTaskLike
+ attempts: ActivityAttempt[]
+}
+
+export interface ActivityProjectTarget {
+ kind: string
+ id: string
+ title: string
+}
+
+export interface ActivityGroup {
+ id: string
+ intentId: string
+ receiptId: string
+ rootId: string
+ workspace: string
+ title: string
+ readingState: ActivityReadingState
+ progress: number
+ hasArtifact: boolean
+ createdAt: number
+ primary: ActivityTaskLike
+ jobs: ActivityJob[]
+ artifacts: string[]
+ previousAttempt?: ActivityAttempt
+ recoveryReason: string
+ project?: ActivityProjectTarget
+}
+
+export interface ActivityFocusRequest {
+ taskId?: string
+ intentId?: string
+ receiptId?: string
+ inspectPreviousAttempt?: boolean
+}
+
+export interface ActivityChrome {
+ selectedId: string | null
+ expandedIds: readonly string[]
+ inspectedAttemptByGroup: Readonly>
+}
+
+const ARTIFACT_KIND = /generat|image|video|audio|render|export|speech|music|sfx|tool|model/
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
+}
+
+function text(value: unknown): string {
+ return typeof value === 'string' && value.trim() ? value.trim() : ''
+}
+
+function metadataOf(task: ActivityTaskLike): Record {
+ return isRecord(task.metadata) ? task.metadata : {}
+}
+
+export function taskIntentId(task: ActivityTaskLike): string {
+ const metadata = metadataOf(task)
+ const receipt = isRecord(metadata.receipt) ? metadata.receipt : {}
+ const command = isRecord(metadata.command) ? metadata.command : {}
+ return text(metadata.intent_id)
+ || text(metadata.intentId)
+ || text(metadata.command_id)
+ || text(metadata.commandId)
+ || text(receipt.commandId)
+ || text(receipt.intent_id)
+ || text(command.command_id)
+ || text(command.commandId)
+}
+
+export function taskReceiptId(task: ActivityTaskLike): string {
+ const metadata = metadataOf(task)
+ const receipt = isRecord(metadata.receipt) ? metadata.receipt : {}
+ return text(metadata.receipt_id)
+ || text(metadata.receiptId)
+ || text(receipt.commandId)
+ || taskIntentId(task)
+}
+
+export function taskWorkspace(task: ActivityTaskLike): string {
+ const metadata = metadataOf(task)
+ return text(task.workspace) || text(metadata.workspace) || text(metadata.workspace_id)
+}
+
+export function taskArtifactRefs(task: ActivityTaskLike): string[] {
+ const metadata = metadataOf(task)
+ const fromTask = Array.isArray(task.result_refs)
+ ? task.result_refs.filter((item): item is string => typeof item === 'string' && Boolean(item.trim()))
+ : []
+ const extra = [metadata.artifact_ids, metadata.output_files, metadata.outputNames]
+ .flatMap(value => Array.isArray(value) ? value : [])
+ .filter((item): item is string => typeof item === 'string' && Boolean(item.trim()))
+ return [...new Set([...fromTask, ...extra])]
+}
+
+export function taskExpectsArtifact(task: ActivityTaskLike): boolean {
+ const metadata = metadataOf(task)
+ if (metadata.expects_artifact === false || metadata.expectsArtifact === false) return false
+ if (metadata.expects_artifact === true || metadata.expectsArtifact === true) return true
+ const kind = `${task.kind || ''} ${task.workflow || ''}`.toLowerCase()
+ return ARTIFACT_KIND.test(kind)
+}
+
+export function isLiveStatus(status: string): boolean {
+ return LIVE_TASK_STATUSES.has(status)
+}
+
+export function taskHasArtifact(task: ActivityTaskLike): boolean {
+ return taskArtifactRefs(task).length > 0
+}
+
+export function taskReadingState(task: ActivityTaskLike): ActivityReadingState {
+ if (FAILED_TASK_STATUSES.has(task.status)) return 'failed'
+ if (task.status === 'running' || task.status === 'waiting_resource') return 'running'
+ if (task.status === 'queued') return 'admitted'
+ if (task.status === 'created') {
+ return task.backend_job_id || taskIntentId(task) ? 'admitted' : 'prepared'
+ }
+ if (task.status === 'completed') {
+ if (taskExpectsArtifact(task) && !taskHasArtifact(task)) return 'partial'
+ return 'completed'
+ }
+ return 'admitted'
+}
+
+export function taskProgressPercent(task: ActivityTaskLike): number {
+ const total = Number(task.total || 0)
+ const current = Number(task.current || 0)
+ if (total > 0) return Math.max(0, Math.min(100, (current / total) * 100))
+ return Math.max(0, Math.min(100, Number(task.progress || 0) * 100))
+}
+
+function uniqueTasks(tasks: ActivityTaskLike[]): ActivityTaskLike[] {
+ const byId = new Map()
+ for (const task of tasks) byId.set(task.id, task)
+ return [...byId.values()]
+}
+
+function rootOf(task: ActivityTaskLike, byId: Map): ActivityTaskLike {
+ const seen = new Set()
+ let cursor = task
+ while (cursor.parent_id && byId.has(cursor.parent_id) && !seen.has(cursor.id)) {
+ seen.add(cursor.id)
+ cursor = byId.get(cursor.parent_id) as ActivityTaskLike
+ }
+ return byId.get(cursor.root_id) || cursor
+}
+
+export function activityRequestKey(task: ActivityTaskLike, byId: Map): string {
+ const root = rootOf(task, byId)
+ const intent = taskIntentId(root) || taskIntentId(task)
+ if (intent) return `intent:${intent}`
+ return `root:${root.root_id || root.id}`
+}
+
+function firstText(...values: unknown[]): string {
+ for (const value of values) {
+ const next = text(value)
+ if (next) return next
+ }
+ return ''
+}
+
+function finiteNumber(value: unknown, fallback: number): number {
+ const parsed = Number(value)
+ if (Number.isFinite(parsed)) return parsed
+ return fallback
+}
+
+const READING_STATES = new Set(['prepared', 'admitted', 'running', 'failed', 'partial', 'completed'])
+
+function asReadingState(value: string): ActivityReadingState | undefined {
+ if (READING_STATES.has(value)) return value as ActivityReadingState
+ return undefined
+}
+
+function failedDetail(task: ActivityTaskLike): string {
+ if (!FAILED_TASK_STATUSES.has(task.status)) return ''
+ return firstText(task.detail, task.message)
+}
+
+function asAttempt(task: ActivityTaskLike, overrides: Partial = {}): ActivityAttempt {
+ const attempt = finiteNumber(overrides.attempt, finiteNumber(task.attempt, 1))
+ return {
+ id: firstText(overrides.id, `${task.id}:${attempt}`),
+ taskId: task.id,
+ attempt,
+ readingState: overrides.readingState ?? taskReadingState(task),
+ status: firstText(overrides.status, task.status),
+ message: firstText(overrides.message, task.message),
+ error: firstText(overrides.error, task.error?.message, failedDetail(task)),
+ resultRefs: overrides.resultRefs ?? taskArtifactRefs(task),
+ createdAt: finiteNumber(overrides.createdAt, finiteNumber(task.created_at, 0)),
+ updatedAt: finiteNumber(overrides.updatedAt, finiteNumber(task.updated_at, 0)),
+ }
+}
+
+function historyItemRefs(item: Record): string[] | undefined {
+ if (!Array.isArray(item.result_refs)) return undefined
+ return item.result_refs.filter((value): value is string => typeof value === 'string')
+}
+
+function historyItemAttempt(task: ActivityTaskLike, item: unknown, index: number): ActivityAttempt | null {
+ if (!isRecord(item)) return null
+ const attempt = finiteNumber(item.attempt, index + 1)
+ const nestedError = isRecord(item.error) ? item.error.message : ''
+ return asAttempt(task, {
+ id: firstText(item.id, `${task.id}:history:${attempt}`),
+ attempt,
+ readingState: asReadingState(text(item.readingState)),
+ status: firstText(item.status, task.status),
+ message: text(item.message),
+ error: firstText(item.error, nestedError),
+ resultRefs: historyItemRefs(item),
+ createdAt: finiteNumber(item.created_at, finiteNumber(item.createdAt, finiteNumber(task.created_at, 0))),
+ updatedAt: finiteNumber(item.updated_at, finiteNumber(item.updatedAt, 0)),
+ })
+}
+
+function historyAttempts(task: ActivityTaskLike): ActivityAttempt[] {
+ const metadata = metadataOf(task)
+ const raw = metadata.previous_attempts || metadata.attempt_history || metadata.attempts
+ if (!Array.isArray(raw)) return []
+ return raw.flatMap((item, index) => {
+ const attempt = historyItemAttempt(task, item, index)
+ return attempt ? [attempt] : []
+ })
+}
+
+function jobAttempts(tasks: ActivityTaskLike[]): ActivityAttempt[] {
+ const attempts = tasks.flatMap(task => [...historyAttempts(task), asAttempt(task)])
+ const byId = new Map()
+ for (const attempt of attempts) byId.set(attempt.id, attempt)
+ return [...byId.values()].sort((left, right) => (
+ left.attempt - right.attempt || left.createdAt - right.createdAt || left.id.localeCompare(right.id)
+ ))
+}
+
+function allStatesAre(states: ActivityReadingState[], allowed: readonly ActivityReadingState[]): boolean {
+ const permitted = new Set(allowed)
+ return states.every(state => permitted.has(state))
+}
+
+function isMixedPartial(states: ActivityReadingState[], missingExpectedArtifact: boolean): boolean {
+ if (missingExpectedArtifact) return true
+ if (states.includes('partial')) return true
+ return states.includes('failed') && states.includes('completed')
+}
+
+function leftoverReading(states: ActivityReadingState[], hasArtifact: boolean): ActivityReadingState {
+ if (states.includes('admitted')) return 'admitted'
+ if (states.includes('prepared')) return 'prepared'
+ if (states.includes('failed')) return 'failed'
+ if (hasArtifact) return 'completed'
+ return 'admitted'
+}
+
+function combineReadingState(
+ states: ActivityReadingState[],
+ hasArtifact: boolean,
+ missingExpectedArtifact = false,
+): ActivityReadingState {
+ if (states.includes('running')) return 'running'
+ if (allStatesAre(states, ['prepared'])) return 'prepared'
+ if (allStatesAre(states, ['admitted', 'prepared']) && states.includes('admitted')) return 'admitted'
+ if (allStatesAre(states, ['failed'])) return 'failed'
+ if (allStatesAre(states, ['completed']) && !missingExpectedArtifact) return 'completed'
+ if (isMixedPartial(states, missingExpectedArtifact)) return 'partial'
+ return leftoverReading(states, hasArtifact)
+}
+
+function recoveryReason(tasks: ActivityTaskLike[], readingState: ActivityReadingState): string {
+ const failed = [...tasks].reverse().find(task => FAILED_TASK_STATUSES.has(task.status) || task.error?.message)
+ if (failed) return text(failed.error?.message) || failed.detail || failed.message || ''
+ if (readingState === 'partial') {
+ const incomplete = tasks.find(task => taskExpectsArtifact(task) && !taskHasArtifact(task))
+ if (incomplete) return incomplete.detail || incomplete.message || ''
+ }
+ const waiting = tasks.find(task => task.status === 'waiting_resource')
+ if (waiting) return waiting.detail || waiting.message || ''
+ return ''
+}
+
+function projectTarget(task: ActivityTaskLike): ActivityProjectTarget | undefined {
+ const metadata = metadataOf(task)
+ const kind = text(metadata.entity_type) || text(metadata.project_kind) || text(metadata.target_kind)
+ const id = text(metadata.project_id) || text(metadata.entity_id) || text(metadata.target_id)
+ if (!kind || !id) return undefined
+ return { kind, id, title: text(metadata.project_title) || text(metadata.entity_title) || id }
+}
+
+function byCreatedAsc(left: ActivityTaskLike, right: ActivityTaskLike): number {
+ return finiteNumber(left.created_at, 0) - finiteNumber(right.created_at, 0) || left.id.localeCompare(right.id)
+}
+
+function latestTask(tasks: ActivityTaskLike[]): ActivityTaskLike {
+ return [...tasks].sort((left, right) => (
+ finiteNumber(right.attempt, 1) - finiteNumber(left.attempt, 1)
+ || finiteNumber(right.created_at, 0) - finiteNumber(left.created_at, 0)
+ ))[0]
+}
+
+function selectPrimary(ordered: ActivityTaskLike[]): ActivityTaskLike {
+ const roots = ordered.filter(task => !task.parent_id)
+ const liveMembers = ordered.filter(task => isLiveStatus(task.status))
+ if (liveMembers[0]) return liveMembers[0]
+ return latestTask(roots.length ? roots : ordered)
+}
+
+function missingExpectedArtifact(tasks: ActivityTaskLike[]): boolean {
+ return tasks.some(task => task.status === 'completed' && taskExpectsArtifact(task) && !taskHasArtifact(task))
+}
+
+function jobBucketsFor(ordered: ActivityTaskLike[], primary: ActivityTaskLike): Map {
+ const buckets = new Map()
+ const children = ordered.filter(task => task.parent_id)
+ const members = children.length ? children : ordered.filter(task => !task.parent_id)
+ const source = members.length ? members : [primary]
+ for (const task of source) {
+ const list = buckets.get(task.id) ?? []
+ list.push(task)
+ buckets.set(task.id, list)
+ }
+ return buckets
+}
+
+function jobFromTasks(tasks: ActivityTaskLike[]): ActivityJob {
+ const jobPrimary = [...tasks].sort((left, right) => (
+ finiteNumber(right.attempt, 1) - finiteNumber(left.attempt, 1)
+ || finiteNumber(right.updated_at, 0) - finiteNumber(left.updated_at, 0)
+ ))[0]
+ return {
+ id: jobPrimary.id,
+ title: firstText(jobPrimary.title, jobPrimary.kind, jobPrimary.id),
+ readingState: combineReadingState(tasks.map(taskReadingState), tasks.some(taskHasArtifact), missingExpectedArtifact(tasks)),
+ task: jobPrimary,
+ attempts: jobAttempts(tasks),
+ }
+}
+
+function previousFromAttempts(attempts: ActivityAttempt[]): ActivityAttempt | undefined {
+ const currentAttempt = Math.max(1, ...attempts.map(item => item.attempt))
+ const previous = [...attempts].reverse().find(item => item.attempt < currentAttempt)
+ if (previous) return previous
+ if (attempts.length > 1) return attempts[attempts.length - 2]
+ return undefined
+}
+
+function buildGroup(id: string, members: ActivityTaskLike[]): ActivityGroup {
+ const ordered = [...members].sort(byCreatedAsc)
+ const roots = ordered.filter(task => !task.parent_id)
+ const primary = selectPrimary(ordered)
+ const jobs = [...jobBucketsFor(ordered, primary).values()].map(jobFromTasks).sort((left, right) => (
+ byCreatedAsc(left.task, right.task)
+ ))
+ const artifacts = [...new Set(ordered.flatMap(taskArtifactRefs))]
+ const readingState = combineReadingState(
+ ordered.map(taskReadingState),
+ artifacts.length > 0,
+ missingExpectedArtifact(ordered),
+ )
+ const live = ordered.filter(task => isLiveStatus(task.status))
+ return {
+ id,
+ intentId: firstText(taskIntentId(primary), taskIntentId(ordered[0])),
+ receiptId: firstText(taskReceiptId(primary), taskReceiptId(ordered[0])),
+ rootId: firstText(primary.root_id, primary.id),
+ workspace: taskWorkspace(primary),
+ title: firstText(primary.title, primary.kind, primary.id),
+ readingState,
+ progress: readingState === 'completed' ? 100 : taskProgressPercent(live[0] ?? primary),
+ hasArtifact: artifacts.length > 0,
+ createdAt: Math.min(...ordered.map(task => finiteNumber(task.created_at, 0))),
+ primary,
+ jobs,
+ artifacts,
+ previousAttempt: previousFromAttempts(jobAttempts(roots.length ? roots : ordered)),
+ recoveryReason: recoveryReason(ordered, readingState),
+ project: projectTarget(primary) ?? ordered.map(projectTarget).find(Boolean),
+ }
+}
+
+export function groupActivityTasks(
+ tasks: ActivityTaskLike[],
+ options: { workspace?: string } = {},
+): ActivityGroup[] {
+ const scoped = uniqueTasks(tasks).filter(task => {
+ if (!options.workspace) return true
+ const workspace = taskWorkspace(task)
+ return !workspace || workspace === options.workspace
+ })
+ const byId = new Map(scoped.map(task => [task.id, task]))
+ const buckets = new Map()
+ for (const task of scoped) {
+ const key = activityRequestKey(task, byId)
+ const members = buckets.get(key) || []
+ members.push(task)
+ buckets.set(key, members)
+ }
+ const groups = [...buckets.entries()].map(([id, members]) => buildGroup(id, members))
+ const live = groups.filter(group => group.readingState === 'prepared'
+ || group.readingState === 'admitted'
+ || group.readingState === 'running')
+ const terminal = groups.filter(group => !live.includes(group))
+ const byCreated = (left: ActivityGroup, right: ActivityGroup) => (
+ right.createdAt - left.createdAt || left.id.localeCompare(right.id)
+ )
+ return [...live.sort(byCreated), ...terminal.sort(byCreated).slice(0, 12)]
+}
+
+export function findActivityGroup(
+ groups: ActivityGroup[],
+ request: ActivityFocusRequest,
+): ActivityGroup | undefined {
+ if (request.taskId) {
+ const taskId = request.taskId
+ const match = groups.find(group => (
+ group.primary.id === taskId
+ || group.rootId === taskId
+ || group.jobs.some(job => job.id === taskId || job.attempts.some(attempt => attempt.taskId === taskId))
+ ))
+ if (match) return match
+ }
+ if (request.intentId) {
+ const match = groups.find(group => group.intentId === request.intentId)
+ if (match) return match
+ }
+ if (request.receiptId) {
+ return groups.find(group => group.receiptId === request.receiptId)
+ }
+ return undefined
+}
+
+export function preserveActivityChrome(chrome: ActivityChrome): ActivityChrome {
+ return {
+ selectedId: chrome.selectedId,
+ expandedIds: [...chrome.expandedIds],
+ inspectedAttemptByGroup: { ...chrome.inspectedAttemptByGroup },
+ }
+}
diff --git a/ui/src/features/activity/openTargets.ts b/ui/src/features/activity/openTargets.ts
new file mode 100644
index 000000000..e48d55d31
--- /dev/null
+++ b/ui/src/features/activity/openTargets.ts
@@ -0,0 +1,78 @@
+import { useStore } from '../../stores/useStore'
+import type { MediaFilter } from '../../types'
+import type { ActivityProjectTarget } from './lineage'
+
+const TAB_FILTER: Partial> = {
+ comics: 'comics',
+ story_lab: 'stories',
+ series_lab: 'series',
+ video_3d: 'scene3d',
+ character_kit: 'characters',
+ video_editor: 'videoeditor',
+ workspaces: 'runs',
+ studio: 'all',
+}
+
+function tabForActivityTarget(kind?: string): string {
+ switch (kind) {
+ case 'comic': return 'comics'
+ case 'director_production': return 'director'
+ case 'story': return 'story_lab'
+ case 'series':
+ case 'series_episode': return 'series_lab'
+ case 'scene': return 'video_3d'
+ case 'character_kit': return 'character_kit'
+ case 'video_editor': return 'video_editor'
+ case 'workspace_collection': return 'workspaces'
+ default: return 'studio'
+ }
+}
+
+function filterForOutput(type: string, name: string): MediaFilter {
+ if (type === 'video' || /\.(mp4|webm|mov)$/i.test(name)) return 'videos'
+ if (type === 'image' || /\.(png|jpe?g|webp|gif)$/i.test(name)) return 'images'
+ if (type === 'audio' || /\.(wav|mp3|flac|ogg)$/i.test(name)) return 'audio'
+ if (type === 'model3d' || /\.(glb|gltf)$/i.test(name)) return 'model3d'
+ if (type === 'scene') return 'scene3d'
+ if (type === 'comic') return 'comics'
+ return 'all'
+}
+
+export function openActivityArtifact(name: string): boolean {
+ const app = useStore.getState()
+ const file = (app.outputs || []).find(item => item.name === name || item.name.endsWith(`/${name}`))
+ app.setDashboardOpen(false)
+ if (!file) {
+ app.setMediaFilter(filterForOutput('', name))
+ return false
+ }
+ app.setMediaFilter(filterForOutput(file.type, file.name))
+ const filtered = app.filteredOutputs()
+ const index = filtered.findIndex(item => item.name === file.name)
+ if (index >= 0) {
+ app.setSelectedOutput(index)
+ return true
+ }
+ return false
+}
+
+export function openActivityProject(target: ActivityProjectTarget): boolean {
+ const app = useStore.getState()
+ const tab = tabForActivityTarget(target.kind)
+ app.setDashboardOpen(tab === 'director')
+ const filter = TAB_FILTER[tab]
+ if (filter) app.setMediaFilter(filter)
+ if (tab === 'story_lab') {
+ void import('../stories/store').then(({ useStoryStore }) => {
+ useStoryStore.getState().openProject?.(target.id)
+ }).catch(() => undefined)
+ }
+ if (tab === 'series_lab') {
+ void import('../series/store').then(({ useSeriesStore }) => {
+ const series = useSeriesStore.getState()
+ if (target.kind === 'episode' || target.kind === 'series_episode') series.openEpisode?.(target.id)
+ else series.openSeries?.(target.id)
+ }).catch(() => undefined)
+ }
+ return Boolean(filter || tab === 'director')
+}
diff --git a/ui/src/features/activity/taskPresentation.ts b/ui/src/features/activity/taskPresentation.ts
new file mode 100644
index 000000000..1dfde6184
--- /dev/null
+++ b/ui/src/features/activity/taskPresentation.ts
@@ -0,0 +1,245 @@
+import { isLiveStatus, type ActivityTaskLike } from './lineage'
+
+export const PHASE_KEYS: Record = {
+ planning: 'planning',
+ known_series_research: 'knownSeriesResearch',
+ canon: 'canon',
+ outline: 'outline',
+ script: 'script',
+ shots: 'shots',
+ canon_validation: 'canonValidation',
+ canon_delta: 'canonDelta',
+ rendering: 'rendering',
+ generating_images: 'generatingImages',
+ generating_video: 'generatingVideo',
+ post_processing: 'postProcessing',
+ waiting_resource: 'waitingResource',
+ cancelling: 'cancelling',
+ completed: 'completed',
+ failed: 'failed',
+ cancelled: 'cancelled',
+ interrupted: 'interrupted',
+}
+
+function epochMs(value?: number | null): number | undefined {
+ if (!value || !Number.isFinite(value)) return undefined
+ return value < 1_000_000_000_000 ? value * 1000 : value
+}
+
+export function elapsedSeconds(task: ActivityTaskLike, now: number): number | undefined {
+ const start = epochMs(task.started_at || task.queued_at || task.created_at)
+ if (!start) return undefined
+ const end = isLiveStatus(task.status)
+ ? now
+ : epochMs(task.completed_at || task.updated_at) || now
+ return Math.max(0, (end - start) / 1000)
+}
+
+export function formatElapsed(task: ActivityTaskLike, now: number): string {
+ const total = elapsedSeconds(task, now)
+ if (total === undefined) return ''
+ const seconds = Math.floor(total)
+ const hours = Math.floor(seconds / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+ const remainder = seconds % 60
+ return hours
+ ? `${hours}:${minutes.toString().padStart(2, '0')}:${remainder.toString().padStart(2, '0')}`
+ : `${minutes}:${remainder.toString().padStart(2, '0')}`
+}
+
+export function estimatedRemainingSeconds(task: ActivityTaskLike, now: number): number | undefined {
+ if (!isLiveStatus(task.status)) return undefined
+ const elapsed = elapsedSeconds(task, now)
+ const total = Number(task.total || 0)
+ const current = Number(task.current || 0)
+ const fraction = total > 0
+ ? Math.max(0, Math.min(1, current / total))
+ : Math.max(0, Math.min(1, Number(task.progress || 0)))
+ if (!elapsed || elapsed < 3 || fraction < 0.01 || fraction >= 1) return undefined
+ return Math.max(1, Math.round(elapsed * ((1 - fraction) / fraction)))
+}
+
+export function formatEta(seconds: number | undefined): string {
+ if (seconds === undefined) return ''
+ const rounded = Math.max(1, Math.round(seconds))
+ const hours = Math.floor(rounded / 3600)
+ const minutes = Math.floor((rounded % 3600) / 60)
+ const remainder = rounded % 60
+ if (hours) return `~${hours}h ${minutes.toString().padStart(2, '0')}m`
+ if (minutes) return `~${minutes}m ${remainder.toString().padStart(2, '0')}s`
+ return `~${remainder}s`
+}
+
+export function fallbackPhaseLabel(task: ActivityTaskLike): string {
+ return task.phase?.replaceAll('_', ' ') || task.status
+}
+
+export function phaseCatalogKey(task: ActivityTaskLike): string {
+ return PHASE_KEYS[task.phase || ''] || 'fallback'
+}
+
+export function translatedPhase(
+ t: (key: string, options?: object) => string,
+ task: ActivityTaskLike,
+): string {
+ return t(`phases.${phaseCatalogKey(task)}`, { phase: fallbackPhaseLabel(task), defaultValue: fallbackPhaseLabel(task) })
+}
+
+export function resourceSummary(task: ActivityTaskLike): { kind: 'using' | 'waiting' | 'required'; value: string } | '' {
+ const acquired = task.acquired_resources || []
+ const required = task.resource_requirements || []
+ if (acquired.length) return { kind: 'using', value: acquired.join(' · ') }
+ if (task.status === 'waiting_resource' && required.length) return { kind: 'waiting', value: required.join(' · ') }
+ return required.length ? { kind: 'required', value: required.join(' · ') } : ''
+}
+
+function recipeDetails(task: ActivityTaskLike): Record {
+ const metadata = task.metadata || {}
+ const details = metadata.generation_details || metadata.settings
+ if (details && typeof details === 'object' && !Array.isArray(details)) return details as Record
+ return {}
+}
+
+function firstDefined(...values: unknown[]): unknown {
+ for (const value of values) {
+ if (value === undefined) continue
+ if (value === null) continue
+ return value
+ }
+ return undefined
+}
+
+function pushUniqueModel(parts: string[], label: string, value: unknown): void {
+ if (!value) return
+ const model = String(value)
+ if (parts.some(part => part === model || part.endsWith(` ${model}`))) return
+ parts.push(label ? `${label} ${model}` : model)
+}
+
+function appendRecipeModels(parts: string[], details: Record): void {
+ pushUniqueModel(parts, '', firstDefined(details.model_name, details.model_type))
+ pushUniqueModel(parts, 'text', details.text_model)
+ pushUniqueModel(parts, 'image', firstDefined(details.image_model_name, details.image_model_type))
+ pushUniqueModel(parts, 'video', firstDefined(details.video_model_name, details.video_model_type))
+}
+
+function appendDefined(parts: string[], value: unknown, label: (item: unknown) => string): void {
+ if (value === undefined) return
+ parts.push(label(value))
+}
+
+function appendRecipeCore(parts: string[], details: Record): void {
+ if (details.simulated === true) parts.push('SIMULATED')
+ else if (details.execution_mode === 'simulate') parts.push('SIMULATED')
+ const resolution = firstDefined(details.video_resolution, details.image_resolution, details.resolution)
+ if (resolution) parts.push(String(resolution))
+ appendDefined(parts, details.seed, value => `seed ${value}`)
+ const steps = firstDefined(details.video_steps, details.image_steps, details.steps, details.numInferenceSteps)
+ appendDefined(parts, steps, value => `${value} steps`)
+ appendDefined(parts, details.guidance, value => `guidance ${value}`)
+ appendDefined(parts, details.frames, value => `${value} frames`)
+ appendDefined(parts, details.duration_seconds, value => `${value}s`)
+}
+
+function h3Minimum(details: Record): string {
+ if (details.dialogue_duration_minimum_limited) return ' · H3 minimum applied'
+ return ''
+}
+
+function dialogueLine(details: Record): string {
+ if (details.dialogue_syllables !== undefined) {
+ return `dialogue ${details.dialogue_syllables} syllables × ${details.dialogue_seconds_per_syllable}s → ${details.dialogue_duration_calculated}s calculated${h3Minimum(details)}`
+ }
+ if (details.dialogue_words !== undefined) {
+ return `dialogue ${details.dialogue_words} words → ${details.dialogue_duration_calculated}s calculated${h3Minimum(details)}`
+ }
+ return ''
+}
+
+function cacheLine(details: Record): string {
+ if (details.cache === undefined) return ''
+ if (!details.cache) return 'Cache off'
+ if (details.cache_type) return `Cache on (${details.cache_type})`
+ return 'Cache on'
+}
+
+function loraLine(details: Record): string {
+ if (details.lora_count === undefined) return ''
+ if (!details.lora_count) return 'LoRAs off'
+ const loras = Array.isArray(details.loras) ? details.loras.map(String).filter(Boolean) : []
+ const suffix = Number(details.lora_count) === 1 ? '' : 's'
+ const names = loras.length ? ` (${loras.join(', ')})` : ''
+ return `${details.lora_count} LoRA${suffix}${names}`
+}
+
+function appendRecipeFlags(parts: string[], details: Record): void {
+ if (details.profile) parts.push(`profile ${details.profile}`)
+ const flow = firstDefined(details.flow_shift, details.flowShift)
+ appendDefined(parts, flow, value => `flow shift ${value}`)
+ const audio = firstDefined(details.audio_shift, details.audioShift)
+ appendDefined(parts, audio, value => `audio shift ${value}`)
+ if (details.turbo !== undefined) parts.push(`Turbo ${details.turbo ? 'on' : 'off'}`)
+ const cache = cacheLine(details)
+ if (cache) parts.push(cache)
+ const loras = loraLine(details)
+ if (loras) parts.push(loras)
+ appendDefined(parts, details.clip_count, value => `${value} clips`)
+}
+
+export function generationRecipe(task: ActivityTaskLike): string {
+ const details = recipeDetails(task)
+ const parts = [task.provider, task.model].filter(Boolean) as string[]
+ appendRecipeModels(parts, details)
+ appendRecipeCore(parts, details)
+ const dialogue = dialogueLine(details)
+ if (dialogue) parts.push(dialogue)
+ appendRecipeFlags(parts, details)
+ return parts.join(' · ')
+}
+
+export function generationPrompt(task: ActivityTaskLike): string {
+ const metadata = task.metadata || {}
+ const details = recipeDetails(task)
+ const value = firstDefined(details.prompt, metadata.prompt, metadata.prompt_preview)
+ if (typeof value === 'string') return value.trim()
+ return ''
+}
+
+function directorInitiator(task: ActivityTaskLike, mode: string): string {
+ if (task.parent_id?.startsWith('task-director-')) return directorLabel(mode)
+ if (task.workflow === 'director') return directorLabel(mode)
+ return ''
+}
+
+function directorLabel(mode: string): string {
+ if (!mode) return 'Director'
+ if (mode === 'music video') return 'Director · Music video'
+ return `Director · ${mode}`
+}
+
+function studioInitiator(mode: string): string {
+ if (mode === 'model3d') return 'Studio · 3D'
+ if (mode) return `Studio · ${mode[0].toUpperCase()}${mode.slice(1)}`
+ return 'Studio · Generation'
+}
+
+export function generationInitiator(task: ActivityTaskLike): string {
+ const metadata = task.metadata || {}
+ const details = recipeDetails(task)
+ const explicit = firstDefined(details.initiator, metadata.initiator)
+ if (typeof explicit === 'string' && explicit.trim()) return explicit.trim()
+ const mode = String(firstDefined(details.generation_mode, task.kind, '')).replaceAll('_', ' ')
+ if (task.parent_id?.startsWith('task-series-')) return 'Series Lab · Chapter'
+ if ((task.workflow || '').startsWith('series')) return 'Series Lab · Chapter'
+ const director = directorInitiator(task, mode)
+ if (director) return director
+ if (task.workflow === 'audio-analysis') return 'Story/Director · Audio analysis'
+ if (task.workflow === 'generation') return studioInitiator(mode)
+ if (task.workflow) return task.workflow.replaceAll('_', ' ')
+ return ''
+}
+
+export function truncatePrompt(prompt: string, limit = 180): string {
+ const oneLine = prompt.replace(/\s+/g, ' ').trim()
+ return oneLine.length > limit ? `${oneLine.slice(0, limit - 1)}…` : oneLine
+}
diff --git a/ui/src/features/activity/useActivityPanel.ts b/ui/src/features/activity/useActivityPanel.ts
new file mode 100644
index 000000000..c93a08768
--- /dev/null
+++ b/ui/src/features/activity/useActivityPanel.ts
@@ -0,0 +1,124 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { listenForAgentActivityDetails, type ActivityDetailsRequest } from '../../lib/uiBus'
+import { findActivityGroup, type ActivityGroup } from './lineage'
+
+function afterPaint(callback: () => void): void {
+ if (typeof window.requestAnimationFrame === 'function') window.requestAnimationFrame(callback)
+ else queueMicrotask(callback)
+}
+
+export function useActivityPanel(groups: ActivityGroup[], workspace: string) {
+ const [detailsOpen, setDetailsOpen] = useState(false)
+ const [selectedGroupId, setSelectedGroupId] = useState(null)
+ const [expandedGroupIds, setExpandedGroupIds] = useState>(() => new Set())
+ const [inspectedAttemptByGroup, setInspectedAttemptByGroup] = useState>({})
+ const [focusNonce, setFocusNonce] = useState(0)
+ const [chromeWorkspace, setChromeWorkspace] = useState(workspace)
+ const pendingFocusRef = useRef(null)
+ const restoreFocusRef = useRef(null)
+ const toggleRef = useRef(null)
+ const panelRef = useRef(null)
+ const detailsOpenRef = useRef(detailsOpen)
+ if (chromeWorkspace !== workspace) {
+ setChromeWorkspace(workspace)
+ setSelectedGroupId(null)
+ setExpandedGroupIds(new Set())
+ setInspectedAttemptByGroup({})
+ }
+
+ const closePanel = useCallback(() => {
+ detailsOpenRef.current = false
+ setDetailsOpen(false)
+ const restore = restoreFocusRef.current || toggleRef.current
+ restoreFocusRef.current = null
+ afterPaint(() => restore?.focus())
+ }, [])
+
+ const openPanel = useCallback(() => {
+ if (!detailsOpenRef.current) {
+ restoreFocusRef.current = document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : toggleRef.current
+ }
+ detailsOpenRef.current = true
+ setDetailsOpen(true)
+ }, [])
+
+ const togglePanel = useCallback(() => {
+ if (detailsOpenRef.current) closePanel()
+ else openPanel()
+ }, [closePanel, openPanel])
+
+ useEffect(() => listenForAgentActivityDetails(request => {
+ openPanel()
+ if (!request?.taskId && !request?.intentId && !request?.receiptId) return
+ pendingFocusRef.current = request
+ setFocusNonce(value => value + 1)
+ }), [openPanel])
+
+ useEffect(() => {
+ const pending = pendingFocusRef.current
+ if (!pending) return
+ if (!groups.length) return
+ const match = findActivityGroup(groups, pending)
+ if (!match) return
+ setSelectedGroupId(match.id)
+ setExpandedGroupIds(current => new Set(current).add(match.id))
+ if (pending.inspectPreviousAttempt && match.previousAttempt) {
+ setInspectedAttemptByGroup(current => ({ ...current, [match.id]: match.previousAttempt!.id }))
+ }
+ pendingFocusRef.current = null
+ afterPaint(() => {
+ const node = panelRef.current?.querySelector(`[data-group-id="${match.id}"]`)
+ if (node instanceof HTMLElement) node.focus()
+ })
+ }, [focusNonce, groups])
+
+ useEffect(() => {
+ if (!detailsOpen) return
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key !== 'Escape') return
+ if (event.defaultPrevented) return
+ event.preventDefault()
+ event.stopPropagation()
+ closePanel()
+ }
+ document.addEventListener('keydown', onKey, true)
+ window.addEventListener('keydown', onKey, true)
+ return () => {
+ document.removeEventListener('keydown', onKey, true)
+ window.removeEventListener('keydown', onKey, true)
+ }
+ }, [closePanel, detailsOpen])
+
+ const toggleExpanded = (groupId: string) => {
+ setExpandedGroupIds(current => {
+ const next = new Set(current)
+ if (next.has(groupId)) next.delete(groupId)
+ else next.add(groupId)
+ return next
+ })
+ }
+
+ const inspectPrevious = (group: ActivityGroup) => {
+ if (!group.previousAttempt) return
+ setSelectedGroupId(group.id)
+ setExpandedGroupIds(current => new Set(current).add(group.id))
+ setInspectedAttemptByGroup(current => ({ ...current, [group.id]: group.previousAttempt!.id }))
+ }
+
+ return {
+ detailsOpen,
+ selectedGroupId,
+ expandedGroupIds,
+ inspectedAttemptByGroup,
+ toggleRef,
+ panelRef,
+ closePanel,
+ openPanel,
+ togglePanel,
+ setSelectedGroupId,
+ toggleExpanded,
+ inspectPrevious,
+ }
+}
diff --git a/ui/src/features/activity/useActivityTasks.ts b/ui/src/features/activity/useActivityTasks.ts
new file mode 100644
index 000000000..43ff3f8a2
--- /dev/null
+++ b/ui/src/features/activity/useActivityTasks.ts
@@ -0,0 +1,164 @@
+import { useEffect, useRef, useState } from 'react'
+import * as api from '../../api/client'
+import type { CanonicalTask } from '../../api/client'
+import { applyCanonicalTaskEvent, reconcileCanonicalTaskSnapshot } from '../../lib/canonicalTaskEvents'
+import { publishCanonicalTasks } from './canonicalTaskFeed'
+import type { TaskControlAction, TaskControlFailure } from './executionDetail'
+import { isLiveStatus } from './lineage'
+
+const CONNECTED_RECONCILE_MS = 60_000
+const DISCONNECTED_POLL_MS = 5_000
+
+function controlRequest(taskId: string, workspace: string, action: TaskControlAction) {
+ if (action === 'cancel') return api.cancelCanonicalTask(taskId, workspace)
+ if (action === 'resume') return api.resumeCanonicalTask(taskId, workspace)
+ return api.dismissCanonicalTask(taskId, workspace)
+}
+
+function applyControlSuccess(
+ current: CanonicalTask[],
+ taskId: string,
+ action: TaskControlAction,
+ result: CanonicalTask,
+): CanonicalTask[] {
+ if (action === 'dismiss') return current.filter(item => item.id !== taskId)
+ return current.map(item => {
+ if (item.id !== taskId) return item
+ if (Number(result.updated_at) < Number(item.updated_at)) return item
+ return result
+ })
+}
+
+export function useActivityTasks(activeWorkspace: string) {
+ const workspaceRef = useRef(activeWorkspace)
+ workspaceRef.current = activeWorkspace
+ const [tasks, setTasks] = useState([])
+ const tasksRef = useRef([])
+ const [busyIds, setBusyIds] = useState>(() => new Set())
+ const [controlFailures, setControlFailures] = useState>({})
+
+ const commitTasks = (next: CanonicalTask[]) => {
+ tasksRef.current = next
+ setTasks(next)
+ publishCanonicalTasks(next)
+ }
+
+ useEffect(() => {
+ let mounted = true
+ let refreshPending = false
+ let streamConnected = false
+ let pollTimer: number | null = null
+ let closeEvents: () => void = () => undefined
+ let unknownTaskBaseline = 0
+
+ const refresh = async (): Promise => {
+ if (refreshPending) return null
+ refreshPending = true
+ try {
+ const result = await api.fetchCanonicalTasks(activeWorkspace, 'all')
+ if (mounted) {
+ unknownTaskBaseline = Math.max(
+ unknownTaskBaseline,
+ ...result.tasks.map(task => Number(task.updated_at || 0)),
+ )
+ commitTasks(reconcileCanonicalTaskSnapshot(tasksRef.current, result.tasks, unknownTaskBaseline))
+ }
+ return Number(result.latest_event_id || 0)
+ } catch {
+ return null
+ } finally {
+ refreshPending = false
+ }
+ }
+
+ const schedulePoll = () => {
+ if (!mounted) return
+ if (pollTimer !== null) window.clearTimeout(pollTimer)
+ pollTimer = window.setTimeout(async () => {
+ pollTimer = null
+ await refresh()
+ schedulePoll()
+ }, streamConnected ? CONNECTED_RECONCILE_MS : DISCONNECTED_POLL_MS)
+ }
+
+ const connectAfterSnapshot = async () => {
+ const initialEventId = await refresh()
+ if (!mounted) return
+ // Never replay from zero after a failed snapshot. Retrying the small
+ // snapshot request first is bounded; opening SSE without its cursor is
+ // not bounded on a long-lived workspace.
+ if (initialEventId === null) {
+ pollTimer = window.setTimeout(() => {
+ pollTimer = null
+ void connectAfterSnapshot()
+ }, DISCONNECTED_POLL_MS)
+ return
+ }
+ closeEvents = api.subscribeCanonicalTaskEvents(
+ activeWorkspace,
+ event => {
+ const result = applyCanonicalTaskEvent(tasksRef.current, event, unknownTaskBaseline)
+ if (result.tasks !== tasksRef.current) commitTasks(result.tasks)
+ if (result.needsRefresh) void refresh()
+ },
+ () => undefined,
+ state => {
+ if (!mounted) return
+ streamConnected = state === 'open'
+ schedulePoll()
+ },
+ initialEventId,
+ )
+ schedulePoll()
+ }
+
+ tasksRef.current = []
+ setTasks([])
+ publishCanonicalTasks([])
+ setControlFailures({})
+ void connectAfterSnapshot()
+ return () => {
+ mounted = false
+ closeEvents()
+ if (pollTimer !== null) window.clearTimeout(pollTimer)
+ }
+ }, [activeWorkspace])
+
+ const runControl = (task: CanonicalTask, action: TaskControlAction, onFailure?: () => void) => {
+ if (busyIds.has(task.id)) return
+ const workspace = activeWorkspace
+ const taskId = task.id
+ setBusyIds(current => new Set(current).add(taskId))
+ void controlRequest(taskId, workspace, action).then(result => {
+ if (workspaceRef.current !== workspace) return
+ commitTasks(applyControlSuccess(tasksRef.current, taskId, action, result as CanonicalTask))
+ setControlFailures(current => {
+ if (!current[taskId]) return current
+ const nextFailures = { ...current }
+ delete nextFailures[taskId]
+ return nextFailures
+ })
+ }).catch(reason => {
+ if (workspaceRef.current !== workspace) return
+ const message = reason instanceof Error ? reason.message : String(reason)
+ setControlFailures(current => ({ ...current, [taskId]: { action, message } }))
+ onFailure?.()
+ }).finally(() => {
+ setBusyIds(current => {
+ const next = new Set(current)
+ next.delete(taskId)
+ return next
+ })
+ })
+ }
+
+ return { tasks, tasksRef, busyIds, controlFailures, runControl }
+}
+
+export function hideTerminalHistory(tasks: CanonicalTask[], hidden: Set): Set {
+ const next = new Set(hidden)
+ for (const task of tasks) {
+ if (!isLiveStatus(task.status)) next.add(task.id)
+ }
+ return next
+}
diff --git a/ui/src/features/agent/AgentAssistantPanel.tsx b/ui/src/features/agent/AgentAssistantPanel.tsx
index c8fdea08d..3d31d0b63 100644
--- a/ui/src/features/agent/AgentAssistantPanel.tsx
+++ b/ui/src/features/agent/AgentAssistantPanel.tsx
@@ -14,6 +14,7 @@ import {
type AgentActionResult,
} from './agentActions'
import { applyPollToCard, cardsFromResults, tabForExecutionTarget, type WizardExecutionCard } from './executionCards'
+import { openAgentActivityDetails } from './agentUiBus'
import {
applyRemoteWizardConversation,
isWizardConversationWriteCurrent,
@@ -30,7 +31,7 @@ import i18n, { useUiTranslation } from '../../i18n'
import { WizardVisualInput, type WizardVisualMedia } from './WizardVisualInput'
import type { VisualEvidence } from './visualEvidence'
import { WizardVisualEvidence } from './WizardVisualEvidence'
-import { reconcileWizardMediaTurn } from './wizardVisualPolicy'
+import { validateWizardPlan } from './wizardVisualPolicy'
import { formatWizardTurnReply, normalizeWizardResult, wizardTurnVisualState } from './wizardTurnReport'
export { AgentAvatar, type AgentVisualState } from './AgentAvatar'
@@ -271,22 +272,24 @@ export function AgentAssistantPanel({ workspace, tasks, onClose, embedded = fals
}
if (!mountedRef.current || !isWizardConversationWriteCurrent(conversationWorkspaceRef.current, conversationWorkspace)) return
setConversationSaveError(null)
- const visibleMessages = messagesRef.current
const pendingClearBase = conversationClearBasesRef.current.get(conversationWorkspace)
?? (queuedWrite.honorLocalDeletes ? queuedWrite.base : undefined)
- const rebased = rebaseWizardConversationAfterSave({
- ...queuedWrite.captured,
- revision: saved.conversation.revision,
- messages: visibleMessages,
- executions: visibleMessages.flatMap(message => message.cards || []),
- }, queuedWrite.captured, saved.conversation, pendingClearBase)
- if (saved.merged || rebased.needsPersist) {
+ // A fast conversational reply may be queued for React before this
+ // save finishes. Rebase against the latest state, not the last render's ref.
+ setMessages(visibleMessages => {
+ const rebased = rebaseWizardConversationAfterSave({
+ ...queuedWrite.captured,
+ revision: saved.conversation.revision,
+ messages: visibleMessages,
+ executions: visibleMessages.flatMap(message => message.cards || []),
+ }, queuedWrite.captured, saved.conversation, pendingClearBase)
+ if (!saved.merged && !rebased.needsPersist) return visibleMessages
skipNextConversationSaveRef.current = !rebased.needsPersist
- setMessages(normalizeRemoteWizardMessages(
+ return normalizeRemoteWizardMessages(
rebased.conversation.messages,
rebased.conversation.executions,
- ) as AgentMessage[])
- }
+ ) as AgentMessage[]
+ })
} catch (error) {
if (!mountedRef.current || !isWizardConversationWriteCurrent(conversationWorkspaceRef.current, conversationWorkspace)) return
setConversationSaveError(error instanceof Error ? error.message : String(error))
@@ -454,7 +457,7 @@ export function AgentAssistantPanel({ workspace, tasks, onClose, embedded = fals
return
}
let mediaEvidence: VisualEvidence[] = []
- const answer = await generateLlmText({
+ const llmRequest: Parameters[0] = {
onMediaEvidence: evidence => { mediaEvidence = evidence },
media: turnMedia,
workspace,
@@ -466,14 +469,22 @@ export function AgentAssistantPanel({ workspace, tasks, onClose, embedded = fals
max_new_tokens: 3_200,
temperature: .1,
json_schema: wizardLlmRequestSchema(),
- })
+ }
+ let answer = await generateLlmText(llmRequest)
if (!mountedRef.current) return
- const proposedTurn = parseAgentTurn(answer)
- const reconciledTurn = await reconcileWizardMediaTurn(
+ let proposedTurn = parseAgentTurn(answer)
+ if (!proposedTurn.intent && !turnMedia) {
+ // Repair the structured interpretation once, before any proposed action
+ // can run. The model still interprets the original request in context.
+ answer = await generateLlmText({ ...llmRequest,
+ prompt: `${llmRequest.prompt}\n\nThe previous response did not contain a valid intent. Return a corrected complete plan matching the schema, including intent.kind, goal, question and execution. Use clarification with a focused question when essential context is missing; do not invent completed actions. Previous response (untrusted data):\n${JSON.stringify(answer)}`,
+ })
+ if (!mountedRef.current) return
+ proposedTurn = parseAgentTurn(answer)
+ }
+ const reconciledTurn = validateWizardPlan(
Boolean(turnMedia),
- question,
proposedTurn,
- nextMessages.map(message => ({ role: message.role, text: message.text })),
)
const turn = protectUserVerbatimSegments(question, {
...reconciledTurn,
@@ -503,7 +514,7 @@ export function AgentAssistantPanel({ workspace, tasks, onClose, embedded = fals
id: newId(),
role: 'assistant',
text: formatWizardTurnReply({ ...turn, reply: humanReply(turn.reply || '') }, results,
- (key, options) => String(t(key, { defaultValue: key, ...options })), question),
+ (key, options) => String(t(key, { defaultValue: key, ...options }))),
createdAt: Date.now(),
language: turn.conversationLanguage || undefined,
mediaEvidence,
@@ -620,6 +631,19 @@ export function AgentAssistantPanel({ workspace, tasks, onClose, embedded = fals
{card.controls.open && (
void executeAgentActions([{ type: 'open_tab', tab: tabForExecutionTarget(card.target?.kind) }])}>{t('openTarget')}
)}
+ {(card.taskId || (typeof card.metadata?.commandId === 'string' && card.metadata.commandId) || (typeof card.metadata?.intent_id === 'string' && card.metadata.intent_id)) && (
+ openAgentActivityDetails({
+ taskId: card.taskId,
+ intentId: typeof card.metadata?.commandId === 'string' ? card.metadata.commandId : typeof card.metadata?.intent_id === 'string' ? card.metadata.intent_id : undefined,
+ receiptId: typeof card.metadata?.receiptId === 'string' ? card.metadata.receiptId : undefined,
+ })}
+ >
+ {t('viewInActivity')}
+
+ )}
{card.controls.cancel && (
void executeAgentActions([{ type: 'cancel_task', taskId: card.taskId || 'latest', confirm: true }])}>{tCommon('actions.cancel')}
)}
diff --git a/ui/src/features/agent/agentActions.ts b/ui/src/features/agent/agentActions.ts
index f0a1b9b86..972353b22 100644
--- a/ui/src/features/agent/agentActions.ts
+++ b/ui/src/features/agent/agentActions.ts
@@ -49,6 +49,7 @@ import type {
AgentMountVideoclipAlternativeSongAction,
} from './alternativeSongActions'
import type { ExampleConversation } from './agentExamples'
+import { parseWizardIntent, WIZARD_INTENT_SCHEMA, type WizardIntent } from './wizardIntent'
import type { AgentSeriesSection, AgentStorySection } from './agentUiBus'
import { ARCADE_HORDE_SFX_PACK, type AgentSfxClip } from './sfxPack'
import {
@@ -478,8 +479,8 @@ export interface AgentCreateRhythmic3dVideoAction extends AgentLanguageAwareActi
}
export type AgentSceneWorkflowAction =
- | { type: 'create_3d_scene'; sceneName: string; durationSeconds: number; width: number; height: number; fps: 30 | 60; confirm: true }
- | { type: 'set_3d_scene_properties'; sceneName: string; durationSeconds?: number; width?: number; height?: number; fps?: 30 | 60; confirm: true }
+ | { type: 'create_3d_scene'; sceneName: string; durationSeconds: number; width: number; height: number; fps: 24 | 30 | 60; confirm: true }
+ | { type: 'set_3d_scene_properties'; sceneName: string; durationSeconds?: number; width?: number; height?: number; fps?: 24 | 30 | 60; confirm: true }
| { type: 'add_3d_scene_layer'; sceneName: string; layerName: string; layerType: 'model3d' | 'image' | 'video' | 'overlay' | 'camera'; outputName: string; confirm: true }
| { type: 'update_3d_scene_layer'; sceneName: string; layerName: string; visible?: boolean; locked?: boolean; confirm: true }
| { type: 'remove_3d_scene_layer'; sceneName: string; layerName: string; confirm: true }
@@ -695,6 +696,8 @@ export type AgentAction = AgentOpenTabAction
export interface AgentTurn {
reply: string
actions: AgentAction[]
+ /** Semantic interpretation proposed by the planner, never proof of execution. */
+ intent?: WizardIntent
/** Locally derived validation/policy diagnostics, never trusted from the model. */
rejections?: WizardActionRejection[]
/** Original proposal positions when parser exclusions shifted action indices. */
@@ -1764,9 +1767,11 @@ export function parseAgentTurn(raw: string): AgentTurn {
proposalIndices.push(index)
}
const conversationLanguage = normalizeConversationLanguageTag(object.conversation_language)
+ const intent = parseWizardIntent(object.intent)
return {
reply: reply || (actions.length ? 'El hechizo está trazado; voy a mover HocusPocus.' : humanReply(raw.trim())),
actions,
+ ...(intent ? { intent } : {}),
...(proposalIndices.some((index, position) => index !== position) ? { proposalIndices } : {}),
...(rejections.length ? { rejections } : {}),
...(conversationLanguage ? { conversationLanguage } : {}),
@@ -2662,6 +2667,7 @@ export const HOCUSPOCUS_AGENT_RESPONSE_SCHEMA: Record = mergeRe
additionalProperties: false,
properties: {
reply: { type: 'string', maxLength: 8_000 },
+ intent: WIZARD_INTENT_SCHEMA,
conversation_language: { type: 'string', maxLength: 120 },
actions: {
type: 'array',
@@ -2755,7 +2761,7 @@ export const HOCUSPOCUS_AGENT_RESPONSE_SCHEMA: Record = mergeRe
output_name: { type: 'string', maxLength: 300 },
width: { type: 'integer', minimum: 320, maximum: 7680 },
height: { type: 'integer', minimum: 240, maximum: 4320 },
- fps: { type: 'integer', enum: [30, 60] },
+ fps: { type: 'integer', enum: [24, 30, 60] },
visible: { type: 'boolean' },
locked: { type: 'boolean' },
confirm: { type: 'boolean' },
@@ -2881,7 +2887,7 @@ export const HOCUSPOCUS_AGENT_RESPONSE_SCHEMA: Record = mergeRe
},
},
},
- required: ['reply', 'actions'],
+ required: ['reply', 'intent', 'actions'],
})
export function wizardLlmRequestSchema(): Record {
diff --git a/ui/src/features/agent/agentKnowledge.ts b/ui/src/features/agent/agentKnowledge.ts
index d1b3029ae..ad5dd8cfe 100644
--- a/ui/src/features/agent/agentKnowledge.ts
+++ b/ui/src/features/agent/agentKnowledge.ts
@@ -75,6 +75,12 @@ Language contract:
Action and truthfulness rules:
- Return only JSON matching the supplied schema. Put the user-facing answer in reply as readable Markdown (short headings and numbered lists). Never paste the actions JSON, schema fields or raw tool payload into reply.
- Never claim success in reply. The application executes actions after your response and appends their real result as a short Markdown report. Do not repeat that report inside reply.
+- Interpret the user's intended outcome from the whole message, recent conversation and current app snapshot, including paraphrases, indirect requests, typos, negation and answers to earlier questions. Do not require command words, exact phrases or names of UI sections. You are the intent interpreter; the application validates your structured plan and does not invent actions from keywords.
+- Always include intent with kind, goal, question and execution. goal is a concise description of the user's intended outcome, not a claim that it happened. kind=conversation is for explanation or brainstorming, with actions=[] and question="". kind=clarification means essential input is missing: put one short, contextual follow-up in question, and include only optional navigation actions. kind=action means there is enough context to act: set question="" and provide the actual supported actions. A mixed explanation/action request is kind=action. Only execution receipts can certify completion.
+- Interpret execution scope from meaning and context too: execution=none for conversation or clarification; execution=prepare for editing a draft, filling controls or navigation while generation/processing remains unrequested or deferred; execution=run when the user wants generation, processing, export, or a requested retry to run. A plan with execution=prepare must not contain compute or external-cost actions. Negation and a request to wait constrain the scope even when earlier context asked for generation. Never treat this field as confirmation that anything has run.
+- Take creative initiative within the user's requested outcome. A subject, setting, characters, tone or reference combination can provide enough direction for an editable first draft; the user does not need to supply a finished premise, a title, a pilot plot or every production setting. Invent provisional missing creative details consistent with their brief. Do not make an explicit delegation phrase a prerequisite. If the conversation is already about creating a project, a reply giving creative direction continues that request; do not restart the interview or require the user to repeat the creation command.
+- For a series-creation request with usable creative direction in the message or earlier conversation, use kind=action, execution=prepare and create_series_episode with create_if_missing=true to develop an original series premise, provisional title, world, characters, locations and a first episode outline. Opening Series Lab is only navigation. Use an existing intended series when present, preserving its canon. If there is truly no creative direction yet, ask one short question about the idea or audience. If the user requests only discussion, options or review before saving, keep it conversational and propose a concrete concept in reply. Generating footage, approving existing canon or overwriting an existing project still requires the corresponding user intent.
+- Interpret creative references by their role. Inspiration for visual style, tone or a workplace milieu does not mean the user wants an existing fictional universe or its cast. When the brief describes original people and a new setting, create original characters/world, express the desired visual and narrative traits, and leave known_universe=false. Never instruct the user to say a particular phrase to proceed.
- Use open_tab to navigate. Supported tabs are studio, director, productions, images, videos, audio, 3d, story_lab, series_lab, comics, video_editor, video_3d, animate_3d, character_creator, character_kit, workspaces and settings.
- Use open_story_section and open_series_section for the internal workflow sections; do not pretend that opening only the outer Lab selected an internal step. If a Story section is not visible for the current project type, open the equivalent compact destination or say it is unavailable. Never report a section as open when the lab discarded it.
- Use prepare_video to open Studio → Video and fill its validated properties. Use prepare_image for Studio → Image. Use prepare_audio for Studio → Audio (audio_sub_mode speech, music or sfx). Use prepare_3d for Studio → 3D / Hunyuan3D. Use queue_sfx_pack with confirm=true to enqueue several SFX clips. Use create_comic to fill Comics lettering. Use start_generation after a matching prepare action when the user asks to generate/start/launch/queue that media or asks for a filled example.
@@ -85,7 +91,7 @@ Action and truthfulness rules:
- Use attach_studio_references only with exact names from recent_image_outputs. Put it after prepare_image/prepare_video and before start_generation in the same turn. reference_role=start_frame is I2V; subject preserves people/objects; style preserves subject/landscape style. Never invent a filename.
- Use configure_studio_loras only with exact filenames from current_studio_loras.available or names explicitly supplied by the user. Put it after prepare_image/prepare_video so compatibility is checked against the selected model, and before start_generation. Weight must be 0..2; replace_existing=true may also clear all LoRAs with an empty list. Never claim an unavailable LoRA was activated.
- An explicit request such as “hazme/genera/crea un vídeo de X” or “hazme/genera/crea una imagen de X” is already enough information: choose the current compatible model and sensible defaults. Do not ask for style, model, duration or format unless the user explicitly asked to review choices before generating.
-- A bare section request with no topic (“hazme un vídeo/cómic/historia”) should ask what they want. If they then say “hazme uno de ejemplo” (or invent/demo/sorpréndeme), invent a different complete example and execute it. Never reuse the same title/prompt from this conversation.
+- A creative request with neither a topic nor permission to invent one should ask what the user has in mind. Once a usable direction or delegation is present anywhere in the conversation, develop it into a complete suitable draft and execute the requested preparation or generation. Do not ask the user to invent the premise or name for you. Never reuse the same title/prompt from this conversation for a new example.
- Speech and Music are audio-only (KugelAudio/Qwen/ACE-Step). SFX is still MMAudio via a short LTX video carrier; there is no dedicated text-to-SFX model in the catalog.
- If the user only asks to prepare, show, fill or configure, use the matching prepare/create action without start_generation.
- For SFX prepare_audio, preserve the currently selected video guide by OMITTING video_guide entirely. Use video_guide: null ONLY for an explicit request to remove the guide or generate without video. Replacing a video's audio does not mean removing its guide. Never invent a guide reference. Keep user-specified duration_seconds, seed and prompt unchanged.
@@ -103,7 +109,7 @@ Action and truthfulness rules:
- Use generate_story_song with confirm=true when the user explicitly says generate, execute, launch or create the configured song. For a request that creates and executes a new videoclip, order create_story(project_type=music_video) → configure_story_song → generate_story_song → stage_story_music_video → start_director_production. “A videoclip of/with/for a song about/in which…” describes a new song and project; it never means “reuse the currently selected song”. Reuse the open candidate only when the user explicitly says selected/current/this song or identifies an existing project, cue or candidate. Never omit project_type=music_video when the user asked for a videoclip. If song generation fails, do not stage or launch the videoclip. Do not call generate_story_visuals for a named film/series look; MiniMax H3 text-to-video must lock that style from the prompt, not from generated stills or photoreal movie frames.
- Use start_director_production with confirm=true only after stage_story_video or stage_story_music_video when the user explicitly asks to launch that prepared film/trailer/videoclip. It starts the exact Wizard handoff, returns the real Director pipeline ID and links it to Story production history. Never claim completion at launch. Distinguish preparado, en cola, en marcha and terminado.
- Use stage_story_music_video with confirm=true to prepare a Story Lab videoclip. The app.story snapshot is authoritative for the currently open project, active_cue_title and selected_song_name; when the user says "this/current/now", leave target_story_title, cue_title and song_name empty so the executor uses those active selections. A rendered version name such as "Title · Español · v2" is a song_name, never a cue_title. Save a reopenable production snapshot and load Music Video Director with the song analyzed at Structure. Never start image/video generation in this action. If several songs exist, song_name or cue_title must be exact and unique. Named movie/series looks use MiniMax H3 T2V (direct_video), not Flux/start-frame stills.
-- Use create_series_episode for a direct request to create a chapter or episode. Chapters and episodes belong in Series Lab, never Story Lab. Search/reuse the named series or create it when create_if_missing=true; that path can create a series. Never say series creation is impossible. Use recent conversation to recover the series name when the final message says “invent it all”.
+- Use create_series_episode for a direct request to create a chapter or episode. Chapters and episodes belong in Series Lab, never Story Lab. Search/reuse the named series or create it when create_if_missing=true; that path can create a series. Never say series creation is impossible. Use recent conversation to recover the series name and premise when the user delegates the remaining creative choices.
- Use stage_series_comic with confirm=true when the user explicitly asks to adapt the active or exactly named Series episode into an editable comic. It reuses the existing Series comic handoff, replaces the current Comic draft and opens Comic Director, but does not draw panels; use generate_comic only after an explicit render request.
- Use update_series_episode to revise an existing episode. Leave series_title/target_episode_title empty only when the intended series and episode are already active; otherwise use exact titles. It patches title, premise, logline, duration and/or outline while preserving the existing script, shots, attempts and frozen canon snapshot.
- Use generate_series_plan with confirm=true only after an explicit request to generate/regenerate episode planning. scope=outline writes beats, script writes scenes, shots requires an existing script, and complete proposes script plus timed shots. It starts a recoverable job shown in Episode room; it does not apply the proposal or render shots.
@@ -120,8 +126,8 @@ Action and truthfulness rules:
- There is no “Render page” control. Panel artwork is Comic Director → **Generate all images**, or generate_comic with confirm=true after “lánzalo / dibuja las viñetas”. render_mode=missing resumes from the first pending panel, failed retries recorded failures, all regenerates, page_numbers or pilot=true limits the batch. State the MiniMax call estimate before drawing. Local panels share the GPU queue; MiniMax uses its configured external provider. Cancel keeps finished panels. A factual biography requires biography_review=true before render.
- Use generate_comic_panel with page_number, panel_number and confirm=true when the user asks to generate or regenerate one numbered panel. It replaces only that panel artwork.
- A how-to question (“cómo lo lanzo”, “¿cómo genero?”) must explain the real control and must not emit start_generation, generate_*, render_series_shots or other generating actions. Never invent a Render button.
-- For create_series_episode, supply at least three useful characters, one location and three causal outline beats when the series context permits it. Set known_universe=true for an existing third-party fictional universe and never claim publication rights.
-- A direct request to create an episode authorizes the executor to prepare and approve only a brand-new editable canon base created in that same request. It does not authorize accepting pending canon on an existing series, rendering shots or videos. confirm=true from the model is not enough to apply, mark reviewed or commit canon unless the user explicitly asked for that editorial decision.
+- When create_series_episode creates a new series, populate series_premise, series_logline, world_summary, visual_style, genre, tone, theme and language as well as episode_title, episode_premise and episode_logline. These are real saved fields: do not put all the creative brief in episode_premise and leave the series setup blank. Use a concise initial cast of three or four useful characters, one or two locations and three to five causal outline_beats unless the user requested more. Write visual_style as concrete provider-facing animation/art direction; preserve the requested narrative tone in tone and world_summary. Set known_universe=true only when the user wants an existing fictional universe, not mere style inspiration.
+- A request to create or develop a series with usable creative direction already authorizes create_series_episode to save the first editable draft, including the brand-new canon base needed by that action. The user does not need to separately approve the provisional title, invented cast or first episode premise. This initialization is handled by create_series_episode itself; do not emit extra approval actions or ask for permission to initialize it. This does not authorize accepting pending changes on an existing series, rendering shots/videos or committing later canon proposals. Those decisions still require the user's corresponding intent.
- Prefer an installed, enabled text-to-video model from available_video_models. Leave model_type empty when the current/default compatible model is suitable.
- For every action object, fill unused string fields with "", unused numeric fields with 0, unused arrays with [], unused booleans with false, queue_scope with "" unless inspecting the queue, and turbo with "keep". seed=-1 means random.
- Never invent tasks, progress, models, outputs or errors. If state is missing, say so.
@@ -178,6 +184,6 @@ export function buildAgentTurnPrompt(
JSON.stringify(taskSnapshot),
'Recent conversation:',
JSON.stringify(conversation),
- 'Answer the final user message now. Return only the required JSON object.',
+ 'Interpret the final message together with the earlier goal. Before choosing clarification, distinguish essential missing context from creative details you can propose: a supplied subject, setting, tone or references is enough to develop a requested editable draft. A reply to your creative question continues the earlier creation request. Missing titles, invented cast or pilot plots do not require another approval turn. If the user instead asks to discuss or review before saving, explain a concrete proposal without actions. Return only the required JSON object now.',
].join('\n\n')
}
diff --git a/ui/src/features/agent/applicationAdapters.ts b/ui/src/features/agent/applicationAdapters.ts
index baabe6f79..8f20326cb 100644
--- a/ui/src/features/agent/applicationAdapters.ts
+++ b/ui/src/features/agent/applicationAdapters.ts
@@ -1,4 +1,5 @@
import { useStore } from '../../stores/useStore'
+import { DIRECT_GENERATION_MEDIA, revealDirectorWorkspace, visibleWorkspaceSurface } from '../../lib/navigationCategories'
import i18n from '../../i18n'
import type { CommandResult } from '../../lib/commandContract'
import { rememberedCharacterKitLibrary } from '../characters/session'
@@ -209,11 +210,11 @@ function isTabOpen(tab: AgentTab): boolean {
if (tab === 'settings') return state.settingsOpen && !state.dashboardOpen
if (tab === 'productions') return state.dashboardOpen && !state.settingsOpen
if (tab === 'director') {
- return state.sidebarMode === 'director' && state.sidebarOpen
+ return visibleWorkspaceSurface(state) === 'director'
&& !state.settingsOpen && !state.dashboardOpen
}
if (tab === 'studio') {
- return state.sidebarMode === 'studio' && state.sidebarOpen
+ return visibleWorkspaceSurface(state) === 'generate'
&& !state.settingsOpen && !state.dashboardOpen
}
const mediaFilter = TAB_TARGETS[tab]
@@ -235,13 +236,13 @@ async function navigate(tab: AgentTab): Promise {
} else if (tab === 'director') {
state.setSettingsOpen(false)
state.setDashboardOpen(false)
- state.setSidebarMode('director')
- state.setSidebarOpen(true)
+ revealDirectorWorkspace(state)
window.dispatchEvent(new Event('maestro:director-open'))
} else if (tab === 'studio') {
state.setSettingsOpen(false)
state.setDashboardOpen(false)
state.setSidebarMode('studio')
+ state.setMediaFilter(DIRECT_GENERATION_MEDIA[state.generationMode] || 'videos')
state.setSidebarOpen(true)
} else {
const mediaFilter = TAB_TARGETS[tab]
diff --git a/ui/src/features/agent/capabilityRegistry.ts b/ui/src/features/agent/capabilityRegistry.ts
index 1f0ee90b2..6453c2fde 100644
--- a/ui/src/features/agent/capabilityRegistry.ts
+++ b/ui/src/features/agent/capabilityRegistry.ts
@@ -1,4 +1,5 @@
import type { CommandResult } from '../../lib/commandContract'
+import { canonicalSceneFps } from '../../lib/sceneFps.ts'
import type {
AgentAction,
AgentApply3dRhythmAction,
@@ -699,10 +700,41 @@ defineCapability({
defineCapability({
name: 'create_series_episode', title: 'Create a filled Series Lab episode',
- description: 'Create or resolve a series, save its editable canon and create one exact episode with its canonical episode ID.',
- useWhen: 'The user asks for a new, filled episode in Series Lab.',
- parameters: ['series_title', 'episode_title', 'episode_premise', 'create_if_missing', 'characters', 'locations', 'outline_beats'],
- inputSchema: { type: 'object', additionalProperties: false, properties: { type: { const: 'create_series_episode' }, series_title: { type: 'string', maxLength: 300 }, episode_premise: { type: 'string', maxLength: 3_000 }, create_if_missing: { type: 'boolean' } }, required: ['type', 'series_title', 'episode_premise'] },
+ description: 'Create or resolve a series, save its premise, visual style and editable world, and create a first episode outline with its canonical episode ID.',
+ useWhen: 'The user wants to develop a series or episode, including creative direction supplied in reply to an earlier question. Invent missing draft titles and plot details from that direction.',
+ parameters: ['series_title', 'series_premise', 'series_logline', 'world_summary', 'visual_style', 'genre', 'tone', 'theme', 'language',
+ 'episode_title', 'episode_premise', 'episode_logline', 'ending', 'target_duration_seconds', 'create_if_missing', 'known_universe',
+ 'characters', 'locations', 'outline_beats'],
+ inputSchema: {
+ type: 'object', additionalProperties: false,
+ properties: {
+ type: { const: 'create_series_episode' },
+ series_title: { type: 'string', maxLength: 300 },
+ series_premise: { type: 'string', maxLength: 3_000 }, series_logline: { type: 'string', maxLength: 2_000 },
+ world_summary: { type: 'string', maxLength: 3_000 }, visual_style: { type: 'string', maxLength: 2_000 },
+ genre: { type: 'string', maxLength: 300 }, tone: { type: 'string', maxLength: 500 },
+ theme: { type: 'string', maxLength: 1_000 }, language: { type: 'string', maxLength: 120 },
+ episode_title: { type: 'string', maxLength: 300 }, episode_premise: { type: 'string', maxLength: 3_000 },
+ episode_logline: { type: 'string', maxLength: 2_000 }, ending: { type: 'string', maxLength: 2_000 },
+ target_duration_seconds: { type: 'number', minimum: 0, maximum: 3_600 },
+ create_if_missing: { type: 'boolean' }, known_universe: { type: 'boolean' },
+ characters: { type: 'array', maxItems: 16, items: {
+ type: 'object', additionalProperties: false,
+ properties: {
+ name: { type: 'string', maxLength: 160 }, role: { type: 'string', maxLength: 300 },
+ personality: { type: 'string', maxLength: 1_000 }, desire: { type: 'string', maxLength: 1_000 },
+ flaw: { type: 'string', maxLength: 1_000 }, appearance: { type: 'string', maxLength: 1_000 },
+ voice: { type: 'string', maxLength: 1_000 },
+ }, required: ['name'],
+ } },
+ locations: { type: 'array', maxItems: 16, items: {
+ type: 'object', additionalProperties: false,
+ properties: { name: { type: 'string', maxLength: 160 }, purpose: { type: 'string', maxLength: 1_000 }, description: { type: 'string', maxLength: 1_500 } },
+ required: ['name'],
+ } },
+ outline_beats: { type: 'array', maxItems: 24, items: { type: 'string', maxLength: 1_500 } },
+ }, required: ['type', 'series_title', 'episode_premise'],
+ },
risk: 'edit', confirmation: 'none', progress: 'Creando el episodio editable de Series Lab…',
resolve(raw) {
const fields = seriesEpisodeFields(raw)
@@ -1098,8 +1130,8 @@ function sceneWorkflowAction(type: AgentSceneWorkflowAction['type'], raw: Record
if (raw.confirm !== true) return null
const sceneName = text(raw.scene_name, 300)
if (!sceneName) return null
- if (type === 'create_3d_scene') return { type, sceneName, durationSeconds: boundedNumber(raw.duration_seconds, 1, 300, 5), width: boundedNumber(raw.width, 320, 7680, 1280), height: boundedNumber(raw.height, 240, 4320, 720), fps: raw.fps === 60 ? 60 : 30, confirm: true }
- if (type === 'set_3d_scene_properties') return { type, sceneName, durationSeconds: raw.duration_seconds === undefined ? undefined : boundedNumber(raw.duration_seconds, 1, 300, 5), width: raw.width === undefined ? undefined : boundedNumber(raw.width, 320, 7680, 1280), height: raw.height === undefined ? undefined : boundedNumber(raw.height, 240, 4320, 720), fps: raw.fps === undefined ? undefined : raw.fps === 60 ? 60 : 30, confirm: true }
+ if (type === 'create_3d_scene') return { type, sceneName, durationSeconds: boundedNumber(raw.duration_seconds, 1, 300, 5), width: boundedNumber(raw.width, 320, 7680, 1280), height: boundedNumber(raw.height, 240, 4320, 720), fps: canonicalSceneFps(raw.fps), confirm: true }
+ if (type === 'set_3d_scene_properties') return { type, sceneName, durationSeconds: raw.duration_seconds === undefined ? undefined : boundedNumber(raw.duration_seconds, 1, 300, 5), width: raw.width === undefined ? undefined : boundedNumber(raw.width, 320, 7680, 1280), height: raw.height === undefined ? undefined : boundedNumber(raw.height, 240, 4320, 720), fps: raw.fps === undefined ? undefined : canonicalSceneFps(raw.fps), confirm: true }
const layerName = text(raw.layer_name, 300)
if (type === 'add_3d_scene_layer') {
const layerType = text(raw.layer_type, 30) as Extract['layerType']
diff --git a/ui/src/features/agent/programmaticVideo.ts b/ui/src/features/agent/programmaticVideo.ts
index 0d93105db..165fece8a 100644
--- a/ui/src/features/agent/programmaticVideo.ts
+++ b/ui/src/features/agent/programmaticVideo.ts
@@ -73,10 +73,10 @@ export function reconcileProgrammaticVideoRequest(request: string, turn: AgentTu
export function registerProgrammaticVideoCapability(register: typeof defineCapability) {
register({
name: 'prepare_programmatic_video', title: 'Prepare programmatic Video3D',
- description: 'Open the visible Video3D recipe form without running any generator, planning model, render or export. Existing assets only by default. For the built-in SFX showcase, set scene_command EXACTLY to {"version":1,"operation":"scenes.effects.showcase","input":{"dimension":"2d","sound":true}} (or dimension 3d). For the magic/anime showcase add collection="anime" (12 effects, 36 seconds). The server supplies all 30 timed effects by default; never add effects, duration_seconds or prompts to this input. This opens an editable scene, with no video export. scenes.effects.apply and scenes.speech.prepare require the exact existing document.',
+ description: 'Open the visible Video3D recipe form without running any generator, planning model, render or export. Existing assets only by default. For the built-in SFX showcase, set scene_command EXACTLY to {"version":1,"operation":"scenes.effects.showcase","input":{"dimension":"2d","sound":true}} (or dimension 3d). For the magic/anime showcase add collection="anime" (12 effects, 36 seconds). For retro consoles/VHS add collection="retro" (10 effects, 30 seconds). The server supplies all catalog timed effects by default; never add effects, duration_seconds or prompts to this input. This opens an editable scene, with no video export. scenes.effects.apply and scenes.speech.prepare require the exact existing document.',
useWhen: 'The user asks to compose/edit video with Video3D, the compositor, without generative video, or only supplied assets. Prefer this to prepare_video/start_generation or Director. Preserve literal dialogue and lyrics. Never claim a prepared form is a rendered video.',
parameters: ['intent', 'output_names', 'scene_command'],
- inputSchema: { type: 'object', additionalProperties: false, properties: { type: { const: 'prepare_programmatic_video' }, intent: { type: 'string', minLength: 1, maxLength: 12000 }, scene_command: { type: 'object', description: 'Shared scene command: version=1, operation=scenes.effects.apply (input document,cues,replace), scenes.effects.showcase (input accepts ONLY dimension="2d" or "3d",sound:boolean,collection="all" or "anime",document; document optional to retain an existing scene), or scenes.speech.prepare (input document,slot_id,clip_id,workspace,audio_filename,text,start,end,offset,isolate_vocals optional boolean for installed-only local voice isolation). Supply the exact current document, never invent its objects or resource names.' }, output_names: { type: 'array', maxItems: 32, items: { type: 'string', maxLength: 300 } } }, required: ['type', 'intent'] },
+ inputSchema: { type: 'object', additionalProperties: false, properties: { type: { const: 'prepare_programmatic_video' }, intent: { type: 'string', minLength: 1, maxLength: 12000 }, scene_command: { type: 'object', description: 'Shared scene command: version=1, operation=scenes.effects.apply (input document,cues,replace), scenes.effects.showcase (input accepts ONLY dimension="2d" or "3d",sound:boolean,collection="all" or "anime" or "retro",document; document optional to retain an existing scene), or scenes.speech.prepare (input document,slot_id,clip_id,workspace,audio_filename,text,start,end,offset,isolate_vocals optional boolean for installed-only local voice isolation). Supply the exact current document, never invent its objects or resource names.' }, output_names: { type: 'array', maxItems: 32, items: { type: 'string', maxLength: 300 } } }, required: ['type', 'intent'] },
risk: 'edit', confirmation: 'none', progress: 'Preparando el compositor sin lanzar generación…',
resolve(raw) {
if (typeof raw.intent !== 'string' || !raw.intent.trim()) return null
diff --git a/ui/src/features/agent/videoGenerationAdapter.ts b/ui/src/features/agent/videoGenerationAdapter.ts
new file mode 100644
index 000000000..e91ededb8
--- /dev/null
+++ b/ui/src/features/agent/videoGenerationAdapter.ts
@@ -0,0 +1,159 @@
+/**
+ * HTTP adapter for typed generation.video.
+ *
+ * Posts the closed envelope to /api/v1/generation/commands. It does not read
+ * useStore or applicationAdapters; Studio button wiring remains pending.
+ */
+import { BASE } from '../../api/http'
+import {
+ buildVideoGenerationCommand,
+ effectiveVideoRequestsMatch,
+ mcpArgumentsFromCommand,
+ videoGenerationPresentation,
+ VIDEO_GENERATION_OPERATION,
+ type AgentGenerationVideoAction,
+ type VideoGenerationCommand,
+ type VideoGenerationPresentation,
+} from './videoGenerationCapability'
+import type { GenerationSubmissionContext } from '../studio/generationProvenance'
+
+export interface VideoGenerationReceipt {
+ version: 1
+ commandId: string
+ operation: typeof VIDEO_GENERATION_OPERATION
+ status: 'queued'
+ taskIds: string[]
+ result: {
+ job_id: string
+ task_id: string
+ workspace: string
+ status: 'queued'
+ }
+ commandVersion?: 2
+ fingerprintVersion?: 2
+ contentFingerprint?: string
+}
+
+export interface VideoGenerationSubmitResult {
+ receipt: VideoGenerationReceipt
+ replayed: boolean
+ command: VideoGenerationCommand
+ presentation: VideoGenerationPresentation
+ mcpArguments: Record
+ message: string
+ taskId: string
+}
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
+}
+
+function errorMessage(payload: unknown, fallback: string): string {
+ if (!isRecord(payload)) return fallback
+ const detail = payload.detail
+ if (typeof detail === 'string' && detail.trim()) return detail
+ if (isRecord(detail) && typeof detail.message === 'string' && detail.message.trim()) {
+ return detail.message
+ }
+ return fallback
+}
+
+async function readJson(response: Response): Promise {
+ return response.json().catch(() => undefined)
+}
+
+function receiptRecord(value: unknown): Record | null {
+ if (!isRecord(value)) return null
+ if ('receipt' in value && isRecord(value.receipt)) return value.receipt
+ return value
+}
+
+function asReceipt(value: unknown, command: VideoGenerationCommand): VideoGenerationReceipt {
+ const receipt = receiptRecord(value)
+ if (!receipt
+ || receipt.operation !== VIDEO_GENERATION_OPERATION
+ || receipt.commandId !== command.intent_id
+ || !isRecord(receipt.result)
+ || receipt.result.workspace !== command.input.workspace) {
+ throw new Error(
+ isRecord(value)
+ ? 'generation.video receipt does not match the submitted command'
+ : 'generation.video receipt is missing',
+ )
+ }
+ return receipt as unknown as VideoGenerationReceipt
+}
+
+function wizardHeaders(context?: GenerationSubmissionContext): Record {
+ const headers: Record = {
+ 'content-type': 'application/json',
+ 'X-Hocus-UI-Surface': 'wizard',
+ }
+ if (!context?.workflowId && !context?.runId) return headers
+ headers['X-Hocus-UI-Context'] = JSON.stringify({
+ ...(context.workflowId ? { workflowId: context.workflowId } : {}),
+ ...(context.runId ? { runId: context.runId } : {}),
+ })
+ return headers
+}
+
+function queuedMessage(replayed: boolean, presentation: VideoGenerationPresentation): string {
+ if (replayed) return `Reused generation.video admission in ${presentation.workspace}`
+ return `Queued generation.video in ${presentation.workspace} with ${presentation.modelType} ${presentation.resolution} ${presentation.videoLength} frames`
+}
+
+export function createVideoGenerationAdapter(options: { fetch?: typeof fetch } = {}) {
+ const send = options.fetch ?? globalThis.fetch.bind(globalThis)
+
+ return {
+ buildCommand: buildVideoGenerationCommand,
+ presentation: videoGenerationPresentation,
+ mcpArguments: mcpArgumentsFromCommand,
+ matchesMcp(command: VideoGenerationCommand, mcpArguments: Record) {
+ return effectiveVideoRequestsMatch(command, mcpArguments)
+ },
+ async submit(
+ action: AgentGenerationVideoAction,
+ context?: GenerationSubmissionContext,
+ ): Promise {
+ const command = buildVideoGenerationCommand(action)
+ const presentation = videoGenerationPresentation(action)
+ const response = await send(`${BASE}/api/v1/generation/commands`, {
+ method: 'POST',
+ headers: wizardHeaders(context),
+ body: JSON.stringify(command),
+ })
+ const payload = await readJson(response)
+ if (!response.ok) {
+ throw new Error(errorMessage(payload, `generation.video failed (${response.status})`))
+ }
+ const receipt = asReceipt(payload, command)
+ const replayed = isRecord(payload) && payload.replayed === true
+ return {
+ receipt,
+ replayed,
+ command,
+ presentation,
+ mcpArguments: mcpArgumentsFromCommand(command),
+ message: queuedMessage(replayed, presentation),
+ taskId: receipt.result.task_id,
+ }
+ },
+ async recover(workspace: string, intentId: string): Promise {
+ const params = new URLSearchParams({ workspace, intent_id: intentId })
+ const response = await send(`${BASE}/api/v1/generation/commands/receipt?${params}`)
+ const payload = await readJson(response)
+ if (!response.ok) {
+ throw new Error(errorMessage(payload, `generation.video receipt failed (${response.status})`))
+ }
+ return asReceipt(payload, {
+ version: 2,
+ operation: VIDEO_GENERATION_OPERATION,
+ intent_id: intentId,
+ input: { workspace, params: {} },
+ })
+ },
+ }
+}
+
+export type VideoGenerationAdapter = ReturnType
diff --git a/ui/src/features/agent/videoGenerationCapability.ts b/ui/src/features/agent/videoGenerationCapability.ts
new file mode 100644
index 000000000..cce0990aa
--- /dev/null
+++ b/ui/src/features/agent/videoGenerationCapability.ts
@@ -0,0 +1,333 @@
+/**
+ * Wizard capability for typed generation.video.
+ *
+ * This module is tested on its own. Store/applicationAdapters wiring is a
+ * later integration; Wizard and MCP can still POST /api/v1/generation/commands.
+ */
+import { stableSerialize } from '../../lib/commandContract'
+import { assertCanonicalAudioReference } from '../../lib/canonicalAudioReference'
+import {
+ VIDEO_GENERATION_OPERATION, VIDEO_GENERATION_SCHEMA_VERSION, VIDEO_MODEL_TYPES,
+ WORKSPACE, MAX_PROMPT, MAX_INTENT, modelType, resolutionValue,
+ detachedVideoGenerationCommand, type VideoGenerationCommand, type VideoModelType,
+} from '../../lib/videoGenerationCommand'
+export {
+ VIDEO_GENERATION_OPERATION, VIDEO_GENERATION_SCHEMA_VERSION, VIDEO_MODEL_TYPES, VIDEO_MODEL_FAMILY,
+ assertVideoGenerationCommand, detachedVideoGenerationCommand,
+ type VideoGenerationCommand, type VideoModelType,
+} from '../../lib/videoGenerationCommand'
+
+export const VIDEO_GENERATION_DEFAULTS = {
+ modelType: 't2v_1.3B' as const,
+ resolution: '832x480',
+ videoLength: 81,
+ numInferenceSteps: 30,
+ guidanceScale: 5,
+ seed: -1,
+ fps: 16,
+}
+
+export interface AgentGenerationVideoAction {
+ type: 'generation_video'
+ intentId: string
+ workspace: string
+ prompt: string
+ modelType: VideoModelType
+ resolution: string
+ videoLength: number
+ numInferenceSteps: number
+ guidanceScale: number
+ seed?: number
+ negativePrompt?: string
+ imageStart?: string | null
+ workspaceCollectionId?: string
+ confirm: true
+}
+
+export interface VideoGenerationPresentation {
+ destination: 'studio'
+ anchors: string[]
+ workspace: string
+ modelType: VideoModelType
+ resolution: string
+ videoLength: number
+ prompt: string
+ imageStart?: string | null
+}
+
+type OptionalField = { ok: true, value?: T } | { ok: false }
+
+interface ResolvedVideoFields {
+ prompt: string
+ workspace: string
+ intent: string
+ selectedModel: VideoModelType
+ resolution: string
+ videoLength: number
+ steps: number
+ guidance: number
+}
+
+export function alignWanT2vFrames(frames: number): number {
+ const minimum = 5
+ const step = 4
+ const bounded = Math.max(minimum, Math.min(10_000, Math.round(frames)))
+ const delta = (bounded - minimum) % step
+ if (!delta) return bounded
+ if (delta >= step / 2) {
+ const raised = bounded + (step - delta)
+ return raised > 10_000 ? bounded - delta : raised
+ }
+ const lowered = bounded - delta
+ return lowered < minimum ? bounded + (step - delta) : lowered
+}
+
+export function framesFromDurationSeconds(seconds: number, fps = VIDEO_GENERATION_DEFAULTS.fps): number {
+ return alignWanT2vFrames(seconds * fps)
+}
+
+function finiteNumber(value: unknown, minimum: number, maximum: number, integer = false): number | undefined {
+ if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
+ const bounded = Math.max(minimum, Math.min(maximum, value))
+ return integer ? Math.round(bounded) : bounded
+}
+
+function literalPrompt(value: unknown): string | null {
+ if (typeof value !== 'string' || value.length > MAX_PROMPT || !value.trim()) return null
+ return value
+}
+
+function workspaceName(value: unknown): string | null {
+ return typeof value === 'string' && WORKSPACE.test(value) ? value : null
+}
+
+function intentId(value: unknown): string | null {
+ return typeof value === 'string' && value.trim() !== '' && value.length <= MAX_INTENT ? value : null
+}
+
+function defaultedModel(value: unknown): VideoModelType | null {
+ return value === undefined ? VIDEO_GENERATION_DEFAULTS.modelType : modelType(value)
+}
+
+function defaultedResolution(value: unknown): string | null {
+ return value === undefined ? VIDEO_GENERATION_DEFAULTS.resolution : resolutionValue(value)
+}
+
+function defaultedVideoLength(raw: Record): number | undefined {
+ if (raw.video_length !== undefined) return finiteNumber(raw.video_length, 5, 10_000, true)
+ if (typeof raw.duration_seconds === 'number') return framesFromDurationSeconds(raw.duration_seconds)
+ return VIDEO_GENERATION_DEFAULTS.videoLength
+}
+
+function defaultedSteps(value: unknown): number | undefined {
+ if (value === undefined) return VIDEO_GENERATION_DEFAULTS.numInferenceSteps
+ return finiteNumber(value, 1, 1000, true)
+}
+
+function defaultedGuidance(value: unknown): number | undefined {
+ if (value === undefined) return VIDEO_GENERATION_DEFAULTS.guidanceScale
+ return finiteNumber(value, 0, 1000)
+}
+
+function optionalImageStart(value: unknown): OptionalField {
+ if (value === undefined) return { ok: true }
+ if (value === null || value === '') return { ok: true, value }
+ if (typeof value !== 'string') return { ok: false }
+ try {
+ assertCanonicalAudioReference(value, 'image_start', 'video')
+ return { ok: true, value }
+ } catch {
+ return { ok: false }
+ }
+}
+
+function optionalCollectionId(value: unknown): OptionalField {
+ if (value === undefined || value === null) return { ok: true }
+ if (typeof value === 'string' && value.trim() !== '' && value.length <= 200) {
+ return { ok: true, value }
+ }
+ return { ok: false }
+}
+
+function optionalSeed(value: unknown): number | undefined {
+ if (typeof value === 'number' && Number.isSafeInteger(value)) return value
+ return undefined
+}
+
+function optionalNegativePrompt(value: unknown): string | undefined {
+ if (typeof value === 'string' && value.length <= MAX_PROMPT) return value
+ return undefined
+}
+
+function requiredResolvedFields(raw: Record): ResolvedVideoFields | null {
+ const prompt = literalPrompt(raw.prompt)
+ const workspace = workspaceName(raw.workspace)
+ const intent = intentId(raw.intent_id)
+ const selectedModel = defaultedModel(raw.model_type)
+ const resolution = defaultedResolution(raw.resolution)
+ if (!prompt || !workspace || !intent || !selectedModel || !resolution) return null
+ const videoLength = defaultedVideoLength(raw)
+ const steps = defaultedSteps(raw.num_inference_steps)
+ const guidance = defaultedGuidance(raw.guidance_scale)
+ if (videoLength === undefined || steps === undefined || guidance === undefined) return null
+ return { prompt, workspace, intent, selectedModel, resolution, videoLength, steps, guidance }
+}
+
+function videoActionFromResolved(
+ fields: ResolvedVideoFields,
+ extras: {
+ seed?: number
+ negativePrompt?: string
+ imageStart?: string | null
+ collectionId?: string
+ },
+): AgentGenerationVideoAction {
+ const action: AgentGenerationVideoAction = {
+ type: 'generation_video',
+ intentId: fields.intent,
+ workspace: fields.workspace,
+ prompt: fields.prompt,
+ modelType: fields.selectedModel,
+ resolution: fields.resolution,
+ videoLength: alignWanT2vFrames(fields.videoLength),
+ numInferenceSteps: fields.steps,
+ guidanceScale: fields.guidance,
+ confirm: true,
+ }
+ if (extras.seed !== undefined) action.seed = extras.seed
+ if (extras.negativePrompt !== undefined) action.negativePrompt = extras.negativePrompt
+ if (extras.imageStart !== undefined) action.imageStart = extras.imageStart
+ if (extras.collectionId !== undefined) action.workspaceCollectionId = extras.collectionId
+ return action
+}
+
+export function resolveVideoGenerationAction(raw: Record): AgentGenerationVideoAction | null {
+ if (raw.confirm !== true) return null
+ const fields = requiredResolvedFields(raw)
+ const imageStart = optionalImageStart(raw.image_start)
+ const collection = optionalCollectionId(raw.workspace_collection_id)
+ if (!fields || !imageStart.ok || !collection.ok) return null
+ return videoActionFromResolved(fields, {
+ seed: optionalSeed(raw.seed),
+ negativePrompt: optionalNegativePrompt(raw.negative_prompt),
+ imageStart: imageStart.value,
+ collectionId: collection.value,
+ })
+}
+
+export function validateVideoGenerationAction(action: AgentGenerationVideoAction): string[] {
+ if (action.confirm !== true) return ['confirmation is required']
+ if (!action.prompt.trim()) return ['a literal prompt is required']
+ if (!WORKSPACE.test(action.workspace)) return ['an explicit output workspace is required']
+ if (!VIDEO_MODEL_TYPES.includes(action.modelType)) return ['choose t2v or t2v_1.3B']
+ return []
+}
+
+export function videoGenerationPresentation(action: AgentGenerationVideoAction): VideoGenerationPresentation {
+ const presentation: VideoGenerationPresentation = {
+ destination: 'studio',
+ anchors: ['video', 'generate', 'destination'],
+ workspace: action.workspace,
+ modelType: action.modelType,
+ resolution: action.resolution,
+ videoLength: action.videoLength,
+ prompt: action.prompt,
+ }
+ if (action.imageStart !== undefined) presentation.imageStart = action.imageStart
+ return presentation
+}
+
+function commandParamsFromAction(action: AgentGenerationVideoAction): Record {
+ const params: Record = {
+ prompt: action.prompt,
+ model_type: action.modelType,
+ resolution: action.resolution,
+ video_length: action.videoLength,
+ num_inference_steps: action.numInferenceSteps,
+ guidance_scale: action.guidanceScale,
+ generation_mode: 'video',
+ image_mode: 0,
+ multi_prompts_gen_type: 2,
+ }
+ if (action.seed !== undefined) params.seed = action.seed
+ if (action.negativePrompt !== undefined) params.negative_prompt = action.negativePrompt
+ if (action.imageStart !== undefined) params.image_start = action.imageStart
+ return params
+}
+
+export function buildVideoGenerationCommand(action: AgentGenerationVideoAction): VideoGenerationCommand {
+ const errors = validateVideoGenerationAction(action)
+ if (errors.length) throw new Error(errors[0])
+ const input: VideoGenerationCommand['input'] = {
+ workspace: action.workspace,
+ params: commandParamsFromAction(action),
+ }
+ if (action.workspaceCollectionId !== undefined) {
+ input.workspace_collection_id = action.workspaceCollectionId
+ }
+ return detachedVideoGenerationCommand({
+ version: VIDEO_GENERATION_SCHEMA_VERSION,
+ operation: VIDEO_GENERATION_OPERATION,
+ intent_id: action.intentId,
+ input,
+ })
+}
+
+export function mcpArgumentsFromCommand(command: VideoGenerationCommand): Record {
+ const arguments_ = { ...command } as Record
+ delete arguments_.operation
+ return arguments_
+}
+
+export function effectiveVideoRequestsMatch(
+ wizardCommand: VideoGenerationCommand,
+ mcpArguments: Record,
+): boolean {
+ return stableSerialize(mcpArgumentsFromCommand(wizardCommand)) === stableSerialize(mcpArguments)
+}
+
+export const videoGenerationCapability = {
+ name: 'generation_video' as const,
+ title: 'Generate Wan 2.1 Text2Video',
+ description: 'Admit a typed generation.video job for an installed t2v or t2v_1.3B model in an explicit workspace.',
+ useWhen: 'The user explicitly asks to generate video with the shared Wizard/MCP command.',
+ parameters: [
+ 'intent_id', 'workspace', 'prompt', 'model_type', 'resolution', 'video_length',
+ 'duration_seconds', 'num_inference_steps', 'guidance_scale', 'seed',
+ 'negative_prompt', 'image_start', 'workspace_collection_id', 'confirm',
+ ],
+ inputSchema: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ type: { const: 'generation_video' },
+ intent_id: { type: 'string', minLength: 1, maxLength: 160 },
+ workspace: { type: 'string', minLength: 1, maxLength: 240 },
+ prompt: { type: 'string', minLength: 1, maxLength: MAX_PROMPT },
+ model_type: { type: 'string', enum: [...VIDEO_MODEL_TYPES] },
+ resolution: { type: 'string' },
+ video_length: { type: 'integer', minimum: 5, maximum: 10_000 },
+ duration_seconds: { type: 'number', exclusiveMinimum: 0 },
+ num_inference_steps: { type: 'integer', minimum: 1, maximum: 1000 },
+ guidance_scale: { type: 'number' },
+ seed: { type: 'integer' },
+ negative_prompt: { type: 'string' },
+ image_start: { type: ['string', 'null'] },
+ workspace_collection_id: { type: 'string', minLength: 1, maxLength: 200 },
+ confirm: { const: true },
+ },
+ required: ['type', 'intent_id', 'workspace', 'prompt', 'confirm'],
+ },
+ risk: 'compute' as const,
+ confirmation: 'required' as const,
+ progress: 'Admitting Wan Text2Video…',
+ resolve: resolveVideoGenerationAction,
+ validate: validateVideoGenerationAction,
+ presentation: { destination: 'studio' as const, anchors: ['video', 'generate', 'destination'], replay: 'atomic' as const },
+}
+
+export function registerVideoGenerationCapability(
+ register: (definition: typeof videoGenerationCapability) => unknown,
+): void {
+ register(videoGenerationCapability)
+}
diff --git a/ui/src/features/agent/wizardContext.ts b/ui/src/features/agent/wizardContext.ts
index 72ae7d310..3fc4d4f71 100644
--- a/ui/src/features/agent/wizardContext.ts
+++ b/ui/src/features/agent/wizardContext.ts
@@ -1,4 +1,5 @@
import { useStore } from '../../stores/useStore'
+import { visibleWorkspaceSurface } from '../../lib/navigationCategories'
import { emptyCharacterKitLibrary } from '../../lib/characterKit'
import { comicArtworkInventory } from '../comics/generateArtwork'
import { useComicStore } from '../comics/store'
@@ -840,10 +841,11 @@ export function comicLabSnapshot() {
function inferredLocation(state: ReturnType): WizardContextLocation {
if (state.settingsOpen) return { area: 'settings', tab: 'settings', section: state.settingsTab || '' }
if (state.dashboardOpen) return { area: 'productions', tab: 'productions', section: 'queue' }
- if (state.sidebarMode === 'director' && state.sidebarOpen) {
+ const surface = visibleWorkspaceSurface(state)
+ if (surface === 'director') {
return { area: 'director', tab: 'director', section: state.directorStep || '' }
}
- if (state.sidebarMode === 'studio' && state.sidebarOpen) {
+ if (surface === 'generate') {
const section = state.generationMode === 'audio'
? state.audioSubMode
: state.generationMode === 'avatar' ? state.editSubMode : state.generationMode
diff --git a/ui/src/features/agent/wizardIntent.ts b/ui/src/features/agent/wizardIntent.ts
new file mode 100644
index 000000000..2d3aca897
--- /dev/null
+++ b/ui/src/features/agent/wizardIntent.ts
@@ -0,0 +1,38 @@
+/** The LLM interprets the request in context; application code validates the plan. */
+export interface WizardIntent {
+ kind: 'conversation' | 'clarification' | 'action'
+ goal: string
+ question: string
+ execution: 'none' | 'prepare' | 'run'
+}
+
+export const WIZARD_INTENT_SCHEMA = {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ kind: { type: 'string', enum: ['conversation', 'clarification', 'action'] },
+ goal: { type: 'string', minLength: 1, maxLength: 2_000 },
+ question: { type: 'string', maxLength: 2_000 },
+ execution: { type: 'string', enum: ['none', 'prepare', 'run'] },
+ },
+ required: ['kind', 'goal', 'question', 'execution'],
+}
+
+export function parseWizardIntent(value: unknown): WizardIntent | null {
+ if (!value || typeof value !== 'object') return null
+ const raw = value as Record
+ if (raw.kind !== 'conversation' && raw.kind !== 'clarification' && raw.kind !== 'action') return null
+ if (typeof raw.goal !== 'string' || !raw.goal.trim() || raw.goal.length > 2_000) return null
+ if (typeof raw.question !== 'string' || raw.question.length > 2_000) return null
+ if (raw.kind === 'clarification' && !raw.question.trim()) return null
+ if (raw.execution !== 'none' && raw.execution !== 'prepare' && raw.execution !== 'run') return null
+ if (raw.kind === 'action' && raw.execution === 'none') return null
+ // Conversation and clarification never authorize work. Normalize redundant
+ // model fields conservatively instead of losing a useful follow-up question.
+ return {
+ kind: raw.kind,
+ goal: raw.goal.trim(),
+ question: raw.kind === 'clarification' ? raw.question.trim() : '',
+ execution: raw.kind === 'action' ? raw.execution as 'prepare' | 'run' : 'none',
+ }
+}
diff --git a/ui/src/features/agent/wizardTurnReport.ts b/ui/src/features/agent/wizardTurnReport.ts
index 92dc894c0..cf7c6def3 100644
--- a/ui/src/features/agent/wizardTurnReport.ts
+++ b/ui/src/features/agent/wizardTurnReport.ts
@@ -3,7 +3,7 @@ import { stableSerialize } from './agentContract'
import type { AgentVisualState } from './AgentAvatar'
export type WizardRejectionCode = 'invalid_action' | 'invalid_action_list' | 'action_limit'
- | 'preparation_required' | 'duplicate_generation' | 'request_policy' | 'visual_evidence_only'
+ | 'preparation_required' | 'duplicate_generation' | 'request_policy' | 'visual_evidence_only' | 'invalid_intent'
export interface WizardActionRejection {
index: number
@@ -41,16 +41,6 @@ export function withWizardRejections(before: AgentTurn, after: AgentTurn,
type Translate = (key: string, options?: Record) => string
-/** Conservative presentation policy, not an authorization or execution classifier. */
-function allowsExplanation(request: string): boolean {
- // JS `\b` is ASCII-only. Fold accents so "Qué" / "por qué" keep a word boundary.
- const text = request.trim().replace(/^[¿¡]+/, '').normalize('NFD').replace(/[\u0300-\u036f]/g, '')
- if (/^(?:hola|hello|hi|gracias|thanks)[\s!.]*$/i.test(text)) return true
- // An informational prefix does not erase a later imperative in a mixed turn.
- if (/(?:[,;.!?\n]|\b(?:and|then|also|y|luego|despu[eé]s))\s*(?:(?:please|por favor)[,\s]+)?(?:create|generate|make|update|delete|remove|add|save|export|start|retry|run|open|select|crea\w*|genera\w*|haz\w*|actualiza\w*|elimina\w*|borra\w*|a[nñ]ade\w*|guarda\w*|exporta\w*|inicia\w*|reintenta\w*|ejecuta\w*|abre|selecciona\w*)\b/i.test(text)) return false
- return /^(?:(?:please|por favor)[,\s]+)?(?:how\b|what\b|which\b|why\b|where\b|explain\b|describe\b|tell me (?:about|how|what|why)\b|(?:can|could) you (?:explain|describe)\b|c[oó]mo\b|qu[eé]\b|cu[aá]l\b|por qu[eé]\b|d[oó]nde\b|explica(?:me|rme)?\b|describe\b|descr[ií]beme\b|(?:puedes|podr[ií]as) explica(?:r|rme)\b)/i.test(text)
-}
-
export function wizardResultState(result: AgentActionResult) {
const states = [result.commandResult?.status, result.report?.state]
if (states.includes('failed')) return 'failed'
@@ -79,14 +69,17 @@ export function wizardTurnVisualState(turn: AgentTurn, results: AgentActionResul
const states = results.map(wizardResultState)
if (turn.rejections?.length || states.some(state => ['failed', 'partial'].includes(state))) return 'error'
if (states.some(state => state === 'queued' || state === 'running')) return 'acting'
+ if (turn.intent?.kind === 'clarification') return 'idle'
return states.length && states.every(state => state === 'completed') ? 'success' : 'idle'
}
/** Free-form model prose cannot certify the result of an action-bearing turn. */
-export function formatWizardTurnReply(turn: AgentTurn, results: AgentActionResult[], t: Translate, request = ''): string {
+export function formatWizardTurnReply(turn: AgentTurn, results: AgentActionResult[], t: Translate): string {
const hasActions = Boolean(turn.actions.length || results.length || turn.rejections?.length)
- const explanation = !hasActions && allowsExplanation(request)
+ const explanation = !hasActions && turn.intent?.kind === 'conversation'
+ const question = turn.intent?.kind === 'clarification' ? turn.intent.question : ''
const paragraphs: string[] = []
+ if (question) paragraphs.push(question)
if (explanation && turn.reply) paragraphs.push(turn.reply)
if (results.length) {
const lines = results.map(result => {
@@ -94,7 +87,7 @@ export function formatWizardTurnReply(turn: AgentTurn, results: AgentActionResul
return `- **${label}.** ${result.message}`
})
paragraphs.push(`### ${t('actionReport')}\n${lines.join('\n')}`)
- } else if (!explanation) paragraphs.push(t('noActionReceipt'))
+ } else if (!explanation && !question) paragraphs.push(t('noActionReceipt'))
if (turn.rejections?.length) {
const lines = turn.rejections.map(rejection => `- ${t('rejectedAction', {
action: rejection.actionType,
diff --git a/ui/src/features/agent/wizardVisualPolicy.ts b/ui/src/features/agent/wizardVisualPolicy.ts
index b847fe392..ddf4ad9cd 100644
--- a/ui/src/features/agent/wizardVisualPolicy.ts
+++ b/ui/src/features/agent/wizardVisualPolicy.ts
@@ -1,10 +1,26 @@
-import { reconcileAgentTurnWithRequest } from './agentActions'
-import { withWizardRejections } from './wizardTurnReport'
+import type { AgentTurn } from './agentActions'
+import { getCapability } from './capabilityRegistry'
+import { isExpensiveAction } from './agentContract'
+import { rejectedWizardAction, withWizardRejections } from './wizardTurnReport'
-/** A visual answer is evidence only; even an LLM's confirm:true cannot grant action authority. */
-export async function reconcileWizardMediaTurn(hasVisualMedia: boolean,
- ...args: Parameters) {
- const before = args[1]
- const after = hasVisualMedia ? { ...before, actions: [] } : await reconcileAgentTurnWithRequest(...args)
+/** Validate the interpreted intent. Never infer or manufacture actions from words in the request. */
+export function validateWizardPlan(hasVisualMedia: boolean, before: AgentTurn): AgentTurn {
+ let after = before
+ if (!before.intent) {
+ after = { ...before, actions: [], rejections: [
+ ...(before.rejections || []), rejectedWizardAction({ type: 'intent' }, 0, 'invalid_intent'),
+ ] }
+ } else if (hasVisualMedia || before.intent.kind === 'conversation') {
+ // Visual analysis remains evidence only, as in the existing media contract.
+ after = { ...before, actions: [] }
+ } else if (before.intent.kind === 'clarification') {
+ after = { ...before, actions: before.actions.filter(action =>
+ action.type === 'open_tab' || action.type === 'open_story_section' || action.type === 'open_series_section') }
+ } else if (before.intent.execution === 'prepare') {
+ after = { ...before, actions: before.actions.filter(action => {
+ const risk = getCapability(action.type)?.risk
+ return risk !== 'compute' && risk !== 'external_cost' && !isExpensiveAction(action.type)
+ }) }
+ }
return withWizardRejections(before, after, hasVisualMedia ? 'visual_evidence_only' : 'request_policy')
}
diff --git a/ui/src/features/agent/wizardWorkflowRuntime.ts b/ui/src/features/agent/wizardWorkflowRuntime.ts
index 1010488ca..91002c294 100644
--- a/ui/src/features/agent/wizardWorkflowRuntime.ts
+++ b/ui/src/features/agent/wizardWorkflowRuntime.ts
@@ -3,6 +3,7 @@ import {
saveWizardWorkflows,
type WizardWorkflowCollectionPayload,
} from '../../api/client'
+import { BASE } from '../../api/http'
import type { CanonicalTaskEvent } from '../../lib/canonicalTaskEvents'
import { cardFromReport, type WizardExecutionCard } from './executionCards'
import { executionKey, executionReport } from './agentContract'
@@ -61,6 +62,25 @@ export interface WizardWorkflowAnswerOptions {
stepId?: string
}
+export const SERVER_WORKFLOW_OWNER = 'server'
+
+export class WizardWorkflowAnswerConflict extends Error {
+ readonly recoverable = true
+ readonly expectedRevision?: number
+ readonly currentRevision?: number
+
+ constructor(message: string, expected?: number, current?: number) {
+ super(message)
+ this.name = 'WizardWorkflowAnswerConflict'
+ this.expectedRevision = expected
+ this.currentRevision = current
+ }
+}
+
+export function isServerOwnedWorkflow(workflow: Pick): boolean {
+ return workflow.executorOwner === SERVER_WORKFLOW_OWNER
+}
+
export interface WizardWorkflowStepRecord {
stepId: string
kind: string
@@ -99,6 +119,9 @@ export interface WizardWorkflowRecord {
cancelRequested: boolean
resumeRequested: boolean
pendingInput: WizardWorkflowPendingInput | null
+ executorOwner: string
+ leaseToken: string
+ leaseExpiresAt: number
}
export interface WizardWorkflowCollection {
@@ -396,6 +419,9 @@ function normalizeWorkflow(value: unknown): WizardWorkflowRecord | null {
cancelRequested: raw.cancelRequested === true,
resumeRequested: raw.resumeRequested === true,
pendingInput,
+ executorOwner: String(raw.executorOwner || ''),
+ leaseToken: String(raw.leaseToken || ''),
+ leaseExpiresAt: Math.max(0, Number(raw.leaseExpiresAt) || 0),
}
}
@@ -486,6 +512,7 @@ export class WizardWorkflowRuntime {
if (this.opened) {
for (const workflow of this.collection.workflows) {
if (workflow.type !== definition.type || workflow.workspace !== this.workspace) continue
+ if (isServerOwnedWorkflow(workflow)) continue
const step = workflow.steps[workflow.currentStep]
if (workflow.state === 'prepared' || workflow.state === 'retrying'
|| (workflow.state === 'running' && step?.state !== 'waiting' && step?.state !== 'awaiting_input')) {
@@ -553,6 +580,7 @@ export class WizardWorkflowRuntime {
processedEventIds: [], attempts: 0, createdAt: now, updatedAt: now,
recoverableError: '', cancelRequested: false, resumeRequested: false,
pendingInput: null,
+ executorOwner: '', leaseToken: '', leaseExpiresAt: 0,
}
this.collection.workflows.push(workflow)
await this.persist()
@@ -569,6 +597,7 @@ export class WizardWorkflowRuntime {
const matches = this.collection.workflows.filter(workflow => {
const step = workflow.steps[workflow.currentStep]
return workflow.workspace === this.workspace
+ && !isServerOwnedWorkflow(workflow)
&& step?.state === 'waiting'
&& step.taskId === event.task_id
&& !workflow.processedEventIds.includes(event.event_id)
@@ -647,6 +676,10 @@ export class WizardWorkflowRuntime {
}
if (!isRecord(answer)) throw new Error('Input answer must be a JSON object.')
validateInputAnswer(pending, answer)
+ if (isServerOwnedWorkflow(workflow)) {
+ await this.answerOnServer(workflow, answer, answerOptions)
+ return
+ }
const now = Date.now()
step.input = applyDeclaredFields(step.input, pending.fields, answer)
@@ -671,6 +704,45 @@ export class WizardWorkflowRuntime {
return this.get(workflowId) as WizardWorkflowRecord
}
+ private async answerOnServer(
+ workflow: WizardWorkflowRecord,
+ answer: Record,
+ options: WizardWorkflowAnswerOptions | undefined,
+ ): Promise {
+ const response = await fetch(`${BASE}/api/v1/wizard/workflows/executor/answer`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ workspace: workflow.workspace,
+ workflowId: workflow.workflowId,
+ expectedRevision: this.collection.revision,
+ stepId: options?.stepId || workflow.pendingInput?.stepId,
+ answerVersion: options?.version,
+ answer,
+ }),
+ })
+ const payload = await response.json().catch(() => null) as {
+ detail?: { message?: string; expectedRevision?: number; currentRevision?: number }
+ workflow?: unknown
+ revision?: number
+ } | null
+ if (response.status === 409) {
+ throw new WizardWorkflowAnswerConflict(
+ String(payload?.detail?.message || 'Wizard workflow revision conflict'),
+ payload?.detail?.expectedRevision,
+ payload?.detail?.currentRevision,
+ )
+ }
+ if (!response.ok) throw new Error('Could not answer the server-owned Wizard workflow.')
+ const record = normalizeWorkflow(payload?.workflow)
+ if (!record) throw new Error('Could not answer the server-owned Wizard workflow.')
+ this.collection.revision = Math.max(0, Number(payload?.revision) || this.collection.revision)
+ const index = this.collection.workflows.findIndex(item => item.workflowId === record.workflowId)
+ if (index >= 0) this.collection.workflows[index] = record
+ else this.collection.workflows.push(record)
+ this.emit(record)
+ }
+
async resume(
workflowId: string,
answer?: Record,
diff --git a/ui/src/features/asset-picker/previewPlayer.tsx b/ui/src/features/asset-picker/previewPlayer.tsx
index a2eba98a9..c72d7aa16 100644
--- a/ui/src/features/asset-picker/previewPlayer.tsx
+++ b/ui/src/features/asset-picker/previewPlayer.tsx
@@ -2,8 +2,13 @@ import { useEffect, useRef, useState } from 'react'
import { Play } from 'lucide-react'
import { useUiTranslation } from '../../i18n'
import type { PickerItem } from './types.ts'
-
-const PREVIEW_BYTE_LIMIT = 80 * 1024 * 1024
+import {
+ PREVIEW_BYTE_LIMIT,
+ getSharedPreviewPool,
+ needsFullPreview,
+ type PreviewResourcePool,
+ type PreviewSession,
+} from './previewResources.ts'
function pauseMedia(element: HTMLMediaElement | null) {
if (!element) return
@@ -25,17 +30,48 @@ function GlbPreview({ url }: { url: string }) {
return () => { cancelled = true }
}, [url])
if (!ready) return {t('explorer.loading')}
- return
+ return
+}
+
+export function AssetPreviewPlayer({
+ item,
+ pool,
+}: {
+ item: PickerItem
+ pool?: PreviewResourcePool
+}) {
+ return
}
-export function AssetPreviewPlayer({ item }: { item: PickerItem }) {
+function PreviewPlayerBody({
+ item,
+ pool,
+}: {
+ item: PickerItem
+ pool?: PreviewResourcePool
+}) {
const { t } = useUiTranslation('common')
const videoRef = useRef(null)
const audioRef = useRef(null)
- const [armedUrl, setArmedUrl] = useState('')
+ const sessionRef = useRef(null)
+ const resources = pool ?? getSharedPreviewPool()
+ const [armed, setArmed] = useState(false)
+ const [playUrl, setPlayUrl] = useState(item.url)
const [failed, setFailed] = useState(false)
- const armed = armedUrl === item.url
const large = item.sizeBytes > PREVIEW_BYTE_LIMIT
+ const sourceUrl = item.url
+ const workspaceId = item.ref.workspaceId
+ const mediaKind = item.kind
+ const sizeBytes = item.sizeBytes
+
+ useEffect(() => {
+ const session = resources.createSession({ scope: 'picker' })
+ sessionRef.current = session
+ return () => {
+ session.dispose()
+ sessionRef.current = null
+ }
+ }, [resources])
useEffect(() => {
const video = videoRef.current
@@ -44,7 +80,27 @@ export function AssetPreviewPlayer({ item }: { item: PickerItem }) {
pauseMedia(video)
pauseMedia(audio)
}
- }, [item.url, armed])
+ }, [armed, playUrl])
+
+ useEffect(() => {
+ const session = sessionRef.current
+ if (!session || !needsFullPreview({ kind: mediaKind }, armed)) return
+ let cancelled = false
+ void session.acquire({
+ sourceUrl,
+ workspaceId,
+ layer: 'full',
+ mediaKind,
+ sizeBytes,
+ }).then(lease => {
+ if (cancelled || !lease) return
+ setPlayUrl(lease.playUrl)
+ })
+ return () => {
+ cancelled = true
+ session.cancelCurrent()
+ }
+ }, [armed, sourceUrl, workspaceId, mediaKind, sizeBytes, resources])
if (failed) {
return {t('explorer.previewFailed')}
@@ -63,7 +119,7 @@ export function AssetPreviewPlayer({ item }: { item: PickerItem }) {
type="button"
data-testid="asset-preview-arm"
aria-label={item.kind === 'model3d' ? t('explorer.view3d') : t('explorer.playPreview')}
- onClick={() => { setFailed(false); setArmedUrl(item.url) }}
+ onClick={() => { setFailed(false); setArmed(true); setPlayUrl(item.url) }}
className="relative flex h-full w-full items-center justify-center"
>
{item.thumbnailUrl ? : null}
@@ -79,7 +135,8 @@ export function AssetPreviewPlayer({ item }: { item: PickerItem }) {
+ return
}
return {t('explorer.selectHint')}
}
diff --git a/ui/src/features/asset-picker/previewResources.ts b/ui/src/features/asset-picker/previewResources.ts
new file mode 100644
index 000000000..f67cccff8
--- /dev/null
+++ b/ui/src/features/asset-picker/previewResources.ts
@@ -0,0 +1,452 @@
+import { assetRefKey, type PickerItem } from './types.ts'
+
+export const PREVIEW_BYTE_LIMIT = 80 * 1024 * 1024
+export const PREVIEW_CACHE_LIMIT = 8
+export const PREVIEW_CACHE_BYTES = 32 * 1024 * 1024
+
+export type PreviewLayer = 'thumbnail' | 'full'
+export type PreviewScope = 'picker' | 'scene' | 'panel'
+
+export type PreviewRequest = {
+ sourceUrl: string
+ workspaceId: string
+ layer: PreviewLayer
+ mediaKind: PickerItem['kind']
+ sizeBytes?: number
+ thumbnailUrl?: string
+}
+
+export type PreviewLease = {
+ ownerId: string
+ key: string
+ playUrl: string
+ objectUrl: string | null
+ generation: number
+ fromCache: boolean
+ release: () => void
+}
+
+export type PreviewCounters = {
+ objectUrlsLive: number
+ objectUrlsCreated: number
+ objectUrlsRevoked: number
+ abortControllersLive: number
+ abortControllersCreated: number
+ leasesLive: number
+ refcountSum: number
+ cacheEntries: number
+ cacheBytes: number
+ inFlight: number
+ published: number
+ staleDropped: number
+ fullAcquires: number
+ thumbnailAcquires: number
+}
+
+export type PreviewFetcher = (url: string, signal: AbortSignal) => Promise
+
+export type PreviewPoolOptions = {
+ maxCacheEntries?: number
+ maxCacheBytes?: number
+ copyRemote?: boolean
+ fetch?: PreviewFetcher
+ createObjectURL?: (blob: Blob) => string
+ revokeObjectURL?: (url: string) => void
+ now?: () => number
+}
+
+type CacheEntry = {
+ key: string
+ playUrl: string
+ objectUrl: string | null
+ bytes: number
+ refcount: number
+ lastUsed: number
+ createdByPool: boolean
+}
+
+type Waiter = {
+ ownerId: string
+ isCurrent: () => boolean
+ resolve: (entry: CacheEntry | null) => void
+}
+
+type Flight = {
+ controller: AbortController
+ waiters: Waiter[]
+}
+
+function emptyCounters(): PreviewCounters {
+ return {
+ objectUrlsLive: 0,
+ objectUrlsCreated: 0,
+ objectUrlsRevoked: 0,
+ abortControllersLive: 0,
+ abortControllersCreated: 0,
+ leasesLive: 0,
+ refcountSum: 0,
+ cacheEntries: 0,
+ cacheBytes: 0,
+ inFlight: 0,
+ published: 0,
+ staleDropped: 0,
+ fullAcquires: 0,
+ thumbnailAcquires: 0,
+ }
+}
+
+export function previewResourceKey(
+ item: Pick,
+ layer: PreviewLayer,
+): string {
+ return `${layer}:${assetRefKey(item.ref)}:${item.url}`
+}
+
+export function previewRequestKey(request: PreviewRequest): string {
+ return `${request.layer}:${request.workspaceId}:${request.sourceUrl}`
+}
+
+export function isInlinePreviewUrl(url: string): boolean {
+ return url.startsWith('blob:') || url.startsWith('data:')
+}
+
+export function needsFullPreview(item: Pick, armed: boolean): boolean {
+ if (!armed) return false
+ return item.kind === 'video' || item.kind === 'audio' || item.kind === 'model3d'
+}
+
+/** Gallery rows never start a full GLB/audio/video load. */
+export function listPreviewPlan(items: readonly PickerItem[]) {
+ const thumbnailUrls = items.map(item => item.thumbnailUrl).filter(Boolean)
+ return {
+ items: items.length,
+ thumbnails: thumbnailUrls.length,
+ thumbnailUrls,
+ fullLoads: 0,
+ modelViewerMounts: 0,
+ }
+}
+
+function isAbortError(error: unknown): boolean {
+ return Boolean(error && typeof error === 'object' && 'name' in error && (error as { name: string }).name === 'AbortError')
+}
+
+export type PreviewSession = {
+ readonly ownerId: string
+ readonly scope: PreviewScope
+ readonly generation: number
+ acquire: (request: PreviewRequest) => Promise
+ cancelCurrent: () => void
+ dispose: () => void
+}
+
+export type PreviewResourcePool = {
+ createSession: (input: { scope: PreviewScope; ownerId?: string; exclusiveFull?: boolean }) => PreviewSession
+ snapshot: () => PreviewCounters
+ dispose: () => void
+}
+
+export function createPreviewResourcePool(options: PreviewPoolOptions = {}): PreviewResourcePool {
+ const maxCacheEntries = options.maxCacheEntries ?? PREVIEW_CACHE_LIMIT
+ const maxCacheBytes = options.maxCacheBytes ?? PREVIEW_CACHE_BYTES
+ const copyRemote = options.copyRemote ?? Boolean(options.fetch)
+ const fetchBlob = options.fetch ?? defaultPreviewFetch
+ const createObjectURL = options.createObjectURL ?? ((blob: Blob) => URL.createObjectURL(blob))
+ const revokeObjectURL = options.revokeObjectURL ?? ((url: string) => URL.revokeObjectURL(url))
+ const now = options.now ?? (() => Date.now())
+
+ const counters = emptyCounters()
+ const entries = new Map()
+ const flights = new Map()
+ let ownerSeq = 0
+ let disposed = false
+
+ function snapshot(): PreviewCounters {
+ let refcountSum = 0
+ let cacheBytes = 0
+ let cacheEntries = 0
+ for (const entry of entries.values()) {
+ refcountSum += entry.refcount
+ cacheEntries += 1
+ cacheBytes += entry.bytes
+ }
+ return {
+ ...counters,
+ refcountSum,
+ cacheEntries,
+ cacheBytes,
+ inFlight: flights.size,
+ }
+ }
+
+ function dropCreatedUrl(entry: CacheEntry) {
+ if (!entry.createdByPool || !entry.objectUrl) return
+ revokeObjectURL(entry.objectUrl)
+ counters.objectUrlsRevoked += 1
+ counters.objectUrlsLive = Math.max(0, counters.objectUrlsLive - 1)
+ entry.objectUrl = null
+ entry.bytes = 0
+ entry.createdByPool = false
+ }
+
+ function evictIdle() {
+ const idle = [...entries.values()]
+ .filter(entry => entry.refcount === 0 && entry.createdByPool)
+ .sort((left, right) => left.lastUsed - right.lastUsed)
+ const over = () => {
+ const live = [...entries.values()].filter(entry => entry.createdByPool).length
+ const bytes = [...entries.values()].reduce((sum, entry) => sum + entry.bytes, 0)
+ return live > maxCacheEntries || bytes > maxCacheBytes
+ }
+ while (idle.length && over()) {
+ const victim = idle.shift()
+ if (!victim) break
+ dropCreatedUrl(victim)
+ entries.delete(victim.key)
+ }
+ }
+
+ function rememberSource(key: string, playUrl: string): CacheEntry {
+ const existing = entries.get(key)
+ if (existing) {
+ existing.lastUsed = now()
+ return existing
+ }
+ const entry: CacheEntry = {
+ key,
+ playUrl,
+ objectUrl: null,
+ bytes: 0,
+ refcount: 0,
+ lastUsed: now(),
+ createdByPool: false,
+ }
+ entries.set(key, entry)
+ return entry
+ }
+
+ function materialize(key: string, blob: Blob): CacheEntry {
+ const existing = entries.get(key)
+ if (existing?.objectUrl && existing.createdByPool) {
+ existing.lastUsed = now()
+ return existing
+ }
+ const objectUrl = createObjectURL(blob)
+ counters.objectUrlsCreated += 1
+ counters.objectUrlsLive += 1
+ const entry: CacheEntry = {
+ key,
+ playUrl: objectUrl,
+ objectUrl,
+ bytes: blob.size,
+ refcount: existing?.refcount ?? 0,
+ lastUsed: now(),
+ createdByPool: true,
+ }
+ entries.set(key, entry)
+ evictIdle()
+ return entry
+ }
+
+ function retain(ownerId: string, entry: CacheEntry, generation: number, fromCache: boolean): PreviewLease {
+ entry.refcount += 1
+ entry.lastUsed = now()
+ counters.leasesLive += 1
+ let released = false
+ const lease: PreviewLease = {
+ ownerId,
+ key: entry.key,
+ playUrl: entry.playUrl,
+ objectUrl: entry.objectUrl,
+ generation,
+ fromCache,
+ release() {
+ if (released) return
+ released = true
+ counters.leasesLive = Math.max(0, counters.leasesLive - 1)
+ entry.refcount = Math.max(0, entry.refcount - 1)
+ entry.lastUsed = now()
+ if (entry.refcount === 0 && !entry.createdByPool) entries.delete(entry.key)
+ else evictIdle()
+ },
+ }
+ return lease
+ }
+
+ function cancelOwnerWaiters(ownerId: string) {
+ for (const [key, flight] of [...flights.entries()]) {
+ const kept: Waiter[] = []
+ for (const waiter of flight.waiters) {
+ if (waiter.ownerId === ownerId) {
+ counters.staleDropped += 1
+ waiter.resolve(null)
+ } else kept.push(waiter)
+ }
+ flight.waiters = kept
+ if (!kept.length) {
+ flights.delete(key)
+ counters.abortControllersLive = Math.max(0, counters.abortControllersLive - 1)
+ flight.controller.abort()
+ }
+ }
+ }
+
+ function shouldCopy(request: PreviewRequest): boolean {
+ if (request.layer !== 'full') return false
+ if ((request.sizeBytes ?? 0) > PREVIEW_BYTE_LIMIT) return false
+ if (isInlinePreviewUrl(request.sourceUrl)) return false
+ return copyRemote
+ }
+
+ function settleFlight(flight: Flight, live: Waiter[], makeEntry: (() => CacheEntry) | null) {
+ const stale = flight.waiters.length - live.length
+ if (stale) counters.staleDropped += stale
+ for (const waiter of flight.waiters) {
+ if (!live.includes(waiter)) waiter.resolve(null)
+ }
+ if (!live.length || !makeEntry) return
+ const entry = makeEntry()
+ for (const waiter of live) waiter.resolve(entry)
+ }
+
+ function joinFlight(key: string, waiter: Waiter) {
+ const flight = flights.get(key)
+ if (!flight) return false
+ flight.waiters.push(waiter)
+ return true
+ }
+
+ function startFlight(request: PreviewRequest, key: string, waiter: Waiter) {
+ const controller = new AbortController()
+ counters.abortControllersCreated += 1
+ counters.abortControllersLive += 1
+ const flight: Flight = { controller, waiters: [waiter] }
+ flights.set(key, flight)
+ void fetchBlob(request.sourceUrl, controller.signal).then(blob => {
+ const current = flights.get(key)
+ if (current !== flight) return
+ flights.delete(key)
+ counters.abortControllersLive = Math.max(0, counters.abortControllersLive - 1)
+ settleFlight(flight, flight.waiters.filter(item => item.isCurrent()), () => materialize(key, blob))
+ }).catch((error: unknown) => {
+ const current = flights.get(key)
+ if (current !== flight) return
+ flights.delete(key)
+ counters.abortControllersLive = Math.max(0, counters.abortControllersLive - 1)
+ if (isAbortError(error) || controller.signal.aborted) {
+ settleFlight(flight, [], null)
+ return
+ }
+ settleFlight(flight, flight.waiters.filter(item => item.isCurrent()), () => rememberSource(key, request.sourceUrl))
+ })
+ }
+
+ function loadEntry(request: PreviewRequest, ownerId: string, isCurrent: () => boolean): Promise {
+ const key = previewRequestKey(request)
+ if (request.layer === 'thumbnail') {
+ return Promise.resolve(rememberSource(key, request.thumbnailUrl || request.sourceUrl))
+ }
+ const cached = entries.get(key)
+ if (cached?.createdByPool && cached.objectUrl) return Promise.resolve(cached)
+ if (isInlinePreviewUrl(request.sourceUrl) || !shouldCopy(request)) {
+ return Promise.resolve(rememberSource(key, request.sourceUrl))
+ }
+ return new Promise(resolve => {
+ const waiter: Waiter = { ownerId, isCurrent, resolve }
+ if (!joinFlight(key, waiter)) startFlight(request, key, waiter)
+ })
+ }
+
+ function createSession(input: { scope: PreviewScope; ownerId?: string; exclusiveFull?: boolean }): PreviewSession {
+ const ownerId = input.ownerId ?? `${input.scope}:${++ownerSeq}`
+ const exclusiveFull = input.exclusiveFull ?? input.scope === 'picker'
+ let generation = 0
+ let sessionDisposed = false
+ const held = new Set()
+ let fullLease: PreviewLease | null = null
+
+ function dropLease(lease: PreviewLease | null) {
+ if (!lease) return
+ held.delete(lease)
+ if (fullLease === lease) fullLease = null
+ lease.release()
+ }
+
+ const session: PreviewSession = {
+ ownerId,
+ scope: input.scope,
+ get generation() { return generation },
+ async acquire(request: PreviewRequest) {
+ if (disposed || sessionDisposed) return null
+ generation += 1
+ const mine = generation
+ if (request.layer === 'full') counters.fullAcquires += 1
+ else counters.thumbnailAcquires += 1
+ if (exclusiveFull && request.layer === 'full') {
+ dropLease(fullLease)
+ cancelOwnerWaiters(ownerId)
+ }
+ const entry = await loadEntry(request, ownerId, () => !disposed && !sessionDisposed && generation === mine)
+ if (!entry || disposed || sessionDisposed || generation !== mine) {
+ if (entry) counters.staleDropped += 1
+ return null
+ }
+ const lease = retain(ownerId, entry, mine, Boolean(entry.createdByPool && entry.objectUrl))
+ held.add(lease)
+ if (request.layer === 'full') fullLease = lease
+ counters.published += 1
+ return lease
+ },
+ cancelCurrent() {
+ generation += 1
+ dropLease(fullLease)
+ cancelOwnerWaiters(ownerId)
+ },
+ dispose() {
+ if (sessionDisposed) return
+ sessionDisposed = true
+ generation += 1
+ cancelOwnerWaiters(ownerId)
+ for (const lease of [...held]) dropLease(lease)
+ fullLease = null
+ },
+ }
+ return session
+ }
+
+ return {
+ createSession,
+ snapshot,
+ dispose() {
+ disposed = true
+ for (const flight of flights.values()) {
+ for (const waiter of flight.waiters) waiter.resolve(null)
+ flight.controller.abort()
+ }
+ flights.clear()
+ counters.abortControllersLive = 0
+ for (const entry of [...entries.values()]) {
+ if (entry.createdByPool) dropCreatedUrl(entry)
+ }
+ entries.clear()
+ },
+ }
+}
+
+async function defaultPreviewFetch(url: string, signal: AbortSignal): Promise {
+ const response = await fetch(url, { signal })
+ if (!response.ok) throw new Error(`preview fetch failed: ${response.status}`)
+ return response.blob()
+}
+
+let sharedPool: PreviewResourcePool | null = null
+
+export function getSharedPreviewPool(): PreviewResourcePool {
+ sharedPool ??= createPreviewResourcePool({ copyRemote: false })
+ return sharedPool
+}
+
+export function resetSharedPreviewPoolForTests() {
+ sharedPool?.dispose()
+ sharedPool = null
+}
diff --git a/ui/src/features/characters/CharacterCreatorPanel.tsx b/ui/src/features/characters/CharacterCreatorPanel.tsx
index 83b23d18d..467f9c7cf 100644
--- a/ui/src/features/characters/CharacterCreatorPanel.tsx
+++ b/ui/src/features/characters/CharacterCreatorPanel.tsx
@@ -18,7 +18,10 @@ import {
} from './characterCreatorHistory'
import { useUiTranslation } from '../../i18n'
import { CharacterSpeechWorkshopEntry } from './CharacterSpeechWorkshopEntry'
+import { CharacterFacePackMaker } from './CharacterFacePackMaker'
import { Character3DLibraryEntry } from './Character3DLibraryEntry'
+import { useCharacterEditorHandoff } from './characterEditorHandoff'
+import { CharacterEditorSession } from './CharacterEditorSession'
import {
buildCharacterOrbitPrompt,
CHARACTER_ORBIT_VIEWS,
@@ -75,6 +78,15 @@ function newId(): string {
}
export function CharacterCreatorPanel() {
+ const workspace = useStore(state => state.activeWorkspace)
+ const request = useCharacterEditorHandoff(state => state.request)
+ if (request?.workspace === workspace) return
+
+
+ return
+}
+
+function CharacterCreatorWorkshop() {
const { t } = useUiTranslation('characters')
const models = useStore(s => s.models)
const activeWorkspace = useStore(s => s.activeWorkspace)
@@ -541,6 +553,7 @@ export function CharacterCreatorPanel() {
)}
+
diff --git a/ui/src/features/characters/CharacterDefinitionEditor.tsx b/ui/src/features/characters/CharacterDefinitionEditor.tsx
index eea1e6b20..d9a9f7282 100644
--- a/ui/src/features/characters/CharacterDefinitionEditor.tsx
+++ b/ui/src/features/characters/CharacterDefinitionEditor.tsx
@@ -1,5 +1,7 @@
-import { useEffect, useRef, useState } from 'react'
-import { fetchCharacterKitLibrary, saveCharacterKit } from '../../api/characters'
+import { useEffect, useRef, useState, type RefObject } from 'react'
+import type { SaveSpeechWorkshop } from './useCharacterSpeechLibrary'
+import { fetchCharacterKitLibrary } from '../../api/characters'
+import { saveCharacterDefinition } from './saveCharacterDefinition'
import { fetchOutputs, type ApiOutput } from '../../api/client'
import { createCharacterKit, type CharacterKit, type CharacterKitLibrary } from '../../lib/characterKit'
import { useUiTranslation } from '../../i18n'
@@ -10,20 +12,32 @@ import { characterFromSlot, characterSlotPatch } from '../scene3d/speech/charact
import { modelDigest } from '../scene3d/speech/profiles'
import { CharacterVoiceFields } from './CharacterVoiceFields'
import type { CharacterVoice } from '../../lib/characterVoice'
+import { randomUuid } from '../../lib/uuid'
+import { CharacterDefinitionSpeechTools } from './CharacterDefinitionSpeechTools'
+import type { CharacterDefinitionDraft } from './characterEditorHandoff'
-type Props = { workspace: string; slot?: Scene3DSlot; disabled?: boolean;
+type Props = { saveRef?: RefObject<(() => Promise
) | null>; workspace: string; slot?: Scene3DSlot; disabled?: boolean;
+ lockIdentity?: boolean; spacious?: boolean; onDirtyChange?: (dirty: boolean) => void;
+ initialDraft?: CharacterDefinitionDraft; onDraftChange?: (draft: CharacterDefinitionDraft) => void;
+ initialKit?: CharacterKit; onSaved?: (kit: CharacterKit) => void | Promise;
onApply?: (patch: Partial) => void; onBusyChange?: (busy: boolean) => void }
-function initialDefinition(workspace: string, slot?: Scene3DSlot) {
- return { id: slot?.character?.kitRef?.workspace === workspace ? slot.character.kitRef.id : '',
- name: slot?.character?.name ?? '', voice: slot?.character?.voice }
+function initialDefinition(workspace: string, slot?: Scene3DSlot, kit?: CharacterKit) {
+ const character = slot?.character
+ if (character) return { id: character.kitRef?.workspace === workspace ? character.kitRef.id : '', name: character.name, voice: character.voice }
+ return { id: kit?.id ?? '', name: kit?.name ?? '', voice: kit?.voice }
}
-function canSaveDefinition(library: CharacterKitLibrary | undefined, name: string, slot: Scene3DSlot | undefined, model: ApiOutput | undefined, kit: CharacterKit | undefined) {
+function definitionKit(library: CharacterKitLibrary | undefined, id: string, seed?: CharacterKit) {
+ const saved = library?.kits[id]
+ if (seed?.id !== id) return saved
+ return saved ? { ...saved, base: saved.base ?? seed.base, identityReference: saved.identityReference ?? seed.identityReference } : seed
+}
+function canSaveDefinition(library: CharacterKitLibrary | undefined, name: string, slot: Scene3DSlot | undefined) {
if (!library || !name.trim()) return false
- return slot ? Boolean(slot.speech?.face && slot.sourceRef) : Boolean(model || kit?.speech3d)
+ return slot ? Boolean(slot.speech?.face && slot.sourceRef) : true
}
async function definitionForSave(workspace: string, name: string, voice: CharacterVoice | undefined, kit: CharacterKit | undefined, slot: Scene3DSlot | undefined, model: ApiOutput | undefined) {
- const previous = kit ?? { ...createCharacterKit(name), id: crypto.randomUUID() }
+ const previous = kit ?? { ...createCharacterKit(name), id: randomUuid() }
let next: CharacterKit = { ...previous, name: name.trim(), voice, updatedAt: new Date().toISOString() }
if (slot) next = { ...await characterFromSlot(next, { ...slot, character: { id: slot.character?.id ?? slot.id, name, voice } }), voice }
else if (model) {
@@ -35,27 +49,46 @@ async function definitionForSave(workspace: string, name: string, voice: Charact
function DefinitionModelInput({ slot, kit, items, model, workspace, onChoose }: { slot?: Scene3DSlot; kit?: CharacterKit; items: ApiOutput[]; model?: ApiOutput; workspace: string; onChoose: (item: ApiOutput | undefined) => void }) {
const { t } = useUiTranslation('scene3dEditor')
if (slot) return null
- return {t('speech.optionalModel')} onChoose(item ?? undefined)} />
+ onChoose={item => onChoose(item ?? undefined)} />
+}
+function DefinitionIdentity({ library, initialKit, id, disabled, onSelect }: {
+ library?: CharacterKitLibrary; initialKit?: CharacterKit; id: string; disabled?: boolean
+ onSelect: (id: string, kit?: CharacterKit) => void
+}) {
+ const { t } = useUiTranslation('scene3dEditor')
+ return {t('speech.savedCharacter')} onSelect(event.target.value, library?.kits[event.target.value])}>
+ {t('speech.newCharacter')}
+ {initialKit && !library?.kits[initialKit.id] && {initialKit.name} }
+ {Object.values(library?.kits ?? {}).map(item => {item.name}{item.speech3d ? ' · 3D' : ' · 2D'} )}
+
}
/** One authoritative Character Kit library, shared by Characters and the native 3D inspector. */
export function CharacterDefinitionEditor(props: Props) {
- return
+ return
}
-function ScopedDefinition({ workspace, slot, disabled, onApply, onBusyChange }: Props) {
+function ScopedDefinition({ workspace, slot, disabled, initialKit, onSaved, onApply, onBusyChange, onDirtyChange, lockIdentity, spacious, initialDraft, onDraftChange, saveRef }: Props) {
const { t } = useUiTranslation('scene3dEditor')
const [library, setLibrary] = useState()
- const initial = initialDefinition(workspace, slot)
+ const initial = { ...initialDefinition(workspace, slot, initialKit), ...initialDraft }
const [id, setId] = useState(initial.id)
const [name, setName] = useState(initial.name)
const [voice, setVoice] = useState(initial.voice)
- const [model, setModel] = useState(), [items, setItems] = useState([])
+ const [model, setModel] = useState(initialDraft?.model), [items, setItems] = useState([])
const [busy, setBusy] = useState(false), [notice, setNotice] = useState('')
+ const [workshopDirty, setWorkshopDirty] = useState(false), [workshopBusy, setWorkshopBusy] = useState(false)
+ const workshopSave = useRef(null)
+ const saving = useRef(false)
const alive = useRef(true)
const hasSlot = Boolean(slot)
- const kit = library?.kits[id]
- useEffect(() => { onBusyChange?.(busy); return () => onBusyChange?.(false) }, [busy, onBusyChange])
+ const kit = definitionKit(library, id, initialKit)
+ const dirty = Boolean(kit && (name !== kit.name || JSON.stringify(voice) !== JSON.stringify(kit.voice) || model))
+ useEffect(() => { onBusyChange?.(busy || workshopBusy); return () => onBusyChange?.(false) }, [busy, workshopBusy, onBusyChange])
+ useEffect(() => { onDirtyChange?.(dirty || workshopDirty); return () => onDirtyChange?.(false) }, [dirty, workshopDirty, onDirtyChange])
+ useEffect(() => { onDraftChange?.({ name, voice, model }) }, [name, voice, model, onDraftChange])
useEffect(() => {
alive.current = true
void fetchCharacterKitLibrary(workspace).then(result => { if (alive.current) setLibrary(result) })
@@ -69,18 +102,34 @@ function ScopedDefinition({ workspace, slot, disabled, onApply, onBusyChange }:
setBusy(true); setNotice('')
void task().catch(error => { if (alive.current) setNotice(error.message) }).finally(() => { if (alive.current) setBusy(false) })
}
- return
+ const saveAll = async () => {
+ if (saving.current || workshopBusy || !canSaveDefinition(library, name, slot)) throw new Error(t('speech.busy'))
+ saving.current = true; setBusy(true); setNotice('')
+ try {
+ const update = (current?: CharacterKit) => definitionForSave(workspace, name, voice, current, slot, model)
+ const { saved, kit: savedKit, linked } = await saveCharacterDefinition({ workspace, library: library!, id, kit,
+ workshop: workshopSave.current, update, isCurrent: () => alive.current })
+ setLibrary(saved); setId(savedKit.id); setName(savedKit.name); setVoice(savedKit.voice); setModel(undefined)
+ if (!linked) await onSaved?.(savedKit)
+ if (slot) onApply?.({ character: { id: slot.character?.id ?? slot.id, name: savedKit.name,
+ kitRef: { id: savedKit.id, workspace }, libraryRevision: saved.revision, voice } })
+ setNotice(t('speech.characterSaved'))
+ } catch (cause) { if (alive.current) setNotice((cause as Error).message); throw cause }
+ finally { saving.current = false; if (alive.current) setBusy(false) }
+ }
+ useEffect(() => {
+ if (saveRef) saveRef.current = saveAll
+ return () => { if (saveRef) saveRef.current = null }
+ })
+ return
{t('speech.characterDefinition')}
{t('speech.definitionHint')}
-
- {t('speech.savedCharacter')} {
- const next = library?.kits[event.target.value]; setId(event.target.value); setNotice('')
+
+ {
+ setId(nextId); setNotice('')
if (!slot) { setName(next?.name ?? ''); setVoice(next?.voice); setModel(undefined) }
- }}>
- {t('speech.newCharacter')}
- {Object.values(library?.kits ?? {}).map(item => {item.name}{item.speech3d ? ' · 3D' : ' · 2D'} )}
-
+ }} />
{slot && run(async () => {
const patch = await characterSlotPatch(kit!, workspace, library!.revision, slot)
@@ -94,19 +143,20 @@ function ScopedDefinition({ workspace, slot, disabled, onApply, onBusyChange }:
if (slot) onApply?.({ character: { id: slot.character?.id ?? slot.id, name: slot.character?.name ?? name, ...slot.character, voice: next } })
}} />
run(async () => {
- const next = await definitionForSave(workspace, name, voice, kit, slot, model)
- if (!alive.current) return
- const saved = await saveCharacterKit(workspace, library!, next)
- if (!alive.current) return
- setLibrary(saved); setId(next.id); setNotice(t('speech.characterSaved'))
- if (slot) onApply?.({ character: { id: slot.character?.id ?? slot.id, name: next.name, kitRef: { id: next.id, workspace }, libraryRevision: saved.revision, voice } })
- })}>{busy ? t('speech.busy') : t('speech.saveCharacter')}
- run(async () => {
+ disabled={workshopBusy || !canSaveDefinition(library, name, slot)}
+ onClick={() => { void saveAll().catch(() => undefined) }}>{busy ? t('speech.busy') : t('speech.saveCharacter')}
+ run(async () => {
const saved = await fetchCharacterKitLibrary(workspace)
- if (alive.current) { setLibrary(saved); setNotice(t('speech.libraryReloaded')) }
+ if (!alive.current) return
+ setLibrary(saved); setNotice(t('speech.libraryReloaded'))
+ if (lockIdentity) {
+ const restored = saved.kits[id] ?? initialKit
+ setName(restored?.name ?? ''); setVoice(restored?.voice); setModel(undefined)
+ }
})}>{t('speech.reloadLibrary')}
+ {!slot && { setLibrary(saved); await onSaved?.(saved.kits[id]) }} />}
{!slot && kit?.speech3d && run(async () => {
const patch = await characterSlotPatch(kit, workspace, library!.revision)
if (!alive.current) return
diff --git a/ui/src/features/characters/CharacterDefinitionSpeechTools.tsx b/ui/src/features/characters/CharacterDefinitionSpeechTools.tsx
new file mode 100644
index 000000000..ca26568a0
--- /dev/null
+++ b/ui/src/features/characters/CharacterDefinitionSpeechTools.tsx
@@ -0,0 +1,25 @@
+import { lazy, Suspense, useState, type RefObject } from 'react'
+import type { SaveSpeechWorkshop } from './useCharacterSpeechLibrary'
+import { useUiTranslation } from '../../i18n'
+import type { CharacterKit, CharacterKitLibrary } from '../../lib/characterKit'
+
+const Preparation = lazy(() => import('./CharacterSpeechPreparation').then(module => ({ default: module.CharacterSpeechPreparation })))
+
+export function CharacterDefinitionSpeechTools({ workspace, kit, disabled, onSaved, onDirtyChange, onBusyChange, saveRef }: {
+ workspace: string; kit?: CharacterKit; disabled: boolean; onSaved: (library: CharacterKitLibrary) => void | Promise
+ saveRef?: RefObject
+ onDirtyChange?: (dirty: boolean) => void; onBusyChange?: (busy: boolean) => void
+}) {
+ const { t } = useUiTranslation('scene3dEditor')
+ const [open, setOpen] = useState(false)
+ const [dirty, setDirty] = useState(false), [busy, setBusy] = useState(false)
+ return
+
setOpen(value => !value)}>{t('speech.configure2d')}
+ {!kit?.base &&
{t('speech.need2dImage')}
}
+ {open && kit?.base &&
{t('speech.busy')}}>
+ { setDirty(value); onDirtyChange?.(value) }} onBusyChange={value => { setBusy(value); onBusyChange?.(value) }} />
+ }
+
+}
diff --git a/ui/src/features/characters/CharacterEditorSession.tsx b/ui/src/features/characters/CharacterEditorSession.tsx
new file mode 100644
index 000000000..873896473
--- /dev/null
+++ b/ui/src/features/characters/CharacterEditorSession.tsx
@@ -0,0 +1,57 @@
+import { lazy, Suspense, useRef, useState } from 'react'
+import { useUiTranslation } from '../../i18n'
+import { useCharacterEditorHandoff, type CharacterEditorRequest } from './characterEditorHandoff'
+
+const Definition = lazy(() => import('./CharacterDefinitionEditor').then(module => ({ default: module.CharacterDefinitionEditor })))
+
+export function CharacterEditorSession({ request }: { request: CharacterEditorRequest }) {
+ const { t } = useUiTranslation('characters')
+ const [busy, setBusy] = useState(false), [dirty, setDirty] = useState(false), [error, setError] = useState('')
+ const saveRef = useRef<(() => Promise) | null>(null)
+ const [saving, setSaving] = useState(false)
+ const returnToSource = async () => {
+ setBusy(true); setError('')
+ try {
+ await request.onReturn()
+ if (useCharacterEditorHandoff.getState().request === request) useCharacterEditorHandoff.setState({ request: null })
+ } catch (cause) { setError((cause as Error).message) }
+ finally { setBusy(false) }
+ }
+ const saveAndReturn = async () => {
+ setSaving(true); setError('')
+ try {
+ if (!saveRef.current) throw new Error(t('speechWorkshop.busy'))
+ await saveRef.current()
+ await returnToSource()
+ } catch (cause) { setError((cause as Error).message) }
+ finally { setSaving(false) }
+ }
+ return
+
+ {t('speechWorkshop.busy')}}>
+ { request.draft = draft }}
+ onSaved={async kit => {
+ await request.onSaved(kit)
+ request.kit = kit
+ request.saved = true
+ }} onBusyChange={setBusy} onDirtyChange={setDirty} />
+
+
+ {error && {error}
}
+
+}
diff --git a/ui/src/features/characters/CharacterFacePackMaker.tsx b/ui/src/features/characters/CharacterFacePackMaker.tsx
new file mode 100644
index 000000000..8b55bbc77
--- /dev/null
+++ b/ui/src/features/characters/CharacterFacePackMaker.tsx
@@ -0,0 +1,136 @@
+import { useMemo, useState } from 'react'
+import { EXPRESSIONS, VISEMES, type Expression, type Viseme } from '../scene3d/speech/types'
+import {
+ EXPRESSION_EYES,
+ FACE_PLANE_REST_PROMPT,
+ VISEME_ALIASES,
+ VISEME_MOUTHS,
+ expressionPrompt,
+ fillFacePrompt,
+ parseFacePackStillName,
+ visemePrompt,
+} from '../scene3d/speech/facePackPrompts'
+import { composeFacePack } from '../scene3d/speech/facePackAssemble'
+import { useUiTranslation } from '../../i18n'
+
+const field = 'mt-1 w-full rounded border border-border bg-bg-primary p-2 text-xs text-text-primary'
+const button = 'rounded border border-border px-3 py-2 text-xs text-text-primary disabled:opacity-40'
+
+export function CharacterFacePackMaker() {
+ const { t } = useUiTranslation('characters')
+ const [skin, setSkin] = useState('cream felt fabric')
+ const [stills, setStills] = useState>({})
+ const [previewUrl, setPreviewUrl] = useState(null)
+ const [status, setStatus] = useState('')
+ const restPrompt = useMemo(() => fillFacePrompt(FACE_PLANE_REST_PROMPT, skin), [skin])
+
+ const loadFiles = (list: FileList | null) => {
+ if (!list) return
+ for (const file of Array.from(list)) {
+ const parsed = parseFacePackStillName(file.name)
+ if (!parsed) {
+ setStatus(t('facePackMaker.unknownFile', { name: file.name }))
+ continue
+ }
+ const key = parsed.kind === 'rest' ? 'rest' : parsed.id
+ const url = URL.createObjectURL(file)
+ setStills(current => {
+ if (current[key]) URL.revokeObjectURL(current[key])
+ return { ...current, [key]: url }
+ })
+ setStatus(t('facePackMaker.loaded', { name: key }))
+ }
+ }
+
+ const copy = (text: string) => { void navigator.clipboard.writeText(text) }
+
+ const build = async () => {
+ if (!stills.rest) {
+ setStatus(t('facePackMaker.needRest'))
+ return
+ }
+ const load = (src: string) => new Promise((resolve, reject) => {
+ const image = new Image()
+ image.onload = () => resolve(image)
+ image.onerror = () => reject(new Error(src))
+ image.src = src
+ })
+ try {
+ const rest = await load(stills.rest)
+ const visemes: Partial> = {}
+ const expressions: Partial> = {}
+ for (const viseme of VISEMES) {
+ if (viseme === 'rest' || !stills[viseme]) continue
+ visemes[viseme] = await load(stills[viseme])
+ }
+ for (const expression of EXPRESSIONS) {
+ if (expression === 'neutral' || !stills[expression]) continue
+ expressions[expression] = await load(stills[expression])
+ }
+ const canvas = composeFacePack({ rest, visemes, expressions })
+ if (previewUrl) URL.revokeObjectURL(previewUrl)
+ const url = canvas.toDataURL('image/png')
+ setPreviewUrl(url)
+ setStatus(t('facePackMaker.ready'))
+ } catch {
+ setStatus(t('facePackMaker.buildFailed'))
+ }
+ }
+
+ const slot = (key: string) => (
+
+ {key}
+ {stills[key]
+ ?
+ : {t('facePackMaker.empty')} }
+
+ )
+
+ return (
+
+ {t('facePackMaker.title')}
+ {t('facePackMaker.intro')}
+ {t('facePackMaker.skin')}
+ setSkin(event.target.value)} className={field} />
+
+ {t('facePackMaker.restPrompt')}
+
+
+ copy(restPrompt)}>{t('facePackMaker.copyRest')}
+
+ {t('facePackMaker.editPrompts')}
+
+ {VISEMES.filter(id => id !== 'rest').map(id => {
+ const text = visemePrompt(id, skin)
+ return
+
{id}{VISEME_ALIASES[id] ? ` → ${VISEME_ALIASES[id]}` : ''} · {VISEME_MOUTHS[id].slice(22)}
+
copy(text)}>{t('facePackMaker.copyNamed', { name: id })}
+
+ })}
+ {EXPRESSIONS.filter(id => id !== 'neutral').map(id => {
+ const text = expressionPrompt(id, skin)
+ return
+
{id} · {EXPRESSION_EYES[id].slice(40)}
+
copy(text)}>{t('facePackMaker.copyNamed', { name: id })}
+
+ })}
+
+
+ {t('facePackMaker.stills')}
+ loadFiles(event.target.files)} />
+
+ {t('facePackMaker.naming')}
+
+ {slot('rest')}
+ {VISEMES.filter(id => id !== 'rest').map(slot)}
+ {EXPRESSIONS.filter(id => id !== 'neutral').map(slot)}
+
+
+ {status && {status}
}
+ {previewUrl && }
+
+ )
+}
diff --git a/ui/src/features/characters/CharacterFacePatchPanel.tsx b/ui/src/features/characters/CharacterFacePatchPanel.tsx
index eed0e3628..8429d35bb 100644
--- a/ui/src/features/characters/CharacterFacePatchPanel.tsx
+++ b/ui/src/features/characters/CharacterFacePatchPanel.tsx
@@ -6,8 +6,9 @@ import { characterFacePatchPrompt, registerCharacterFacePatch, type FacePatchMet
import { prepareCharacterFacePatch } from '../../lib/prepareCharacterFacePatch'
import { faceRigOverlayPreviewStyle, type CharacterKitFaceRigState } from '../../lib/characterKitFaceRig'
import type { CharacterFaceAnchor, CharacterKit, CharacterKitAsset, CharacterMouthState } from '../../lib/characterKit'
+import { CHARACTER_MOUTH_STATES } from '../../lib/characterMouthStates'
-const MOUTH_STATES = ['closed', 'small', 'wide', 'round'] as const
+const MOUTH_STATES = CHARACTER_MOUTH_STATES
const ACCEPTED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp'])
type PreparedPatch = Awaited>
type PrepareService = (poseSource: string, variant: File, anchor: CharacterFaceAnchor) => Promise
@@ -201,6 +202,8 @@ function useCharacterFacePatchController(props: CharacterFacePatchPanelProps): C
small: t('faceRig.states.small'),
wide: t('faceRig.states.wide'),
round: t('faceRig.states.round'),
+ pressed: t('faceRig.states.pressed'), medium: t('faceRig.states.medium'), pucker: t('faceRig.states.pucker'),
+ bite: t('faceRig.states.bite'), tongue: t('faceRig.states.tongue'),
'open-eyes': t('faceRig.states.open-eyes'),
blink: t('faceRig.states.blink'),
}
diff --git a/ui/src/features/characters/CharacterKitFaceRigPanel.tsx b/ui/src/features/characters/CharacterKitFaceRigPanel.tsx
index 251842c66..24a7aa3ed 100644
--- a/ui/src/features/characters/CharacterKitFaceRigPanel.tsx
+++ b/ui/src/features/characters/CharacterKitFaceRigPanel.tsx
@@ -1,10 +1,10 @@
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import type { ParseKeys } from 'i18next'
-import { analyzeAudio, cleanCharacterKitFaceOverlay, getFileUrl, uploadImage } from '../../api/client'
+import { cleanCharacterKitFaceOverlay, getFileUrl, uploadImage } from '../../api/client'
import { generateImageAsset } from '../../lib/imageGeneration'
-import { generateSceneSpeechClip } from '../../lib/sceneSpeech'
+import { createCharacterSpeechPreview } from '../../lib/characterSpeechPreview'
+import { CHARACTER_MOUTH_STATES } from '../../lib/characterMouthStates'
import {
- CHARACTER_FACE_RIG_STATES,
FACE_RIG_PRESET_ROOT,
FACE_RIG_STYLE_PRESETS,
FACE_RIG_TRAIT_CHIPS,
@@ -15,8 +15,6 @@ import {
composeCharacterKitLook,
facePatchControls,
faceRigAnchorFor,
- faceRigAnchorFromRegion,
- faceRigRegionFromAnchor,
previewPercentToImagePixel,
wipeMouthRegion,
faceRigGenerationRequests,
@@ -26,7 +24,6 @@ import {
lockFaceRigEyePlacement,
lockFaceRigMouthPlacement,
previewFaceRigDialogue,
- previewFaceRigDialogueFromAudio,
registerCleanedFaceRigAsset,
registerGeneratedFaceRigAsset,
setFaceRigReviewState,
@@ -42,6 +39,10 @@ import { useFaceRigOperationGuard } from './useFaceRigOperationGuard'
import { isFacePatchCompatible } from '../../lib/characterFacePatch'
import { useStore } from '../../stores/useStore'
import i18n, { useUiTranslation } from '../../i18n'
+import { mouthWipeBox, resizeMouthWipeBox } from './mouthWipeBox'
+import { MouthPackChoices } from './MouthPackChoices'
+import { FaceRigSamplePreview } from './FaceRigSamplePreview'
+import { FaceRigStatePicker } from './FaceRigStatePicker'
type Props = {
kit: CharacterKit
@@ -53,6 +54,7 @@ type Props = {
onChange: (kit: CharacterKit) => void
onCommit?: (kit: CharacterKit) => void
onStatus?: (message: string) => void
+ onBusyChange?: (busy: boolean) => void
}
const PLACEMENT_WARNING_KEYS: Record> = {
@@ -98,7 +100,7 @@ async function inspectSourceAlpha(source: string) {
} finally { bitmap.close() }
}
-export function CharacterKitFaceRigPanel({ kit, poseId, disabled = false, allowModelActions = true, workspace: workspaceOverride, onChange, onCommit, onStatus }: Props) {
+export function CharacterKitFaceRigPanel({ kit, poseId, disabled = false, allowModelActions = true, workspace: workspaceOverride, onChange, onCommit, onStatus, onBusyChange }: Props) {
const { t } = useUiTranslation('characters')
const stateLabel = (state: CharacterKitFaceRigState) => t(`faceRig.states.${state}`)
const imageModel = useStore(state => state.selectedModelPerMode.image || '')
@@ -122,10 +124,14 @@ export function CharacterKitFaceRigPanel({ kit, poseId, disabled = false, allowM
const [dialogueAudio, setDialogueAudio] = useState(null)
const [presetPacks, setPresetPacks] = useState([])
const [presetId, setPresetId] = useState('')
+ const [boxAspect, setBoxAspect] = useState(1)
const previewRef = useRef(null)
- const dragRef = useRef<{ pointerId: number; startX: number; startY: number; origin: CharacterFaceAnchor; mode: 'move' | 'resize' } | null>(null)
+ const dragRef = useRef<{ pointerId: number; startX: number; startY: number; origin: CharacterFaceAnchor; aspect: number; mode: 'move' | 'resize' } | null>(null)
const audioRef = useRef(null)
const playTokenRef = useRef(0)
+ const speechRequestRef = useRef(null)
+ const dialogueFilenameRef = useRef(null)
+ const sampleStopRef = useRef<(() => void) | null>(null)
const dialoguePreviewRef = useRef(null)
const savedAnchor = useMemo(() => faceRigAnchorFor(kit, poseId, selectedState), [kit, poseId, selectedState])
const poseSource = poseId === 'base' ? kit.base?.source : kit.poses[poseId]?.source
@@ -152,11 +158,22 @@ export function CharacterKitFaceRigPanel({ kit, poseId, disabled = false, allowM
const poseApproved = Boolean((poseId === 'base' ? kit.base : kit.poses[poseId])?.reviewState === 'approved')
const placement = useMemo(() => assessFaceRigPlacement(draftAnchor, selectedState), [draftAnchor, selectedState])
const overlayStyle = useMemo(() => faceRigOverlayPreviewStyle(playbackAnchor), [playbackAnchor])
- const mouthRegion = useMemo(() => faceRigRegionFromAnchor(draftAnchor), [draftAnchor])
+ const mouthRegion = useMemo(() => mouthWipeBox(draftAnchor, boxAspect), [draftAnchor, boxAspect])
const nextStep = characterKitNextStep(kit, poseId)
const poseName = characterKitPoseLabel(poseId)
const dirtyAnchor = JSON.stringify(draftAnchor) !== JSON.stringify(savedAnchor)
dialoguePreviewRef.current = dialoguePreview
+ useEffect(() => { onBusyChange?.(Boolean(busyState)); return () => onBusyChange?.(false) }, [busyState, onBusyChange])
+
+ useEffect(() => {
+ const playback = playTokenRef, audio = audioRef.current, speech = speechRequestRef
+ playback.current++
+ audio?.pause()
+ setDialoguePreview(null); dialoguePreviewRef.current = null
+ setDialogueAudio(null); dialogueFilenameRef.current = null
+ setLiveViseme(undefined)
+ return () => { playback.current++; audio?.pause(); speech.current?.abort() }
+ }, [kit, poseId, workspace, dialogueText])
useEffect(() => {
setDraftAnchor(savedAnchor)
@@ -168,7 +185,7 @@ export function CharacterKitFaceRigPanel({ kit, poseId, disabled = false, allowM
const data = await response.json() as { packs?: FaceRigMouthPresetPack[] }
if (!cancelled) {
setPresetPacks(Array.isArray(data.packs) ? data.packs : [])
- setPresetId(current => current || data.packs?.[0]?.id || '')
+ setPresetId(current => current || data.packs?.find(pack => CHARACTER_MOUTH_STATES.every(state => pack.states[state]?.file))?.id || data.packs?.[0]?.id || '')
}
}).catch(() => { if (!cancelled) setPresetPacks([]) })
return () => { cancelled = true }
@@ -188,7 +205,7 @@ export function CharacterKitFaceRigPanel({ kit, poseId, disabled = false, allowM
imageModel || undefined,
request.reference,
'full character, head, body, skin rectangle, opaque background, checkerboard, text, glow, halo, shadow, extra objects',
- { strictReference: true, referenceMode: 'identity', resolution: '1024x1024', aspectRatio: '1:1' },
+ { strictReference: true, referenceMode: 'identity', resolution: '1024x1024', aspectRatio: '1:1', workspace },
)
const alpha = await inspectSourceAlpha(generated.source).catch(() => ({
pixelCount: 0, transparentRatio: 0, translucentRatio: 0, opaqueRatio: 0, status: 'unknown' as const,
@@ -268,7 +285,7 @@ export function CharacterKitFaceRigPanel({ kit, poseId, disabled = false, allowM
setBusyState('pack'); setError(null)
try {
let next = persistLook(kit)
- const missing = CHARACTER_FACE_RIG_STATES.filter(state => !assetFor(next, state) || assetFor(next, state)?.reviewState === 'rejected')
+ const missing = CHARACTER_MOUTH_STATES.filter(state => !assetFor(next, state)?.source || assetFor(next, state)?.reviewState === 'rejected')
if (!missing.length) throw new Error(t('faceRig.errors.packComplete'))
for (const state of missing) {
onStatus?.(t('faceRig.status.generatingState', { name: stateLabel(state) }))
@@ -307,6 +324,9 @@ export function CharacterKitFaceRigPanel({ kit, poseId, disabled = false, allowM
}
const planDialogue = () => {
+ sampleStopRef.current?.()
+ playTokenRef.current++; audioRef.current?.pause()
+ setDialogueAudio(null); dialogueFilenameRef.current = null
try {
const preview = previewFaceRigDialogue(kit, dialogueText)
setDialoguePreview(preview)
@@ -318,7 +338,8 @@ export function CharacterKitFaceRigPanel({ kit, poseId, disabled = false, allowM
}
}
- const playDialogue = () => {
+ const playDialogue = async () => {
+ sampleStopRef.current?.()
let preview = dialoguePreviewRef.current
if (!preview) {
try {
@@ -332,20 +353,25 @@ export function CharacterKitFaceRigPanel({ kit, poseId, disabled = false, allowM
}
}
const token = ++playTokenRef.current
- const started = performance.now()
const audio = audioRef.current
- if (audio && dialogueAudio) {
+ const filename = dialogueFilenameRef.current
+ if (audio && filename) {
+ audio.src = getFileUrl(filename, workspace)
audio.currentTime = 0
- void audio.play().catch(() => undefined)
+ try { await audio.play() } catch (cause) {
+ if (token === playTokenRef.current) setError(cause instanceof Error ? cause.message : t('faceRig.errors.planFailed'))
+ return
+ }
}
+ const started = performance.now()
const tick = () => {
if (playTokenRef.current !== token) return
const current = dialoguePreviewRef.current
if (!current) return
- const elapsed = audio && dialogueAudio && !audio.paused ? audio.currentTime : (performance.now() - started) / 1000
+ const elapsed = audio && filename ? audio.currentTime : (performance.now() - started) / 1000
setLiveViseme(faceRigVisemeAt(current, elapsed))
- if (elapsed >= current.end) {
- setLiveViseme(undefined)
+ if (elapsed >= current.end || (audio && filename && audio.ended)) {
+ setLiveViseme({ start: current.end, end: current.end, state: 'closed', sourceState: 'closed', fallback: false })
return
}
requestAnimationFrame(tick)
@@ -355,27 +381,25 @@ export function CharacterKitFaceRigPanel({ kit, poseId, disabled = false, allowM
const speakDialogue = async () => {
if (modelActionsDisabled) return
+ sampleStopRef.current?.()
const line = dialogueText.trim()
- if (!line) throw new Error(t('faceRig.errors.writeLine'))
+ if (!line) { setError(t('faceRig.errors.writeLine')); return }
+ playTokenRef.current++; audioRef.current?.pause()
+ setDialoguePreview(null); dialoguePreviewRef.current = null
+ setDialogueAudio(null); dialogueFilenameRef.current = null
+ const request = new AbortController()
+ speechRequestRef.current?.abort(); speechRequestRef.current = request
setBusyState('dialogue'); setError(null)
try {
- const clip = await generateSceneSpeechClip({ prompt: line, model: speechModel, durationSeconds: 3 })
- setDialogueAudio(clip.filename)
- let preview = previewFaceRigDialogue(kit, line, 3)
- try {
- const analysis = await analyzeAudio({ audio_path: clip.filename, transcribe: true, extract_vocals: true, lyrics_hint: line })
- const units = (analysis.lyrics ?? []).flatMap(segment => segment.words?.length
- ? segment.words.map(word => ({ text: word.text, start: word.start, end: word.end }))
- : [{ text: segment.text, start: segment.start, end: segment.end }])
- preview = previewFaceRigDialogueFromAudio(kit, line, units)
- } catch {
- preview = previewFaceRigDialogue(kit, line, 3)
- }
- setDialoguePreview(preview)
+ const { filename, preview } = await createCharacterSpeechPreview({ kit, text: line, model: speechModel,
+ workspace, language: i18n.resolvedLanguage || i18n.language, signal: request.signal })
+ if (request.signal.aborted) return
+ setDialogueAudio(filename); dialogueFilenameRef.current = filename
+ setDialoguePreview(preview); dialoguePreviewRef.current = preview
onStatus?.(t('faceRig.status.speechReady', { count: preview.visemes.length }))
- requestAnimationFrame(() => playDialogue())
+ requestAnimationFrame(() => { if (!request.signal.aborted) void playDialogue() })
} catch (cause) {
- setError(cause instanceof Error ? cause.message : t('faceRig.errors.speechFailed'))
+ if (!request.signal.aborted) setError(cause instanceof Error ? cause.message : t('faceRig.errors.speechFailed'))
} finally { setBusyState(null) }
}
@@ -384,7 +408,7 @@ export function CharacterKitFaceRigPanel({ kit, poseId, disabled = false, allowM
const box = previewRef.current
if (!box) return
event.currentTarget.setPointerCapture(event.pointerId)
- dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, origin: draftAnchor, mode: 'move' }
+ dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, origin: draftAnchor, aspect: boxAspect, mode: 'move' }
}
const onRegionPointerDown = (event: ReactPointerEvent, mode: 'move' | 'resize') => {
@@ -392,7 +416,7 @@ export function CharacterKitFaceRigPanel({ kit, poseId, disabled = false, allowM
event.preventDefault()
event.stopPropagation()
event.currentTarget.setPointerCapture(event.pointerId)
- dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, origin: draftAnchor, mode }
+ dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, origin: draftAnchor, aspect: boxAspect, mode }
}
const onOverlayPointerMove = (event: ReactPointerEvent) => {
@@ -402,14 +426,11 @@ export function CharacterKitFaceRigPanel({ kit, poseId, disabled = false, allowM
const dx = ((event.clientX - drag.startX) / Math.max(1, box.clientWidth)) * 100
const dy = ((event.clientY - drag.startY) / Math.max(1, box.clientHeight)) * 100
if (drag.mode === 'resize') {
- const region = faceRigRegionFromAnchor(drag.origin)
- const next = faceRigAnchorFromRegion({
- ...region,
- width: Math.max(1, region.width + dx),
- height: Math.max(1, region.height + dy),
- })
- draftAnchorRef.current = next
- setDraftAnchor(next)
+ const region = mouthWipeBox(drag.origin, drag.aspect)
+ const next = resizeMouthWipeBox(region, region.width + dx, region.height + dy)
+ draftAnchorRef.current = next.anchor
+ setDraftAnchor(next.anchor)
+ setBoxAspect(next.aspect)
return
}
const next = {
@@ -495,7 +516,7 @@ export function CharacterKitFaceRigPanel({ kit, poseId, disabled = false, allowM
const context = canvas.getContext('2d', { willReadFrequently: true })
if (!context) throw new Error(t('faceRig.errors.editPose'))
context.drawImage(bitmap, 0, 0)
- const region = faceRigRegionFromAnchor(draftAnchor)
+ const region = mouthRegion
const topLeft = previewPercentToImagePixel(region.x, region.y, bitmap.width, bitmap.height)
const bottomRight = previewPercentToImagePixel(region.x + region.width, region.y + region.height, bitmap.width, bitmap.height)
const pixels = wipeMouthRegion(
@@ -507,6 +528,7 @@ export function CharacterKitFaceRigPanel({ kit, poseId, disabled = false, allowM
cy: (topLeft.y + bottomRight.y) / 2,
rx: Math.max(2, Math.abs(bottomRight.x - topLeft.x) / 2),
ry: Math.max(2, Math.abs(bottomRight.y - topLeft.y) / 2),
+ shape: 'rectangle',
},
)
const painted = new ImageData(bitmap.width, bitmap.height)
@@ -562,38 +584,36 @@ export function CharacterKitFaceRigPanel({ kit, poseId, disabled = false, allowM
} catch (cause) { setError(cause instanceof Error ? cause.message : t('faceRig.errors.reviewFailed')) }
}
- return
-
{t('faceRig.mouthsOnPose', { pose: poseName })}
-
+ return
+
{t('faceRig.mouthsOnPose', { pose: poseName })}
+
{t('faceRig.steps.box')}
{t(patchControls.instruction)}
{t('faceRig.steps.eyes')}
{t('faceRig.steps.scene')}
-
{nextStep.title}
+
{nextStep.title}
- {t('faceRig.createNew.summary')}
+ {t('faceRig.createNew.summary')}
-
{t('faceRig.createNew.hint')}
+
{t('faceRig.createNew.hint')}
{FACE_RIG_STYLE_PRESETS.map(preset => (
- setStyleId(preset.id)} className={`rounded border px-1 py-0.5 text-[8px] ${styleId === preset.id ? 'border-emerald-300 bg-emerald-400/15 text-emerald-100' : 'border-border text-text-muted'}`}>{t(`faceRig.styles.${preset.id}`)}
+ setStyleId(preset.id)} className={`rounded border px-1 py-0.5 text-xs ${styleId === preset.id ? 'border-emerald-300 bg-emerald-400/15 text-emerald-100' : 'border-border text-text-muted'}`}>{t(`faceRig.styles.${preset.id}`)}
))}
{FACE_RIG_TRAIT_CHIPS.map(trait => (
- toggleTrait(trait)} className={`rounded border px-1 py-0.5 text-[8px] ${traits.includes(trait) ? 'border-amber-300 bg-amber-400/15 text-amber-100' : 'border-border text-text-muted'}`}>{t(`faceRig.traits.${trait}`)}
+ toggleTrait(trait)} className={`rounded border px-1 py-0.5 text-xs ${traits.includes(trait) ? 'border-amber-300 bg-amber-400/15 text-amber-100' : 'border-border text-text-muted'}`}>{t(`faceRig.traits.${trait}`)}
))}
-
{t('faceRig.extraNotes')}
-
void generatePose()} className="w-full rounded border border-emerald-300/40 bg-emerald-400/10 px-1 py-1 text-[9px] text-emerald-100 disabled:opacity-40">{busyState === 'pose' ? t('faceRig.generatingBody') : poseApproved ? t('faceRig.regenerateBody') : t('faceRig.generateBody')}
- {presetPacks.length > 0 &&
- setPresetId(event.target.value)} className="rounded border border-border bg-bg-primary px-1 py-1 text-[8px]">
- {presetPacks.map(pack => {pack.label} )}
-
- {t('faceRig.usePack')}
-
}
+
{t('faceRig.extraNotes')}
+
void generatePose()} className="w-full rounded border border-emerald-300/40 bg-emerald-400/10 px-1 py-2 text-xs text-emerald-100 disabled:opacity-40">{busyState === 'pose' ? t('faceRig.generatingBody') : poseApproved ? t('faceRig.regenerateBody') : t('faceRig.generateBody')}
+
- {poseSource &&
+
+
+ {t('faceRig.stepPlace')}
+ {poseSource &&
}
onRegionPointerDown(event, 'move')}
onPointerMove={onOverlayPointerMove}
@@ -619,78 +641,86 @@ export function CharacterKitFaceRigPanel({ kit, poseId, disabled = false, allowM
onPointerCancel={onOverlayPointerUp}
>
onRegionPointerDown(event, 'resize')}
/>
- {t('faceRig.dragBoxHint')}
+ {t('faceRig.dragBoxHint')}
- void wipeMouthZone()} className="rounded border border-amber-300/50 bg-amber-400/10 px-1 py-1.5 text-[10px] text-amber-100 disabled:opacity-40">{busyState === 'wipe' ? t('faceRig.wiping') : t('faceRig.wipeMouth')}
- {t('faceRig.lockMouths')}
+ void wipeMouthZone()} className="rounded border border-amber-300/50 bg-amber-400/10 px-1 py-1.5 text-xs text-amber-100 disabled:opacity-40">{busyState === 'wipe' ? t('faceRig.wiping') : t('faceRig.wipeMouth')}
+ {t('faceRig.lockMouths')}
}
-
{CHARACTER_FACE_RIG_STATES.map(state => {
- const asset = assetFor(kit, state)
- return setSelectedState(state)} className={`rounded border px-1 py-1 text-[7px] ${selectedState === state ? 'border-emerald-300 bg-emerald-400/15 text-emerald-100' : 'border-border text-text-muted'}`}>{stateLabel(state)}{t(`review.${asset?.reviewState ?? 'missing'}`)}
- })}
- {selectedRequest &&
{t('faceRig.promptUsed', { name: stateLabel(selectedState) })} {selectedRequest.prompt}
}
+
+
+
{t('faceRig.stepChoose')}
+
{t('faceRig.restHint')}
+
+
+ {selectedRequest &&
{t('faceRig.promptUsed', { name: stateLabel(selectedState) })} {selectedRequest.prompt}
}
{assetFor(kit, selectedState) &&
-
{assetFor(kit, selectedState)!.name} {t(`alpha.${assetFor(kit, selectedState)!.alphaStatus}`)} · {t(`review.${assetFor(kit, selectedState)!.reviewState}`)}
+
{assetFor(kit, selectedState)!.name} {t(`alpha.${assetFor(kit, selectedState)!.alphaStatus}`)} · {t(`review.${assetFor(kit, selectedState)!.reviewState}`)}
-
void cleanSelected()} className="w-full rounded border border-cyan-300/40 bg-cyan-400/10 px-1 py-1 text-[8px] text-cyan-100 disabled:opacity-40">{busyState === 'cleanup' ? t('faceRig.cleaningCutout') : t('faceRig.cleanMouthBackground')}
-
+
void cleanSelected()} className="w-full rounded border border-cyan-300/40 bg-cyan-400/10 px-1 py-2 text-xs text-cyan-100 disabled:opacity-40">{busyState === 'cleanup' ? t('faceRig.cleaningCutout') : t('faceRig.cleanMouthBackground')}
+
setShowOverlay(value => !value)} className="rounded border border-border px-1 py-0.5">{showOverlay ? t('faceRig.hideMouth') : t('faceRig.showMouth')}
setCheckerboard(value => !value)} className="rounded border border-border px-1 py-0.5">{checkerboard ? t('faceRig.solidBackground') : t('faceRig.checkerboard')}
- nudge('offsetY', -1)} className="rounded border border-border px-1 py-1 text-[8px] text-text-secondary">{t('faceRig.nudge.up')}
- nudge('offsetY', 1)} className="rounded border border-amber-300/40 bg-amber-400/10 px-1 py-1 text-[8px] text-amber-100">{t('faceRig.nudge.down')}
- nudge('offsetX', -1)} className="rounded border border-border px-1 py-1 text-[8px] text-text-secondary">{t('faceRig.nudge.left')}
- nudge('offsetX', 1)} className="rounded border border-border px-1 py-1 text-[8px] text-text-secondary">{t('faceRig.nudge.right')}
+ nudge('offsetY', -1)} className="rounded border border-border px-1 py-2 text-xs text-text-secondary">{t('faceRig.nudge.up')}
+ nudge('offsetY', 1)} className="rounded border border-amber-300/40 bg-amber-400/10 px-1 py-2 text-xs text-amber-100">{t('faceRig.nudge.down')}
+ nudge('offsetX', -1)} className="rounded border border-border px-1 py-2 text-xs text-text-secondary">{t('faceRig.nudge.left')}
+ nudge('offsetX', 1)} className="rounded border border-border px-1 py-2 text-xs text-text-secondary">{t('faceRig.nudge.right')}
- nudge('scale', -0.005)} className="rounded border border-border px-1 py-1 text-[8px] text-text-secondary">{t('faceRig.nudge.smaller')}
- nudge('scale', 0.005)} className="rounded border border-border px-1 py-1 text-[8px] text-text-secondary">{t('faceRig.nudge.bigger')}
+ nudge('scale', -0.005)} className="rounded border border-border px-1 py-2 text-xs text-text-secondary">{t('faceRig.nudge.smaller')}
+ nudge('scale', 0.005)} className="rounded border border-border px-1 py-2 text-xs text-text-secondary">{t('faceRig.nudge.bigger')}
-
+
{t('faceRig.fields.x')} updateAnchorField('offsetX', Number(event.target.value))} onPointerUp={() => commitPlacement()} className="w-full" />
{t('faceRig.fields.y')} updateAnchorField('offsetY', Number(event.target.value))} onPointerUp={() => commitPlacement()} className="w-full" />
{t('faceRig.fields.scale')} updateAnchorField('scale', Number(event.target.value))} onPointerUp={() => commitPlacement()} className="w-full" />
{t('faceRig.fields.rotate')} updateAnchorField('rotation', Number(event.target.value))} onPointerUp={() => commitPlacement()} className="w-full" />
-
{t('faceRig.readout', { x: draftAnchor.offsetX.toFixed(2), y: draftAnchor.offsetY.toFixed(2), scale: draftAnchor.scale.toFixed(4), rot: draftAnchor.rotation.toFixed(1) })}
- {placement.warnings.map(warning =>
{PLACEMENT_WARNING_KEYS[warning] ? t(PLACEMENT_WARNING_KEYS[warning]) : warning}
)}
+
{t('faceRig.readout', { x: draftAnchor.offsetX.toFixed(2), y: draftAnchor.offsetY.toFixed(2), scale: draftAnchor.scale.toFixed(4), rot: draftAnchor.rotation.toFixed(1) })}
+ {placement.warnings.map(warning =>
{PLACEMENT_WARNING_KEYS[warning] ? t(PLACEMENT_WARNING_KEYS[warning]) : warning}
)}
- {t('faceRig.saveMouth')}
- {t('faceRig.lockMouths')}
+ {t('faceRig.saveMouth')}
+ {t('faceRig.lockMouths')}
- setDraftAnchor(savedAnchor)} className="rounded border border-border px-1 py-1 text-[8px] text-text-muted disabled:opacity-40">{t('faceRig.reset')}
- {holdBlink ? t('faceRig.blinking') : t('faceRig.flashBlink')}
+ setDraftAnchor(savedAnchor)} className="rounded border border-border px-1 py-2 text-xs text-text-muted disabled:opacity-40">{t('faceRig.reset')}
+ {holdBlink ? t('faceRig.blinking') : t('faceRig.flashBlink')}
-
review(selectedState, true)} className="rounded border border-emerald-300/40 bg-emerald-400/10 px-1 py-1 text-[8px] text-emerald-100 disabled:opacity-40">{t('faceRig.approveTransparent')} review(selectedState, false)} className="rounded border border-red-300/30 px-1 py-1 text-[8px] text-red-200">{t('faceRig.reject')}
+
review(selectedState, true)} className="rounded border border-emerald-300/40 bg-emerald-400/10 px-1 py-2 text-xs text-emerald-100 disabled:opacity-40">{t('faceRig.approveTransparent')} review(selectedState, false)} className="rounded border border-red-300/30 px-1 py-2 text-xs text-red-200">{t('faceRig.reject')}
}
-
void generateSelected()} className="rounded border border-emerald-300/50 bg-emerald-400/10 px-1 py-1 text-[8px] text-emerald-100 disabled:opacity-40">{busyState === selectedState ? t('faceRig.generatingNamed', { name: stateLabel(selectedState) }) : t('faceRig.generateNamed', { name: stateLabel(selectedState) })} void generateMissingPack()} className="rounded border border-emerald-300/30 px-1 py-1 text-[8px] text-emerald-100 disabled:opacity-40">{busyState === 'pack' ? t('faceRig.generatingPack') : t('faceRig.generateMissing')}
-
- {t('faceRig.tryLine.summary')}
-
+
+
{t('faceRig.unsavedHint')}
+ {error &&
{error}
}
}
diff --git a/ui/src/features/characters/CharacterKitLibraryPanel.tsx b/ui/src/features/characters/CharacterKitLibraryPanel.tsx
index 79c81c226..185fd6054 100644
--- a/ui/src/features/characters/CharacterKitLibraryPanel.tsx
+++ b/ui/src/features/characters/CharacterKitLibraryPanel.tsx
@@ -2,6 +2,7 @@ import { Loader2, Trash2 } from 'lucide-react'
import type { CharacterKit, CharacterKitAlphaStatus, CharacterKitLibrary, CharacterMouthState } from '../../lib/characterKit'
import { useUiTranslation } from '../../i18n'
import { CharacterKitFaceRigPanel } from './CharacterKitFaceRigPanel'
+import { characterKitStillSource } from '../../lib/characterKit'
import {
characterKitNextStep,
characterKitOpeningTab,
@@ -98,7 +99,7 @@ export function CharacterKitLibraryPanel({
onClick={() => onSelectKit(structuredClone(kit), characterKitOpeningTab(kit))}
className={`overflow-hidden rounded border p-1 text-left ${draft?.id === kit.id ? 'border-emerald-300 bg-emerald-400/10' : 'border-border bg-black/10'}`}
>
- {kit.base?.source &&
}
+ {characterKitStillSource(kit) &&
}
{kit.name}
{t('library.bodyCount', { count: characterKitPoseOptions(kit).length })}
diff --git a/ui/src/features/characters/CharacterKitLink.tsx b/ui/src/features/characters/CharacterKitLink.tsx
index 6420ab40f..7bb0ba82e 100644
--- a/ui/src/features/characters/CharacterKitLink.tsx
+++ b/ui/src/features/characters/CharacterKitLink.tsx
@@ -1,33 +1,51 @@
-import { useEffect, useState } from 'react'
import { useStore } from '../../stores/useStore'
import { useUiTranslation } from '../../i18n'
-import { fetchCharacterKitLibrary } from '../../api/characters'
-import type { CharacterKit } from '../../lib/characterKit'
+import { listCharacterKitsFrom, type CharacterKit } from '../../lib/characterKit'
import type { CharacterKitRef } from '../../lib/characterVoice'
+import { useCharacterKitLibrary } from './useCharacterKitLibrary'
/** Stores an id, not a display-name match or a duplicate character definition. */
-export function CharacterKitLink({ value, onChange, workspace: scope, disabled }: {
- value?: CharacterKitRef; onChange: (ref: CharacterKitRef | undefined) => void; workspace?: string; disabled?: boolean
+export function CharacterKitLink({
+ value, onChange, workspace: scope, disabled, requireSpeech3d = false, kits: kitsOverride, error: errorOverride,
+}: {
+ value?: CharacterKitRef
+ onChange: (ref: CharacterKitRef | undefined) => void
+ workspace?: string
+ disabled?: boolean
+ /** 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), workspace = scope ?? active
+ const active = useStore(s => s.activeWorkspace)
+ const workspace = scope ?? active
const { t } = useUiTranslation('scene3dEditor')
- const [state, setState] = useState<{ workspace: string; kits: CharacterKit[]; error?: string }>()
- const kits = state?.workspace === workspace ? state.kits : [], error = state?.workspace === workspace ? state.error : ''
- useEffect(() => {
- let live = true
- void fetchCharacterKitLibrary(workspace).then(result => { if (live) setState({ workspace, kits: Object.values(result.kits).filter(kit => kit.speech3d) }) })
- .catch(reason => { if (live) setState({ workspace, kits: [], error: reason.message }) })
- return () => { live = false }
- }, [workspace])
+ const fetched = useCharacterKitLibrary(workspace, requireSpeech3d, !kitsOverride)
+ const kits = kitsOverride ? listCharacterKitsFrom(kitsOverride, { requireSpeech3d }) : fetched.kits
+ const error = kitsOverride ? errorOverride : fetched.error
const selected = value?.workspace === workspace ? value.id : ''
- return
{t('speech.savedCharacter')}
- onChange(e.target.value ? { id: e.target.value, workspace } : undefined)}>
- {t('speech.noLinkedCharacter')}
- {selected && !kits.some(kit => kit.id === selected) && {selected} · {t('speech.missingCharacter')} }
- {kits.map(kit => {kit.name} )}
-
- {value && value.workspace !== workspace && {t('speech.otherWorkspace', { workspace: value.workspace })} }
- {error && {error} }
-
+ return (
+
+ {t('speech.savedCharacter')}
+ onChange(event.target.value ? { id: event.target.value, workspace } : undefined)}
+ >
+ {t('speech.noLinkedCharacter')}
+ {selected && !kits.some(kit => kit.id === selected) && (
+ {selected} · {t('speech.missingCharacter')}
+ )}
+ {kits.map(kit => (
+
+ {kit.name} · {kit.speech3d ? t('speech.kitKind3d') : t('speech.kitKind2d')}
+
+ ))}
+
+ {value && value.workspace !== workspace && {t('speech.otherWorkspace', { workspace: value.workspace })} }
+ {error && {error} }
+
+ )
}
diff --git a/ui/src/features/characters/CharacterKitSummary.tsx b/ui/src/features/characters/CharacterKitSummary.tsx
new file mode 100644
index 000000000..6de9e1983
--- /dev/null
+++ b/ui/src/features/characters/CharacterKitSummary.tsx
@@ -0,0 +1,31 @@
+import { useUiTranslation } from '../../i18n'
+import { characterKitStillSource, resolvedCharacterTts, type CharacterKit } from '../../lib/characterKit'
+
+/** Read-only look + TTS from the library character. Story acting notes stay on the story row. */
+export function CharacterKitSummary({ kit }: { kit?: CharacterKit }) {
+ const { t } = useUiTranslation('characters')
+ if (!kit) return null
+ const still = characterKitStillSource(kit)
+ const tts = resolvedCharacterTts(kit)
+ return (
+
+ {still ? (
+
+ ) : null}
+
+
{kit.name}
+
+ {tts.source === 'kit'
+ ? t('sheet.ttsVoice', { voiceId: tts.voiceId ?? '' })
+ : t('sheet.ttsNone')}
+
+ {kit.lookNotes?.trim() ?
{kit.lookNotes}
: null}
+
{t('sheet.linkedHint')}
+
+
+ )
+}
diff --git a/ui/src/features/characters/CharacterSpeechPreparation.tsx b/ui/src/features/characters/CharacterSpeechPreparation.tsx
index 7c63e08ef..38167477e 100644
--- a/ui/src/features/characters/CharacterSpeechPreparation.tsx
+++ b/ui/src/features/characters/CharacterSpeechPreparation.tsx
@@ -1,24 +1,33 @@
-import { useState } from 'react'
+import { useEffect, useState, type RefObject } from 'react'
import { LabsLibraryPick } from '../../lib/LabsLibraryPick'
import { useUiTranslation } from '../../i18n'
import { speechPreparationReadiness } from '../../lib/characterSpeechPreparation'
-import type { CharacterKit } from '../../lib/characterKit'
+import type { CharacterKit, CharacterKitLibrary } from '../../lib/characterKit'
import { CharacterKitFaceRigPanel } from './CharacterKitFaceRigPanel'
import { characterKitPoseOptions } from './characterKitGuide'
-import { speechLibraryServices, useCharacterSpeechLibrary, type SpeechLibraryServices } from './useCharacterSpeechLibrary'
+import { speechLibraryServices, useCharacterSpeechLibrary, type SpeechLibraryServices, type SaveSpeechWorkshop } from './useCharacterSpeechLibrary'
-type Props = { workspace: string; services?: SpeechLibraryServices }
+type Props = { workspace: string; services?: SpeechLibraryServices; initialKitId?: string; onSaved?: (library: CharacterKitLibrary) => void | Promise
;
+ saveRef?: RefObject;
+ onDirtyChange?: (dirty: boolean) => void; onBusyChange?: (busy: boolean) => void }
type Controller = ReturnType
const button = 'rounded border border-border px-3 py-2 text-xs text-text-primary disabled:opacity-40'
-export function CharacterSpeechPreparation({ workspace, services = speechLibraryServices }: Props) {
- return
+export function CharacterSpeechPreparation(props: Props) {
+ return
}
-function SpeechWorkspace({ workspace, services = speechLibraryServices }: Props) {
+function SpeechWorkspace({ workspace, services = speechLibraryServices, initialKitId, onSaved, onDirtyChange, onBusyChange, saveRef }: Props) {
const { t } = useUiTranslation('characters')
- const controller = useCharacterSpeechLibrary(workspace, services)
+ const controller = useCharacterSpeechLibrary(workspace, services, initialKitId, onSaved)
const { library, draft, busy, dirty } = controller
+ const [faceBusy, setFaceBusy] = useState(false)
+ useEffect(() => { onDirtyChange?.(dirty) }, [dirty, onDirtyChange])
+ useEffect(() => { onBusyChange?.(busy || faceBusy) }, [busy, faceBusy, onBusyChange])
+ useEffect(() => {
+ if (saveRef) saveRef.current = controller.saveNow
+ return () => { if (saveRef) saveRef.current = null }
+ }, [saveRef, controller.saveNow])
return
{t('speechWorkshop.title')}
@@ -34,17 +43,17 @@ function SpeechWorkspace({ workspace, services = speechLibraryServices }: Props)
{t('speechWorkshop.manualOnly')}
{t('speechWorkshop.character')}
- controller.select(event.target.value)} className="mt-1 w-full rounded border border-border bg-bg-primary p-2">
+ controller.select(event.target.value)} className="mt-1 w-full rounded border border-border bg-bg-primary p-2">
{t('speechWorkshop.choose')}
{Object.values(library?.kits ?? {}).map(kit => {kit.name} )}
{draft && !library?.kits[draft.id] && {draft.name} }
-
- {draft && }
+ {!initialKitId && }
+ {draft && }
- {t('speechWorkshop.save')}
- {t(dirty ? 'speechWorkshop.discardReload' : 'speechWorkshop.reload')}
+ {t('speechWorkshop.save')}
+ {t(dirty ? 'speechWorkshop.discardReload' : 'speechWorkshop.reload')}
{dirty && {t('speechWorkshop.unsaved')}
}
{busy && {t('speechWorkshop.busy')}
}
@@ -70,7 +79,7 @@ function ImportSpeechBase({ workspace, controller }: { workspace: string; contro
}
-function SpeechDraftEditor({ kit: draft, workspace, controller }: { kit: CharacterKit; workspace: string; controller: Controller }) {
+function SpeechDraftEditor({ kit: draft, workspace, controller, faceBusy, onBusyChange }: { faceBusy: boolean; kit: CharacterKit; workspace: string; controller: Controller; onBusyChange: (busy: boolean) => void }) {
const { t } = useUiTranslation('characters')
const { busy } = controller
const [poseId, setPoseId] = useState('base')
@@ -85,18 +94,19 @@ function SpeechDraftEditor({ kit: draft, workspace, controller }: { kit: Charact
}
return <>
- {t('speechWorkshop.pose')} setPoseId(event.target.value)} className="ml-2 rounded border border-border bg-bg-primary p-2">
+ {t('speechWorkshop.pose')} setPoseId(event.target.value)} className="ml-2 rounded border border-border bg-bg-primary p-2">
{poses.map(option => {option.label} )}
- {t('speechWorkshop.approvePose')}
+ {t('speechWorkshop.approvePose')}
{t('speechWorkshop.singlePack')}
+ {t('speechWorkshop.completeSet', { count: readiness.rows.filter(row => row.status === 'approved').length })}
{readiness.rows.map(row => {t(`mouths.${row.state}`)}: {t(`speechWorkshop.states.${row.status}`)} )}
{t(readiness.previewReady ? 'speechWorkshop.previewReady' : 'speechWorkshop.previewNotReady')}
-
-
+
+
>
}
diff --git a/ui/src/features/characters/FaceRigSamplePreview.tsx b/ui/src/features/characters/FaceRigSamplePreview.tsx
new file mode 100644
index 000000000..18284da0f
--- /dev/null
+++ b/ui/src/features/characters/FaceRigSamplePreview.tsx
@@ -0,0 +1,59 @@
+import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'
+import { useUiTranslation } from '../../i18n'
+import type { CharacterKit } from '../../lib/characterKit'
+import type { FaceRigDialogueViseme } from '../../lib/characterKitFaceRig'
+import { sampleMouthCues } from './sampleMouthCues'
+
+/** Ships with the app: no upload, speech job, model download or library write. */
+export function FaceRigSamplePreview({ kit, disabled, onStart, onViseme, stopRef }: {
+ kit: CharacterKit; disabled: boolean; onStart: () => void; onViseme: (viseme?: FaceRigDialogueViseme) => void
+ stopRef: RefObject<(() => void) | null>
+}) {
+ const { t } = useUiTranslation('characters')
+ const audio = useRef
(null), frame = useRef(0), token = useRef(0)
+ const [playing, setPlaying] = useState(false), [error, setError] = useState('')
+ const callbacks = useRef({ onStart, onViseme }); callbacks.current = { onStart, onViseme }
+ const stop = useCallback(() => {
+ token.current++
+ if (frame.current) { cancelAnimationFrame(frame.current); frame.current = 0 }
+ if (audio.current && !audio.current.paused) audio.current.pause()
+ callbacks.current.onViseme(undefined); setPlaying(false)
+ }, [])
+ useEffect(() => {
+ setPlaying(false)
+ stopRef.current = stop
+ return () => {
+ stop(); stopRef.current = null
+ }
+ }, [kit, stopRef, stop])
+ const play = async () => {
+ stop(); callbacks.current.onStart(); setError(''); setPlaying(true)
+ const owner = token.current
+ try {
+ const response = await fetch('/speech-examples/english-preview.json')
+ if (!response.ok) throw new Error(t('faceRig.sampleUnavailable'))
+ const cues = sampleMouthCues(await response.json(), kit)
+ if (owner !== token.current || !audio.current) return
+ audio.current.currentTime = 0
+ await audio.current.play()
+ const tick = () => {
+ if (owner !== token.current || !audio.current) return
+ const time = audio.current.currentTime
+ callbacks.current.onViseme(cues.find(cue => time >= cue.start && time < cue.end))
+ if (!audio.current.ended) frame.current = requestAnimationFrame(tick)
+ else stop()
+ }
+ tick()
+ } catch (cause) { if (owner === token.current) { stop(); setError((cause as Error).message) } }
+ }
+ return
+
asset?.source)}
+ onClick={() => { if (playing) stop(); else void play() }} className="rounded bg-cyan-500 px-4 py-2 text-sm font-semibold text-black disabled:opacity-40">
+ {t(playing ? 'faceRig.stopSample' : 'faceRig.playSample')}
+
+
{t('faceRig.sampleHint')}
+
Hello! This is a quick voice test. Watch my lips move as I speak.
+
+ {error &&
{error}
}
+
+}
diff --git a/ui/src/features/characters/FaceRigStatePicker.tsx b/ui/src/features/characters/FaceRigStatePicker.tsx
new file mode 100644
index 000000000..9c385c53d
--- /dev/null
+++ b/ui/src/features/characters/FaceRigStatePicker.tsx
@@ -0,0 +1,31 @@
+import { useUiTranslation } from '../../i18n'
+import type { CharacterKit } from '../../lib/characterKit'
+import { isFaceRigEyeState, type CharacterKitFaceRigState } from '../../lib/characterKitFaceRig'
+import { CHARACTER_MOUTH_STATES } from '../../lib/characterMouthStates'
+
+export function FaceRigStatePicker({ kit, selected, disabled, onSelect }: {
+ kit: CharacterKit; selected: CharacterKitFaceRigState; disabled: boolean; onSelect: (state: CharacterKitFaceRigState) => void
+}) {
+ const { t } = useUiTranslation('characters')
+ const choice = (state: CharacterKitFaceRigState) => {
+ const asset = state === 'open-eyes' ? kit.eyes.open : state === 'blink' ? kit.eyes.blink : kit.mouth[state]
+ const label = t(`faceRig.states.${state}`)
+ return onSelect(state)}
+ className={`rounded border p-2 text-xs ${selected === state ? 'border-emerald-300 bg-emerald-400/15 text-emerald-100' : 'border-border text-text-muted'}`}>
+ {label}{isFaceRigEyeState(state) && !asset ? t('faceRig.eyesOriginal') : t(`review.${asset?.reviewState ?? 'missing'}`)}
+
+ }
+ return <>
+ {(['closed', 'small', 'wide', 'round'] as const).map(choice)}
+
+ {CHARACTER_MOUTH_STATES.slice(4).map(choice)}
+
+ {
+ if (!event.currentTarget.open && isFaceRigEyeState(selected)) onSelect('wide')
+ }}>
+ {t('faceRig.optionalEyes')}
+ {t('faceRig.optionalEyesHint')}
+ {(['open-eyes', 'blink'] as const).map(choice)}
+
+ >
+}
diff --git a/ui/src/features/characters/ModelCharacterLink.tsx b/ui/src/features/characters/ModelCharacterLink.tsx
index 0843f42a7..e2a90640f 100644
--- a/ui/src/features/characters/ModelCharacterLink.tsx
+++ b/ui/src/features/characters/ModelCharacterLink.tsx
@@ -5,6 +5,6 @@ export function ModelCharacterLink({ layer, workspace, disabled, onChange }: {
layer?: SceneLayer | null; workspace: string; disabled: boolean; onChange: (id: string, ref?: CharacterKitRef) => void
}) {
if (layer?.type !== 'model3d') return null
- return onChange(layer.id, ref)} />
}
diff --git a/ui/src/features/characters/MouthPackChoices.tsx b/ui/src/features/characters/MouthPackChoices.tsx
new file mode 100644
index 000000000..a9b70801c
--- /dev/null
+++ b/ui/src/features/characters/MouthPackChoices.tsx
@@ -0,0 +1,42 @@
+import { useUiTranslation } from '../../i18n'
+import { FACE_RIG_PRESET_ROOT, type FaceRigMouthPresetPack } from '../../lib/characterKitFaceRig'
+import { CHARACTER_MOUTH_STATES } from '../../lib/characterMouthStates'
+import { useState } from 'react'
+import { downloadMouthPacks } from './downloadMouthPacks'
+
+export function MouthPackChoices({ packs, selected, disabled, onSelect, onApply }: {
+ packs: FaceRigMouthPresetPack[]; selected: string; disabled: boolean
+ onSelect: (id: string) => void; onApply: () => void
+}) {
+ const { t } = useUiTranslation('characters')
+ const pack = packs.find(item => item.id === selected)
+ const [downloading, setDownloading] = useState(false), [error, setError] = useState('')
+ const download = async (selectedPacks: FaceRigMouthPresetPack[]) => {
+ setDownloading(true); setError('')
+ try { await downloadMouthPacks(selectedPacks) } catch (cause) { setError((cause as Error).message) }
+ finally { setDownloading(false) }
+ }
+ return
+ {t('faceRig.existingMouths')}
+ {t('faceRig.packHint')}
+ {packs.length ? <>
+ {t('faceRig.mouthPackAria')}
+ onSelect(event.target.value)} className="mt-1 w-full rounded border border-border bg-bg-primary p-2">
+ {packs.map(item => {t('faceRig.packOption', {
+ name: item.label, count: CHARACTER_MOUTH_STATES.filter(state => item.states[state]?.file).length,
+ })} )}
+
+
+ {CHARACTER_MOUTH_STATES.filter(state => pack?.states[state]).map(state =>
+ {pack?.states[state] && }
+ {t(`mouths.${state}`)}
+ )}
+ {t('faceRig.usePack')}
+
+ void download([pack!])} className="underline disabled:opacity-40">{t(downloading ? 'faceRig.downloading' : 'faceRig.downloadPack')}
+ {packs.some(item => item.collection === 'studio-20') && void download(packs.filter(item => item.collection === 'studio-20'))} className="underline disabled:opacity-40">{t('faceRig.downloadCollection')} }
+
+ {error && {error}
}
+ > : {t('faceRig.packsUnavailable')}
}
+
+}
diff --git a/ui/src/features/characters/characterEditorHandoff.ts b/ui/src/features/characters/characterEditorHandoff.ts
new file mode 100644
index 000000000..72bd218d2
--- /dev/null
+++ b/ui/src/features/characters/characterEditorHandoff.ts
@@ -0,0 +1,29 @@
+import { create } from 'zustand'
+import { readSpeechDraft } from '../../lib/characterSpeechDraft'
+import type { CharacterKit } from '../../lib/characterKit'
+import type { CharacterVoice } from '../../lib/characterVoice'
+import type { ApiOutput } from '../../api/client'
+
+export type CharacterDefinitionDraft = { name: string; voice?: CharacterVoice; model?: ApiOutput }
+
+export interface CharacterEditorRequest {
+ workspace: string
+ kit: CharacterKit
+ sourceLabel: string
+ sourceId: string
+ draft?: CharacterDefinitionDraft
+ saved?: boolean
+ onSaved: (kit: CharacterKit) => Promise
+ onReturn: () => Promise
+}
+
+/** Keep the exact subject while moving between studio tabs. Never match by name. */
+export const useCharacterEditorHandoff = create<{ request: CharacterEditorRequest | null }>(() => ({ request: null }))
+
+/** A saved session can yield to another subject even when the user navigated with studio tabs. */
+export function characterEditorHasUnsavedChanges(request: CharacterEditorRequest) {
+ if (!request.saved) return true
+ const draft = request.draft
+ if (draft && (draft.name !== request.kit.name || draft.model || JSON.stringify(draft.voice) !== JSON.stringify(request.kit.voice))) return true
+ return Boolean(readSpeechDraft(request.workspace, request.kit.id))
+}
diff --git a/ui/src/features/characters/characterKitGuide.ts b/ui/src/features/characters/characterKitGuide.ts
index 046d79296..eb357fbd8 100644
--- a/ui/src/features/characters/characterKitGuide.ts
+++ b/ui/src/features/characters/characterKitGuide.ts
@@ -2,6 +2,8 @@ import type { CharacterKit, CharacterKitAsset, CharacterMouthState } from '../..
import { isFacePatchCompatible } from '../../lib/characterFacePatch'
import type { ParseKeys } from 'i18next'
import i18n from '../../i18n'
+import { CHARACTER_MOUTH_STATES } from '../../lib/characterMouthStates'
+import { speechPreparationReadiness } from '../../lib/characterSpeechPreparation'
export type CharacterKitEditorTab = 'kit' | 'face-rig'
@@ -21,7 +23,7 @@ export type CharacterKitNextStep = {
const KNOWN_POSES = ['base', 'pointing', 'reaction'] as const
-const MOUTH_STATES: CharacterMouthState[] = ['closed', 'small', 'wide', 'round']
+const MOUTH_STATES = CHARACTER_MOUTH_STATES
function tCharacters(key: ParseKeys<'characters'>, options?: Record): string {
return i18n.t(key, { ns: 'characters', ...options })
@@ -125,7 +127,7 @@ export function characterKitNextStep(kit: CharacterKit | null, poseId = 'base'):
tab: 'face-rig',
}
}
- if (characterKitApprovedMouths(kit).length < 2) {
+ if (!speechPreparationReadiness(kit, poseId).complete) {
return {
id: 'make-mouths',
title: tCharacters('guide.makeMouths.title'),
diff --git a/ui/src/features/characters/downloadMouthPacks.ts b/ui/src/features/characters/downloadMouthPacks.ts
new file mode 100644
index 000000000..791524e08
--- /dev/null
+++ b/ui/src/features/characters/downloadMouthPacks.ts
@@ -0,0 +1,25 @@
+import { FACE_RIG_PRESET_ROOT, type FaceRigMouthPresetPack } from '../../lib/characterKitFaceRig'
+import { CHARACTER_MOUTH_STATES } from '../../lib/characterMouthStates'
+
+/** Export ordinary PNGs + a manifest; no user character images or voice data. */
+export async function downloadMouthPacks(packs: FaceRigMouthPresetPack[]) {
+ const { default: JSZip } = await import('jszip')
+ const archive = new JSZip()
+ for (const pack of packs) for (const state of CHARACTER_MOUTH_STATES) {
+ const file = pack.states[state]?.file
+ if (!file) continue
+ if (!/^[a-z0-9-]+\/[a-z]+\.png$/.test(file)) throw new Error('Invalid mouth pack asset.')
+ const response = await fetch(`${FACE_RIG_PRESET_ROOT}/${file}`)
+ if (!response.ok) throw new Error('The mouth pack could not be downloaded.')
+ archive.file(file, await response.blob())
+ }
+ archive.file('manifest.json', JSON.stringify({ version: 1, states: CHARACTER_MOUTH_STATES, packs }, null, 2))
+ const readme = await fetch(`${FACE_RIG_PRESET_ROOT}/STUDIO-20.txt`)
+ if (!readme.ok) throw new Error('The mouth pack instructions could not be downloaded.')
+ archive.file('README.txt', await readme.text())
+ const url = URL.createObjectURL(await archive.generateAsync({ type: 'blob', compression: 'DEFLATE' }))
+ const anchor = document.createElement('a')
+ anchor.href = url; anchor.download = packs.length === 1 ? `${packs[0].id}-mouths.zip` : 'hocuspocus-20-mouth-styles.zip'
+ anchor.click()
+ setTimeout(() => URL.revokeObjectURL(url), 60000)
+}
diff --git a/ui/src/features/characters/mouthWipeBox.ts b/ui/src/features/characters/mouthWipeBox.ts
new file mode 100644
index 000000000..d67d5a6bd
--- /dev/null
+++ b/ui/src/features/characters/mouthWipeBox.ts
@@ -0,0 +1,15 @@
+import type { CharacterFaceAnchor } from '../../lib/characterKit'
+import { faceRigAnchorFromRegion, faceRigRegionFromAnchor, type FaceRigMouthRegion } from '../../lib/characterKitFaceRig'
+
+/** Erasure bounds are rectangular; the reusable sprite still retains its natural aspect ratio. */
+export function mouthWipeBox(anchor: CharacterFaceAnchor, aspect = 1): FaceRigMouthRegion {
+ const square = faceRigRegionFromAnchor(anchor)
+ const width = square.width * Math.min(1, aspect)
+ const height = square.height / Math.max(1, aspect)
+ return { x: 50 + anchor.offsetX - width / 2, y: 50 + anchor.offsetY - height / 2, width, height }
+}
+
+export function resizeMouthWipeBox(region: FaceRigMouthRegion, width: number, height: number) {
+ const resized = { ...region, width: Math.max(.5, Math.min(100, width)), height: Math.max(.5, Math.min(100, height)) }
+ return { anchor: faceRigAnchorFromRegion(resized), aspect: resized.width / resized.height }
+}
diff --git a/ui/src/features/characters/sampleMouthCues.ts b/ui/src/features/characters/sampleMouthCues.ts
new file mode 100644
index 000000000..c59e4d38e
--- /dev/null
+++ b/ui/src/features/characters/sampleMouthCues.ts
@@ -0,0 +1,14 @@
+import type { CharacterKit, CharacterMouthState } from '../../lib/characterKit'
+import type { FaceRigDialogueViseme } from '../../lib/characterKitFaceRig'
+import { parseMouthCues } from '../scene3d/speech/track'
+import { PHONETIC_MOUTH_STATE, MOUTH_STATE_FALLBACK } from '../../lib/characterMouthStates'
+
+export function sampleMouthCues(data: unknown, kit: CharacterKit): FaceRigDialogueViseme[] {
+ const fallback = (['wide', 'small', 'round', 'closed'] as const).find(state => kit.mouth[state]?.source)
+ return parseMouthCues(data).map(cue => {
+ const requested = PHONETIC_MOUTH_STATE[cue.viseme]
+ const state: CharacterMouthState = kit.mouth[requested]?.source ? requested : MOUTH_STATE_FALLBACK[requested]
+ const sourceState = kit.mouth[state]?.source ? state : fallback ?? state
+ return { start: cue.start, end: cue.end, state, sourceState, fallback: state !== sourceState }
+ })
+}
diff --git a/ui/src/features/characters/saveCharacterDefinition.ts b/ui/src/features/characters/saveCharacterDefinition.ts
new file mode 100644
index 000000000..b68fcc3b8
--- /dev/null
+++ b/ui/src/features/characters/saveCharacterDefinition.ts
@@ -0,0 +1,32 @@
+import { saveCharacterKit } from '../../api/characters'
+import type { CharacterKit, CharacterKitLibrary } from '../../lib/characterKit'
+import { clearSpeechDraft, readSpeechDraft } from '../../lib/characterSpeechDraft'
+import type { SaveSpeechWorkshop } from './useCharacterSpeechLibrary'
+
+/** One write merges the definition with the mounted workshop or its scoped recovery draft. */
+export async function saveCharacterDefinition(input: {
+ workspace: string; library: CharacterKitLibrary; id: string; kit?: CharacterKit
+ workshop: SaveSpeechWorkshop | null; update: (kit?: CharacterKit) => Promise; isCurrent: () => boolean
+}) {
+ const { workspace, library, id, kit, workshop, update, isCurrent } = input
+ const check = () => { if (!isCurrent()) throw new Error('The character editor changed before saving finished.') }
+ check()
+ if (workshop) {
+ const saved = await workshop(update)
+ check()
+ const savedKit = saved.kits[id]
+ if (!savedKit) throw new Error('The character editor changed before saving finished.')
+ return { saved, kit: savedKit, linked: true }
+ }
+ // Only a real kit id may recover a scoped draft. An empty id is "new
+ // character" and must not alias the general workshop recovery key.
+ const recovery = id ? readSpeechDraft(workspace, id) : null
+ const next = await update(recovery?.kit ?? kit)
+ check()
+ const saved = await saveCharacterKit(workspace, { ...library, revision: recovery?.baseRevision ?? library.revision }, next)
+ check()
+ if (id) clearSpeechDraft(workspace, id)
+ const savedKit = saved.kits[next.id]
+ if (!savedKit) throw new Error('The character editor changed before saving finished.')
+ return { saved, kit: savedKit, linked: false }
+}
diff --git a/ui/src/features/characters/useCharacterKitLibrary.ts b/ui/src/features/characters/useCharacterKitLibrary.ts
new file mode 100644
index 000000000..e4532798f
--- /dev/null
+++ b/ui/src/features/characters/useCharacterKitLibrary.ts
@@ -0,0 +1,25 @@
+import { useCallback, useEffect, useState } from 'react'
+import { fetchCharacterKitLibrary } from '../../api/characters'
+import { listCharacterKits, type CharacterKit } from '../../lib/characterKit'
+
+export function useCharacterKitLibrary(workspace: string, requireSpeech3d = false, enabled = true) {
+ const [state, setState] = useState<{ workspace: string; version: number; kits: CharacterKit[]; error?: string }>()
+ const [version, setVersion] = useState(0)
+ const reload = useCallback(() => setVersion(value => value + 1), [])
+ useEffect(() => {
+ if (!enabled) return
+ let live = true
+ void fetchCharacterKitLibrary(workspace)
+ .then(result => {
+ if (live) setState({ workspace, version, kits: listCharacterKits(result, { requireSpeech3d }) })
+ })
+ .catch(reason => {
+ if (live) setState({ workspace, version, kits: [], error: reason instanceof Error ? reason.message : String(reason) })
+ })
+ return () => { live = false }
+ }, [workspace, requireSpeech3d, enabled, version])
+ const kits = state?.workspace === workspace ? state.kits : []
+ const error = state?.workspace === workspace ? state.error : undefined
+ const loading = enabled && (state?.workspace !== workspace || state.version !== version)
+ return { kits, error, reload, loading }
+}
diff --git a/ui/src/features/characters/useCharacterSpeechLibrary.ts b/ui/src/features/characters/useCharacterSpeechLibrary.ts
index 7414b2af8..b46022889 100644
--- a/ui/src/features/characters/useCharacterSpeechLibrary.ts
+++ b/ui/src/features/characters/useCharacterSpeechLibrary.ts
@@ -6,9 +6,15 @@ import { clearSpeechDraft, readSpeechDraft, writeSpeechDraft } from '../../lib/c
export const speechLibraryServices = { load: fetchCharacterKitLibrary, save: saveCharacterKit, upload: uploadImage }
export type SpeechLibraryServices = typeof speechLibraryServices
+export type SaveSpeechWorkshop = (update?: (kit: CharacterKit) => Promise) => Promise
+
+function speechSaveState(owner: object | null, draft: CharacterKit | null, library: CharacterKitLibrary | null, blocked: boolean, message: string) {
+ if (blocked || !owner || !draft || !library) throw new Error(message)
+ return { owner, snapshot: draft, currentLibrary: library }
+}
/** The owner is keyed by workspace. Late completions never write into a new owner. */
-export function useCharacterSpeechLibrary(workspace: string, services: SpeechLibraryServices) {
+export function useCharacterSpeechLibrary(workspace: string, services: SpeechLibraryServices, initialKitId?: string, onSaved?: (library: CharacterKitLibrary) => void | Promise) {
const { t } = useUiTranslation('characters')
const [library, setLibrary] = useState(null)
const [draft, setDraft] = useState(null)
@@ -25,15 +31,15 @@ export function useCharacterSpeechLibrary(workspace: string, services: SpeechLib
epoch.current = owner
void services.load(workspace).then(result => {
if (epoch.current !== owner) return
- const recovered = readSpeechDraft(workspace)
+ const recovered = readSpeechDraft(workspace, initialKitId)
setLibrary(result)
setBaseRevision(recovered?.baseRevision ?? result.revision)
- setDraft(recovered?.kit ?? result.kits[result.activeId] ?? Object.values(result.kits)[0] ?? null)
+ setDraft(recovered?.kit ?? (initialKitId ? result.kits[initialKitId] : result.kits[result.activeId] ?? Object.values(result.kits)[0]) ?? null)
}).catch(cause => {
if (epoch.current === owner) setError(cause instanceof Error ? cause.message : String(cause))
}).finally(() => { if (epoch.current === owner) setBusy(false) })
return () => { epoch.current = null }
- }, [workspace, services])
+ }, [workspace, services, initialKitId])
useEffect(() => {
if (!dirty) return
@@ -64,19 +70,19 @@ export function useCharacterSpeechLibrary(workspace: string, services: SpeechLib
const remember = (next: CharacterKit, revision: number) => {
setDraft(next)
setBaseRevision(revision)
- try { writeSpeechDraft(workspace, { baseRevision: revision, kit: next }) }
+ try { writeSpeechDraft(workspace, { baseRevision: revision, kit: next }, initialKitId) }
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) }
}
const select = (id: string) => {
- if (busy || dirty || !library) return
- clearSpeechDraft(workspace)
+ if (busy || dirty || !library || initialKitId) return
+ clearSpeechDraft(workspace, initialKitId)
setBaseRevision(library.revision)
setDraft(library.kits[id] ?? null); setStatus(''); setError(null)
}
const importBase = (name: string, file: File) => {
- if (busy || dirty || !library) return
+ if (busy || dirty || !library || initialKitId) return
if (!name.trim() || !['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size <= 0 || file.size > 20 * 1024 * 1024) {
setError(t('speechWorkshop.invalidImport')); return
}
@@ -96,18 +102,27 @@ export function useCharacterSpeechLibrary(workspace: string, services: SpeechLib
})
}
- const save = () => {
- if (busy || !draft || !library || !dirty) return
- const snapshot = draft
- void run(async current => {
- const result = await services.save(workspace, { ...library, revision: baseRevision }, snapshot)
- if (!current()) return
+ const saveNow: SaveSpeechWorkshop = async update => {
+ const { owner, snapshot: currentDraft, currentLibrary } = speechSaveState(epoch.current, draft, library, operation.current || busy, t('speechWorkshop.busy'))
+ operation.current = true; setBusy(true); setError(null)
+ try {
+ const snapshot = update ? await update(currentDraft) : currentDraft
+ if (epoch.current !== owner) throw new Error(t('speechWorkshop.invalidSave'))
+ const result = dirty || update ? await services.save(workspace, { ...currentLibrary, revision: baseRevision }, snapshot) : currentLibrary
+ if (epoch.current !== owner) throw new Error(t('speechWorkshop.invalidSave'))
if (!result.kits[snapshot.id]) throw new Error(t('speechWorkshop.invalidSave'))
- clearSpeechDraft(workspace)
+ clearSpeechDraft(workspace, initialKitId)
setBaseRevision(result.revision)
- setLibrary(result); setDraft(result.kits[snapshot.id]); setStatus(t('speechWorkshop.saved'))
- })
+ setLibrary(result); setDraft(result.kits[snapshot.id])
+ await onSaved?.(result)
+ if (epoch.current === owner) setStatus(t('speechWorkshop.saved'))
+ return result
+ } catch (cause) {
+ if (epoch.current === owner) setError(cause instanceof Error ? cause.message : String(cause))
+ throw cause
+ } finally { operation.current = false; if (epoch.current === owner) setBusy(false) }
}
+ const save = () => { void saveNow().catch(() => undefined) }
// Explicit discard is the only way to reload over local edits after a 409.
const reload = () => {
@@ -115,12 +130,12 @@ export function useCharacterSpeechLibrary(workspace: string, services: SpeechLib
void run(async current => {
const result = await services.load(workspace)
if (!current()) return
- clearSpeechDraft(workspace)
+ clearSpeechDraft(workspace, initialKitId)
setBaseRevision(result.revision)
setLibrary(result)
setDraft(result.kits[draft?.id ?? result.activeId] ?? null)
})
}
- return { library, draft, busy, dirty, error, status, setStatus, change, select, importBase, save, reload }
+ return { library, draft, busy, dirty, error, status, setStatus, change, select, importBase, save, saveNow, reload }
}
diff --git a/ui/src/features/cutPaper/bible.ts b/ui/src/features/cutPaper/bible.ts
new file mode 100644
index 000000000..6b78d0522
--- /dev/null
+++ b/ui/src/features/cutPaper/bible.ts
@@ -0,0 +1,124 @@
+/** Original cut-paper series kit. Not a clone of any existing show. */
+
+export const CUT_PAPER_KIT_ID = 'tijeral-cut-paper'
+export const CUT_PAPER_PUBLIC_ROOT = '/examples/cut-paper'
+
+export const CUT_PAPER_VISEMES = ['closed', 'small', 'wide', 'round'] as const
+export type CutPaperViseme = (typeof CUT_PAPER_VISEMES)[number]
+
+export const CUT_PAPER_EXPRESSIONS = ['neutral', 'happy', 'worried'] as const
+export type CutPaperExpression = (typeof CUT_PAPER_EXPRESSIONS)[number]
+
+export const CUT_PAPER_PIECES = ['legs', 'torso', 'arm-back', 'arm-front', 'head', 'face', 'hat'] as const
+export type CutPaperPiece = (typeof CUT_PAPER_PIECES)[number]
+
+export type CutPaperVoice = {
+ id: string
+ pitch: 'grave' | 'nasal' | 'acute' | 'ronca' | 'seca' | 'calida'
+ notes: string
+}
+
+export type CutPaperCharacter = {
+ id: string
+ name: string
+ role: 'kid' | 'adult'
+ silhouette: string
+ palette: string[]
+ voice: CutPaperVoice
+ hat: string
+ notes: string
+}
+
+export type CutPaperLocation = {
+ id: string
+ name: string
+ shot: string
+}
+
+export const CUT_PAPER_TOWN = {
+ id: 'tijeral',
+ name: 'Tijeral',
+ range: 'Sierra Cartulina',
+ summary: 'A highland paper village of scalloped tile roofs and grey-blue cardstock streets. Winter happens; the architecture is not a North-American suburb.',
+}
+
+export const CUT_PAPER_CAST: CutPaperCharacter[] = [
+ {
+ id: 'nilo', name: 'Nilo Carda', role: 'kid',
+ silhouette: 'very tall thin rectangle',
+ palette: ['#1d2b5a', '#d8c7a2', '#2a2a2a'],
+ voice: { id: 'nilo-grave', pitch: 'grave', notes: 'Slow, formal, slightly too precise.' },
+ hat: 'none — stacked black paper strips for hair, round paper-clip glasses',
+ notes: 'Inventor of glue. Navy dungarees. Never an orange coat.',
+ },
+ {
+ id: 'berta', name: 'Berta Miga', role: 'kid',
+ silhouette: 'wide short pentagon',
+ palette: ['#d4a017', '#5a3a1c', '#f3e6c4'],
+ voice: { id: 'berta-nasal', pitch: 'nasal', notes: 'Fast, hungry, always tasting things.' },
+ hat: 'two paper buns, no beanie',
+ notes: 'Mustard slicker, punched-dot freckles. Mustard is not orange.',
+ },
+ {
+ id: 'kito', name: 'Kito Veleta', role: 'kid',
+ silhouette: 'tiny triangle body under a huge paper-boat hat',
+ palette: ['#1f6f6a', '#f2ead4', '#c45c2a'],
+ voice: { id: 'kito-acute', pitch: 'acute', notes: 'Short lines, high, always arriving.' },
+ hat: 'folded paper boat, cream and teal stripes',
+ notes: 'Boat hat, not a pom-pom beanie. Teal-cream, not orange-cyan.',
+ },
+ {
+ id: 'rami', name: 'Rami Tambo', role: 'kid',
+ silhouette: 'square block with a drum',
+ palette: ['#4a5c28', '#6b4423', '#c4a574'],
+ voice: { id: 'rami-ronca', pitch: 'ronca', notes: 'Few words, dry, a drum hit is a sentence.' },
+ hat: 'none — torn brown paper hair',
+ notes: 'Olive patched vest, cardboard drum on a strap.',
+ },
+ {
+ id: 'paca', name: 'Doña Paca', role: 'adult',
+ silhouette: 'very tall trapezoid',
+ palette: ['#3a3a3a', '#8a8a8a', '#1d2b5a'],
+ voice: { id: 'paca-seca', pitch: 'seca', notes: 'Dry adult alto. School caretaker.' },
+ hat: 'grey paper-roll bun',
+ notes: 'Charcoal apron. Not a parent sitcom type.',
+ },
+ {
+ id: 'lino', name: 'Lino Horna', role: 'adult',
+ silhouette: 'wide oval baker',
+ palette: ['#8b3a1e', '#f4efe4', '#d8c7a2'],
+ voice: { id: 'lino-calida', pitch: 'calida', notes: 'Warm mid-baritone. Speaks with flour in the air.' },
+ hat: 'none — flour as torn white flakes',
+ notes: 'Rust-red cardstock apron.',
+ },
+]
+
+export const CUT_PAPER_LOCATIONS: CutPaperLocation[] = [
+ { id: 'plaza', name: 'Plaza nevada', shot: 'Front, slightly high. Fountain of stacked paper discs, scalloped tile roofs, torn-paper snow. Not a bus stop.' },
+ { id: 'porche', name: 'Porche de la panadería', shot: '3/4 very flat. Rust awning, flour dust, a bench of corrugated card.' },
+ { id: 'aula', name: 'Aula del cole', shot: 'Front. Paper desks, a tin-bell silhouette in the window, charcoal blackboard.' },
+ { id: 'cocina', name: 'Cocina de Doña Paca', shot: 'Front. Shelf of jars, navy stove, cream wall of construction paper.' },
+ { id: 'sierra', name: 'Sierra al fondo', shot: 'Wide. Folded-paper mountains, tiny village stamp, late-afternoon mustard light.' },
+]
+
+export const CUT_PAPER_FORBIDDEN = [
+ 'iconic orange parka',
+ 'orange-and-turquoise pom-pom beanie',
+ 'four kids waiting at a suburban snowy bus stop as the identity of the show',
+ 'cloned actor voices',
+ 'round painted portrait in a circle for a face',
+ 'tv-head-humanoid.glb as a body',
+] as const
+
+export function cutPaperAssetUrl(kind: 'cardboard' | 'location' | 'piece' | 'mouth' | 'brow', id: string, extra = ''): string {
+ const suffix = extra ? `-${extra}` : ''
+ if (kind === 'cardboard') return `${CUT_PAPER_PUBLIC_ROOT}/cardboard.png`
+ if (kind === 'location') return `${CUT_PAPER_PUBLIC_ROOT}/locations/${id}.png`
+ if (kind === 'piece') return `${CUT_PAPER_PUBLIC_ROOT}/puppets/${id}${suffix}.png`
+ if (kind === 'mouth') return `${CUT_PAPER_PUBLIC_ROOT}/mouths/${id}-${extra}.png`
+ return `${CUT_PAPER_PUBLIC_ROOT}/brows/${id}-${extra}.png`
+}
+
+export function cutPaperCharacter(id: string): CutPaperCharacter | undefined {
+ return CUT_PAPER_CAST.find(item => item.id === id)
+}
diff --git a/ui/src/features/cutPaper/characterKits.ts b/ui/src/features/cutPaper/characterKits.ts
new file mode 100644
index 000000000..2ca2699fb
--- /dev/null
+++ b/ui/src/features/cutPaper/characterKits.ts
@@ -0,0 +1,83 @@
+import { fetchCharacterKitLibrary, saveCharacterKit } from '../../api/characters'
+import {
+ createCharacterKit,
+ type CharacterKit,
+ type CharacterKitAsset,
+} from '../../lib/characterKit'
+import type { CharacterVoice } from '../../lib/characterVoice'
+import { rememberCharacterKitLibrary } from '../characters/session'
+import { CUT_PAPER_CAST, CUT_PAPER_PUBLIC_ROOT, CUT_PAPER_VISEMES } from './bible.ts'
+import { CUT_PAPER_MOUTH_ANCHOR } from './puppet.ts'
+
+export const TIJERAL_CHARACTER_PREFIX = 'tijeral-'
+
+/** Distinct Qwen3 CustomVoice presets; not cloned actors. */
+export const CUT_PAPER_TTS: Record = {
+ nilo: { provider: 'local', model: 'qwen3_tts_customvoice', voiceId: 'dylan', instructions: 'Slow, formal, slightly too precise. Speak the written language (Spanish or English).' },
+ berta: { provider: 'local', model: 'qwen3_tts_customvoice', voiceId: 'serena', instructions: 'Fast, hungry, always tasting things. Nasal kid. Speak the written language (Spanish or English).' },
+ kito: { provider: 'local', model: 'qwen3_tts_customvoice', voiceId: 'sohee', instructions: 'Short lines, high, always arriving. Excited kid. Speak the written language (Spanish or English).' },
+ rami: { provider: 'local', model: 'qwen3_tts_customvoice', voiceId: 'ryan', instructions: 'Few words, dry, a drum hit is a sentence. Speak the written language (Spanish or English).' },
+ paca: { provider: 'local', model: 'qwen3_tts_customvoice', voiceId: 'vivian', instructions: 'Dry adult alto. School caretaker. Speak the written language (Spanish or English).' },
+ lino: { provider: 'local', model: 'qwen3_tts_customvoice', voiceId: 'eric', instructions: 'Warm mid-baritone. Speaks with flour in the air. Speak the written language (Spanish or English).' },
+}
+
+const SPEAKING = new Set(['nilo', 'berta', 'kito'])
+
+function imageAsset(id: string, name: string, source: string, kind: CharacterKitAsset['kind'] = 'image'): CharacterKitAsset {
+ return {
+ id, name, source, kind,
+ alphaStatus: source.endsWith('.png') ? 'transparent' : 'opaque',
+ reviewState: 'approved',
+ }
+}
+
+export function tijeralCharacterKitId(characterId: string): string {
+ return `${TIJERAL_CHARACTER_PREFIX}${characterId}`
+}
+
+export function createTijeralCharacterKits(): CharacterKit[] {
+ const now = '2026-09-12T00:00:00.000Z'
+ return CUT_PAPER_CAST.map(character => {
+ const id = tijeralCharacterKitId(character.id)
+ const voice = CUT_PAPER_TTS[character.id]
+ const kit: CharacterKit = {
+ ...createCharacterKit(character.name, 'cutout', []),
+ id,
+ name: character.name,
+ lookNotes: `${character.silhouette}. ${character.hat}. ${character.notes}`,
+ voice,
+ provenance: [
+ {
+ method: 'tijeral-cut-paper',
+ characterId: character.id,
+ ...(SPEAKING.has(character.id) ? { voiceSample: `${CUT_PAPER_PUBLIC_ROOT}/voices/vo-${character.id}-${character.id}-1.wav` } : {}),
+ },
+ ],
+ createdAt: now,
+ updatedAt: now,
+ }
+ if (!SPEAKING.has(character.id)) return kit
+ const anchor = CUT_PAPER_MOUTH_ANCHOR[character.id]
+ kit.identityReference = imageAsset(`${id}-identity`, `${character.name} still`, `${CUT_PAPER_PUBLIC_ROOT}/puppets/${character.id}-canonical.jpg`)
+ kit.base = imageAsset(`${id}-body`, `${character.name} body`, `${CUT_PAPER_PUBLIC_ROOT}/puppets/${character.id}-body.png`)
+ kit.mouth = Object.fromEntries(CUT_PAPER_VISEMES.map(state => [
+ state,
+ imageAsset(`${id}-mouth-${state}`, `${character.name} ${state}`, `${CUT_PAPER_PUBLIC_ROOT}/mouths/paper-${state}.png`, 'overlay'),
+ ]))
+ if (anchor) kit.anchors = { base: { mouth: { offsetX: anchor.offsetX, offsetY: anchor.offsetY, scale: anchor.scale, rotation: 0 } } }
+ return kit
+ })
+}
+
+/** Insert bundled Tijeral kits into the workspace library. Never overwrite a kit the user already saved. */
+export async function seedTijeralCharacterKits(workspace: string): Promise {
+ let library = await fetchCharacterKitLibrary(workspace)
+ let inserted = 0
+ for (const kit of createTijeralCharacterKits()) {
+ if (library.kits[kit.id]) continue
+ library = await saveCharacterKit(workspace, library, kit)
+ inserted += 1
+ }
+ rememberCharacterKitLibrary(library)
+ return inserted
+}
diff --git a/ui/src/features/cutPaper/index.ts b/ui/src/features/cutPaper/index.ts
new file mode 100644
index 000000000..50c1fc0eb
--- /dev/null
+++ b/ui/src/features/cutPaper/index.ts
@@ -0,0 +1,27 @@
+export {
+ CUT_PAPER_CAST,
+ CUT_PAPER_FORBIDDEN,
+ CUT_PAPER_KIT_ID,
+ CUT_PAPER_LOCATIONS,
+ CUT_PAPER_PIECES,
+ CUT_PAPER_PUBLIC_ROOT,
+ CUT_PAPER_TOWN,
+ CUT_PAPER_VISEMES,
+ cutPaperAssetUrl,
+ cutPaperCharacter,
+} from './bible.ts'
+export { compileCutPaperPilotScene, compileCutPaperShot, CUT_PAPER_PILOT_DURATION, CUT_PAPER_PILOT_SCRIPT, CUT_PAPER_PILOT_SCRIPT_EN } from './pilot.ts'
+export { CUT_PAPER_VOICE_ALIGN, CUT_PAPER_VOICE_ALIGN_EN, cutPaperDialogueBeats, cutPaperVoiceFilename } from './voiceAlign.ts'
+export { createTijeralCharacterKits, seedTijeralCharacterKits, tijeralCharacterKitId, CUT_PAPER_TTS } from './characterKits.ts'
+export { createTijeralStoryProject, TIJERAL_STORY_ID } from './storyProject.ts'
+export {
+ applyPuppetSpeech,
+ assertCutPaperKitHasNoPrivateGlb,
+ CUT_PAPER_MOUTH_ANCHOR,
+ cutPaperCamera,
+ cutPaperKitManifest,
+ cutPaperLocationLayer,
+ cutPaperPuppetLayers,
+ emptyCutPaperScene,
+ slidePuppet,
+} from './puppet.ts'
diff --git a/ui/src/features/cutPaper/pilot.ts b/ui/src/features/cutPaper/pilot.ts
new file mode 100644
index 000000000..4c3c7256a
--- /dev/null
+++ b/ui/src/features/cutPaper/pilot.ts
@@ -0,0 +1,174 @@
+import { rebuildCutoutDialogueLayers } from '../../lib/cutoutDialogue'
+import type { Scene, SceneKeyframe, SceneLayer } from '../../types'
+import {
+ assertCutPaperKitHasNoPrivateGlb,
+ cutPaperCamera,
+ cutPaperLocationLayer,
+ cutPaperPuppetLayers,
+ emptyCutPaperScene,
+ slidePuppet,
+} from './puppet.ts'
+import { cutPaperDialogueBeats, cutPaperVoiceFilename, type CutPaperLocale } from './voiceAlign.ts'
+
+export const CUT_PAPER_PILOT_DURATION = 78
+
+export const CUT_PAPER_PILOT_SCRIPT = [
+ { id: 'nilo-1', speaker: 'nilo', start: 6, end: 16, text: 'La fuente no está congelada. Alguien le pegó un cuadrado de papel cebolla.' },
+ { id: 'berta-1', speaker: 'berta', start: 17, end: 24, text: 'Pues sabe a hielo. Lo probé.' },
+ { id: 'nilo-2', speaker: 'nilo', start: 25, end: 30, text: 'Berta, eso es cola.' },
+ { id: 'berta-2', speaker: 'berta', start: 31, end: 38, text: 'Cola fría. Como hielo.' },
+ { id: 'kito-1', speaker: 'kito', start: 62, end: 68, text: '¡Era un sticker!' },
+] as const
+
+export const CUT_PAPER_PILOT_SCRIPT_EN = [
+ { id: 'nilo-1', speaker: 'nilo', start: 6, end: 16, text: 'The fountain is not frozen. Someone stuck a square of tracing paper on it.' },
+ { id: 'berta-1', speaker: 'berta', start: 17, end: 24, text: 'Well it tastes like ice. I tried it.' },
+ { id: 'nilo-2', speaker: 'nilo', start: 25, end: 30, text: "Berta, that's glue." },
+ { id: 'berta-2', speaker: 'berta', start: 31, end: 38, text: 'Cold glue. Like ice.' },
+ { id: 'kito-1', speaker: 'kito', start: 62, end: 68, text: 'It was a sticker!' },
+] as const
+
+/** 78 s, three shots: plaza, talk, paper-sled gag. Dialogue first, then mouths, then slides. */
+export function compileCutPaperPilotScene(locale: CutPaperLocale = 'es'): Scene {
+ const script = locale === 'en' ? CUT_PAPER_PILOT_SCRIPT_EN : CUT_PAPER_PILOT_SCRIPT
+ const duration = CUT_PAPER_PILOT_DURATION
+ const scene = emptyCutPaperScene(locale === 'en' ? 'Tijeral · the fountain' : 'Tijeral · la fuente', duration)
+ scene.layers = [
+ cutPaperCamera(duration),
+ cutPaperLocationLayer('plaza', duration),
+ ...cutPaperPuppetLayers({ characterId: 'nilo', x: 36, y: 62, scale: 1, z0: 20 }, duration),
+ ...cutPaperPuppetLayers({ characterId: 'berta', x: 62, y: 64, scale: 0.95, z0: 30 }, duration),
+ ...cutPaperPuppetLayers({ characterId: 'kito', x: 118, y: 70, scale: 0.7, z0: 40 }, duration),
+ ]
+ scene.layers.push({
+ id: 'sticker-ice', name: 'Papel cebolla', type: 'image',
+ source: '/examples/cut-paper/props/onion-paper.png',
+ visible: true, locked: false, z: 8,
+ transform: { x: 50, y: 58, scale: 0.22, opacity: 1, rotation: -6 },
+ animation: {
+ start: { x: 50, y: 58, scale: 0.22, opacity: 1, rotation: -6 },
+ end: { x: 78, y: 82, scale: 0.18, opacity: 0, rotation: 18 },
+ duration, curve: 'ease',
+ keyframes: [
+ { id: 'ice-0', time: 0, x: 50, y: 58, scale: 0.22, opacity: 1, rotation: -6, curve: 'hold' },
+ { id: 'ice-1', time: 54, x: 50, y: 58, scale: 0.22, opacity: 1, rotation: -6, curve: 'ease' },
+ { id: 'ice-2', time: 61, x: 78, y: 82, scale: 0.18, opacity: 0, rotation: 18, curve: 'ease' },
+ { id: 'ice-3', time: duration, x: 78, y: 82, scale: 0.18, opacity: 0, rotation: 18, curve: 'hold' },
+ ],
+ },
+ parallax: 0.4,
+ })
+ scene.layers = slidePuppet(scene.layers, 'kito', { x: 118, y: 70 }, { x: 52, y: 70 }, 54, 61)
+ scene.dialogueBeats = script.flatMap(line => cutPaperDialogueBeats(line, line.start, locale))
+ scene.layers = rebuildCutoutDialogueLayers(scene.layers, scene.dialogueBeats ?? [], 30, duration)
+ scene.texts = [
+ { id: 'title', text: 'Tijeral', start: 0.4, end: 3.6, preset: 'rise', x: 50, y: 12, size: 7, color: '#1d2b5a', rotation: 0 },
+ ]
+ scene.audioTracks = script.map(line => ({
+ id: `vo-${line.id}`, filename: cutPaperVoiceFilename(line.speaker, line.id, locale),
+ name: `${line.speaker} · ${line.text.slice(0, 24)}`, kind: 'speech' as const,
+ startTime: line.start, volume: 1,
+ }))
+ assertCutPaperKitHasNoPrivateGlb(scene)
+ return scene
+}
+
+export type CutPaperShotId = 'plaza' | 'talk' | 'sticker'
+
+/**
+ * Scene Animator import expands a layer (and then the scene) to the last
+ * keyframe time. A shot sliced from the 78 s pilot must not keep t=78
+ * holds or the later ice-peel, or opening the beat becomes a 78 s timeline.
+ */
+function clampLayerToShotDuration(layer: SceneLayer, duration: number): SceneLayer {
+ const frames = [...(layer.animation.keyframes ?? [])].sort((left, right) => left.time - right.time)
+ if (!frames.length) {
+ return { ...layer, animation: { ...layer.animation, duration } }
+ }
+ const epsilon = 1e-9
+ const kept = frames.filter(frame => frame.time <= duration + epsilon)
+ let keyframes: SceneKeyframe[]
+ if (frames.length === 2 && frames[0].time <= epsilon && frames[1].time > duration + epsilon) {
+ keyframes = [
+ { ...frames[0], time: 0 },
+ { ...frames[1], id: `${layer.id}-${Math.round(duration * 1000)}`, time: duration },
+ ]
+ } else {
+ keyframes = kept.length ? kept : [{ ...frames[0], time: 0 }]
+ const lastKept = keyframes[keyframes.length - 1]
+ if (lastKept.time < duration - epsilon) {
+ keyframes = [...keyframes, { ...lastKept, id: `${layer.id}-${Math.round(duration * 1000)}`, time: duration }]
+ }
+ }
+ const first = keyframes[0]
+ const last = keyframes[keyframes.length - 1]
+ return {
+ ...layer,
+ animation: {
+ ...layer.animation,
+ duration,
+ start: { x: first.x, y: first.y, scale: first.scale, opacity: first.opacity, rotation: first.rotation },
+ end: { x: last.x, y: last.y, scale: last.scale, opacity: last.opacity, rotation: last.rotation },
+ keyframes,
+ },
+ }
+}
+
+/** One Video 2D clip per Story Lab beat. Same kit, shorter timeline. */
+export function compileCutPaperShot(shot: CutPaperShotId, locale: CutPaperLocale = 'es'): Scene {
+ const full = compileCutPaperPilotScene(locale)
+ const script = locale === 'en' ? CUT_PAPER_PILOT_SCRIPT_EN : CUT_PAPER_PILOT_SCRIPT
+ if (shot === 'plaza') {
+ const duration = 6
+ const layers = full.layers.filter(layer => layer.type === 'camera' || layer.id === 'location-plaza' || layer.id === 'sticker-ice')
+ .map(layer => clampLayerToShotDuration(layer, duration))
+ const scene = { ...full, name: locale === 'en' ? 'Tijeral · shot 1 plaza' : 'Tijeral · plano 1 plaza', duration, layers, dialogueBeats: [], audioTracks: [], texts: full.texts }
+ assertCutPaperKitHasNoPrivateGlb(scene)
+ return scene
+ }
+ if (shot === 'talk') {
+ const duration = 40
+ const layers = full.layers.filter(layer =>
+ layer.type === 'camera' || layer.id === 'location-plaza' || layer.id === 'sticker-ice'
+ || layer.id.startsWith('puppet-nilo') || layer.id.startsWith('puppet-berta'))
+ .map(layer => clampLayerToShotDuration(layer, duration))
+ const scene = {
+ ...full, name: locale === 'en' ? 'Tijeral · shot 2 cold glue' : 'Tijeral · plano 2 cola fría', duration, layers,
+ dialogueBeats: (full.dialogueBeats ?? []).filter(beat => beat.start < 40),
+ audioTracks: (full.audioTracks ?? []).filter(track => track.startTime < 40),
+ texts: [],
+ }
+ assertCutPaperKitHasNoPrivateGlb(scene)
+ return scene
+ }
+ const duration = 26
+ const scene = emptyCutPaperScene(locale === 'en' ? 'Tijeral · shot 3 sticker' : 'Tijeral · plano 3 sticker', duration)
+ scene.layers = [
+ cutPaperCamera(duration),
+ cutPaperLocationLayer('plaza', duration),
+ ...cutPaperPuppetLayers({ characterId: 'nilo', x: 36, y: 62, scale: 1, z0: 20 }, duration),
+ ...cutPaperPuppetLayers({ characterId: 'berta', x: 62, y: 64, scale: 0.95, z0: 30 }, duration),
+ ...cutPaperPuppetLayers({ characterId: 'kito', x: 118, y: 70, scale: 0.7, z0: 40 }, duration),
+ full.layers.find(layer => layer.id === 'sticker-ice')!,
+ ]
+ const ice = scene.layers.find(layer => layer.id === 'sticker-ice')
+ if (ice) {
+ ice.animation = {
+ ...ice.animation, duration,
+ keyframes: [
+ { id: 'ice-0', time: 0, x: 50, y: 58, scale: 0.22, opacity: 1, rotation: -6, curve: 'hold' },
+ { id: 'ice-1', time: 2, x: 50, y: 58, scale: 0.22, opacity: 1, rotation: -6, curve: 'ease' },
+ { id: 'ice-2', time: 9, x: 78, y: 82, scale: 0.18, opacity: 0, rotation: 18, curve: 'ease' },
+ { id: 'ice-3', time: duration, x: 78, y: 82, scale: 0.18, opacity: 0, rotation: 18, curve: 'hold' },
+ ],
+ }
+ }
+ scene.layers = slidePuppet(scene.layers, 'kito', { x: 118, y: 70 }, { x: 52, y: 70 }, 2, 9)
+ const kito = script.find(line => line.id === 'kito-1')!
+ scene.dialogueBeats = cutPaperDialogueBeats(kito, 10, locale)
+ scene.layers = rebuildCutoutDialogueLayers(scene.layers, scene.dialogueBeats ?? [], 30, duration)
+ .map(layer => clampLayerToShotDuration(layer, duration))
+ scene.audioTracks = [{ id: 'vo-kito-1', filename: cutPaperVoiceFilename('kito', 'kito-1', locale), name: 'kito · sticker', kind: 'speech', startTime: 10, volume: 1 }]
+ assertCutPaperKitHasNoPrivateGlb(scene)
+ return scene
+}
diff --git a/ui/src/features/cutPaper/puppet.ts b/ui/src/features/cutPaper/puppet.ts
new file mode 100644
index 000000000..ccc74f334
--- /dev/null
+++ b/ui/src/features/cutPaper/puppet.ts
@@ -0,0 +1,191 @@
+import type { Scene, SceneKeyframe, SceneLayer } from '../../types'
+import { applyCutoutDialogue, planCutoutDialogue } from '../../lib/cutoutDialogue'
+import {
+ CUT_PAPER_CAST,
+ CUT_PAPER_KIT_ID,
+ CUT_PAPER_PIECES,
+ CUT_PAPER_PUBLIC_ROOT,
+ CUT_PAPER_VISEMES,
+ cutPaperAssetUrl,
+ type CutPaperExpression,
+ type CutPaperViseme,
+} from './bible.ts'
+
+/** One transparent body + four paper-cut mouth cards. Never stack opaque copies. */
+const BODY_SCALE: Record