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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 25 additions & 14 deletions ui/src/components/Sidebar/SceneAnimatorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ import { sceneToRecipe } from '../../lib/sceneToRecipe'
import { parseSceneFile, sceneFileName, serializeSceneFile } from '../../lib/sceneFile'
import { SceneLibraryDialog } from './SceneLibraryDialog'
import { PENDING_SCENE_KEY } from '../../lib/sceneOutput'
import { normalizeSceneLookupName, sceneFromLibraryPayload, sceneLibraryTitle, sceneOutputMatchesName } from '../../lib/sceneLibrary'
import { loadAgentLibraryScene } from '../../lib/agentSceneOpen'
import { normalizeSceneLookupName } from '../../lib/sceneLibrary'
import { assessNarrativeAsset } from '../../lib/assetSuitability'
import { getSceneClipTime } from '../../lib/sceneClip'
import { sanitizeSceneMotion } from '../../lib/sceneMotion'
Expand Down Expand Up @@ -2687,24 +2688,34 @@ export function SceneAnimatorPanel() {
}
}

const library = await fetchOutputs(0, 0, { mediaType: 'scene', workspace })
const matches = library.outputs.filter(file => sceneOutputMatchesName(file, request.sceneName))
if (!matches.length) {
const available = library.outputs.slice(0, 8).map(file => `“${sceneLibraryTitle(file.name)}”`).join(', ')
throw new Error(available
? t('agent.sceneMissingAvailable', { name: request.sceneName, available })
: t('agent.sceneMissing', { name: request.sceneName }))
}
if (matches.length > 1) throw new Error(t('agent.sceneAmbiguous', { name: request.sceneName }))
const response = await fetch(matches[0].url)
if (!response.ok) throw new Error(t('agent.sceneLoadFailed', { name: request.sceneName }))
const next = sceneFromLibraryPayload(await response.json()) as AnimatorScene
const source = {
epoch: galleryWorkspaceEpoch(),
workspace: galleryWorkspaceName(useStore.getState()),
generation: generationRef.current,
}
const stillCurrent = () => source.epoch === galleryWorkspaceEpoch()
&& source.workspace === galleryWorkspaceName(useStore.getState())
&& source.generation === generationRef.current
const loaded = await loadAgentLibraryScene(request.sceneName, source.workspace, stillCurrent)
if (!loaded.ok) {
if (loaded.reason === 'stale') throw new Error(t('agent.workspaceChangedOpen'))
if (loaded.reason === 'missing') {
const available = loaded.availableTitles.map(name => `“${name}”`).join(', ')
throw new Error(available
? t('agent.sceneMissingAvailable', { name: request.sceneName, available })
: t('agent.sceneMissing', { name: request.sceneName }))
}
if (loaded.reason === 'ambiguous') throw new Error(t('agent.sceneAmbiguous', { name: request.sceneName }))
throw new Error(t('agent.sceneLoadFailed', { name: request.sceneName }))
}
const next = loaded.scene as AnimatorScene
const layerMatches = request.layerName
? next.layers.filter(layer => normalizeSceneLookupName(layer.name) === normalizeSceneLookupName(request.layerName))
: []
if (layerMatches.length > 1) throw new Error(t('agent.layersAmbiguousWizard', { name: request.layerName }))
if (request.layerName && !layerMatches.length) throw new Error(t('agent.layerMissingInScene', { scene: next.name, name: request.layerName }))
if (!importSceneRef.current(JSON.stringify(next), t('animator.openedLabel', { label: sceneLibraryTitle(matches[0].name) }))) {
if (!stillCurrent()) throw new Error(t('agent.workspaceChangedOpen'))
if (!importSceneRef.current(JSON.stringify(next), t('animator.openedLabel', { label: loaded.label }))) {
throw new Error(t('agent.sceneOpenFailed', { name: request.sceneName }))
}
const target = layerMatches[0] ?? next.layers[0]
Expand Down
1 change: 1 addition & 0 deletions ui/src/i18n/locales/en/scene3d.json
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,7 @@
"sceneMissing": "There is no saved scene named “{{name}}” in this workspace.",
"sceneMissingAvailable": "There is no saved scene named “{{name}}” in this workspace. Available: {{available}}.",
"sceneAmbiguous": "Several saved scenes are named “{{name}}”; give the full filename.",
"workspaceChangedOpen": "Workspace changed while opening the scene. Open it again in this folder.",
"sceneLoadFailed": "Could not load saved scene “{{name}}”.",
"sceneOpenFailed": "Saved scene “{{name}}” could not be opened in Video 3D.",
"openedWithLayer": "Opened “{{scene}}” and selected layer “{{layer}}”.",
Expand Down
1 change: 1 addition & 0 deletions ui/src/i18n/locales/es/scene3d.json
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,7 @@
"sceneMissing": "No existe una escena guardada llamada “{{name}}” en este workspace.",
"sceneMissingAvailable": "No existe una escena guardada llamada “{{name}}” en este workspace. Disponibles: {{available}}.",
"sceneAmbiguous": "Hay varias escenas guardadas llamadas “{{name}}”; indica el nombre completo del archivo.",
"workspaceChangedOpen": "El espacio de trabajo cambió al abrir la escena. Ábrela de nuevo en esta carpeta.",
"sceneLoadFailed": "No se pudo cargar la escena guardada “{{name}}”.",
"sceneOpenFailed": "La escena guardada “{{name}}” no se pudo abrir en Vídeo 3D.",
"openedWithLayer": "He abierto “{{scene}}” y seleccionado la capa “{{layer}}”.",
Expand Down
36 changes: 36 additions & 0 deletions ui/src/lib/agentSceneOpen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { fetchOutputs, type ApiOutput } from '../api/client'
import type { Scene } from '../types'
import { sceneFromLibraryPayload, sceneLibraryTitle, sceneOutputMatchesName } from './sceneLibrary'

export type AgentLibrarySceneLoad =
| { ok: true; scene: Scene; file: ApiOutput; label: string }
| { ok: false; reason: 'stale' }
| { ok: false; reason: 'missing'; availableTitles: string[] }
| { ok: false; reason: 'ambiguous' }
| { ok: false; reason: 'load-failed' }

/** Resolve a saved compositor scene, but refuse to return it after the footer workspace moves. */
export async function loadAgentLibraryScene(
sceneName: string,
workspace: string,
current: () => boolean,
): Promise<AgentLibrarySceneLoad> {
if (!current()) return { ok: false, reason: 'stale' }
const library = await fetchOutputs(0, 0, { mediaType: 'scene', workspace })
if (!current()) return { ok: false, reason: 'stale' }
const matches = library.outputs.filter(file => sceneOutputMatchesName(file, sceneName))
if (!matches.length) {
return {
ok: false,
reason: 'missing',
availableTitles: library.outputs.slice(0, 8).map(file => sceneLibraryTitle(file.name)),
}
}
if (matches.length > 1) return { ok: false, reason: 'ambiguous' }
const response = await fetch(matches[0].url)
if (!current()) return { ok: false, reason: 'stale' }
if (!response.ok) return { ok: false, reason: 'load-failed' }
const payload = await response.json()
if (!current()) return { ok: false, reason: 'stale' }
return { ok: true, scene: sceneFromLibraryPayload(payload), file: matches[0], label: sceneLibraryTitle(matches[0].name) }
}
100 changes: 100 additions & 0 deletions ui/tests/agentSceneOpen.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { loadAgentLibraryScene } from '../src/lib/agentSceneOpen.ts'

const scene = {
version: 1,
name: 'Concierto arcano',
width: 1280,
height: 720,
duration: 8,
layers: [{
id: 'hero',
name: 'Mago',
type: 'image',
source: '/api/v1/file/hero.png',
visible: true,
z: 0,
transform: { x: 50, y: 50, scale: 1, opacity: 1 },
animation: { start: { x: 50, y: 50, scale: 1 }, end: { x: 50, y: 50, scale: 1 }, duration: 8, curve: 'linear' },
}],
}
const file = {
name: '2026-08-30-14h05m02s_Concierto-arcano_a1b2c3.scene.json',
url: '/api/v1/file/concert.scene.json',
type: 'scene',
}
const originalFetch = globalThis.fetch

test.afterEach(() => { globalThis.fetch = originalFetch })

function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
}

test('a late Wizard open must not return a scene after the workspace moves', async () => {
let releaseList
let sceneFetches = 0
globalThis.fetch = async (input) => {
const url = String(input)
if (url.includes('/api/v1/outputs')) {
return new Promise(resolve => { releaseList = resolve })
}
sceneFetches += 1
return jsonResponse(scene)
}
let current = true
const opening = loadAgentLibraryScene('Concierto arcano', 'one', () => current)
await new Promise(resolve => { const wait = () => { if (releaseList) resolve(undefined); else setTimeout(wait, 0) }; wait() })
current = false
releaseList(jsonResponse({ outputs: [file], total: 1 }))
assert.deepEqual(await opening, { ok: false, reason: 'stale' })
assert.equal(sceneFetches, 0)
})

test('a late scene payload must not import after the workspace moves', async () => {
let releaseScene
globalThis.fetch = async (input) => {
const url = String(input)
if (url.includes('/api/v1/outputs')) return jsonResponse({ outputs: [file], total: 1 })
return new Promise(resolve => { releaseScene = resolve })
}
let current = true
const opening = loadAgentLibraryScene('Concierto arcano', 'one', () => current)
await new Promise(resolve => { const wait = () => { if (releaseScene) resolve(undefined); else setTimeout(wait, 0) }; wait() })
current = false
releaseScene(jsonResponse(scene))
assert.deepEqual(await opening, { ok: false, reason: 'stale' })
})

test('a current Wizard open still returns the saved compositor scene', async () => {
globalThis.fetch = async (input) => {
const url = String(input)
if (url.includes('/api/v1/outputs')) return jsonResponse({ outputs: [file], total: 1 })
return jsonResponse(scene)
}
const loaded = await loadAgentLibraryScene('Concierto arcano', 'one', () => true)
assert.equal(loaded.ok, true)
if (!loaded.ok) return
assert.equal(loaded.scene.name, 'Concierto arcano')
assert.equal(loaded.label, 'Concierto arcano')
assert.equal(loaded.file.name, file.name)
})

test('missing and ambiguous saved names stay fail-closed', async () => {
globalThis.fetch = async () => jsonResponse({ outputs: [], total: 0 })
assert.deepEqual(await loadAgentLibraryScene('Missing', 'one', () => true), {
ok: false,
reason: 'missing',
availableTitles: [],
})

globalThis.fetch = async () => jsonResponse({
outputs: [
{ ...file, name: '2026-08-30-14h05m02s_Concierto-arcano_aaaaaa.scene.json' },
{ ...file, name: '2026-08-30-14h05m02s_Concierto-arcano_bbbbbb.scene.json' },
],
total: 2,
})
assert.deepEqual(await loadAgentLibraryScene('Concierto arcano', 'one', () => true), { ok: false, reason: 'ambiguous' })
})
Loading