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
50 changes: 50 additions & 0 deletions docs/development/VIDEO3D_EDITOR_REFRESH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Vídeo 3D: plantillas y controles de edición

Base de implementación: `f05bb2c8` de `development`. Trabajo aislado; no cambia
launchers ni servicios activos.

## Comportamiento

- Las 21 plantillas 3D existentes reciben perfiles de cámara e iluminación
diferenciados. Se añaden 15 composiciones (36 en total), agrupadas en cine,
producto, videoclip, espacio y conducción, con búsqueda y descripciones ES/EN.
- La barra de reproducción tiene una acción principal de 48 px, pausa, reinicio,
cursor temporal y velocidad de 0,25× a 4×. La biblioteca se puede plegar y el
editor ofrece pantalla completa para disponer de más espacio.
- Mover usa el gizmo real de Three.js para X/Y/Z; girar permite el eje Y que
admite el documento. Escalar conserva las proporciones. Los campos numéricos,
botones de tamaño y restablecimiento comparten el mismo estado que el gizmo.
- Cambiar de plano conserva por defecto fuentes, identidad y clips compatibles;
usa las posiciones del plano nuevo. La opción se puede desmarcar.
- Las 48 plantillas por capas reciben un acabado editable de profundidad:
fondos más contenidos, primer término suavizado, sombras suaves y partículas
ajustadas a la intensidad. No se cambia la imagen del sujeto ni el prompt.
La galería incorpora búsqueda y textos mayores; las acciones de reproducción
del compositor y las galerías ganan tamaño y contraste.

## Contrato temporal y de recursos

`Scene3DDocument.playbackSpeed` es opcional; ausencia equivale a 1. Se acota entre
0,25 y 4. `duration` permanece como longitud de la línea temporal original.
La duración del resultado es `duration / playbackSpeed`; exportación y preview
recorren la misma cámara, clips y entorno. El encoder recibe tiempos del
resultado y los transforma a tiempos de la escena. El sidecar conserva el
documento completo y la duración efectiva del vídeo.

El gizmo actúa sobre un objeto auxiliar en coordenadas del documento. No
reescribe huesos, normalización del GLB ni identidad de sus animaciones. Se
oculta durante reproducción y exportación y se libera al desmontar la escena.
Los botones de edición quedan inactivos durante la reproducción/exportación.

Las nuevas composiciones son configuraciones de cámara, luz, decorado y slots.
No generan animación esquelética, física ni audio: los clips se eligen de los
GLB aportados. Las referencias coral/MiniMax existentes y sus hashes permanecen
intactos. El nuevo acabado sólo se aplica al compilar escenas nuevas y se
identifica como `narrative.controls.finishVersion: 1`.

## Validación

Los resultados y las limitaciones de validación se registran en el PR. Las
pruebas dirigidas cubren todos los catálogos, cámaras, roundtrip, procedencia,
velocidad y transformación. El E2E usa la escena WebGL real con API simulada en
un puerto propio, sin proveedores ni backend de usuario.
139 changes: 139 additions & 0 deletions ui/e2e/specs/scene3d-editor-controls.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { expect, test } from '@playwright/test'
import { cameraEyeAtTime, cameraLookAtTime, projectPoint } from '../../src/features/scene3d/camera'
import { applyScene3DTemplate } from '../../src/features/scene3d/templates'
import { gotoApp, closeApp } from '../helpers/gotoApp'

// The real Three.js scene runs against a closed, simulated API. No model provider.
test('3D templates, playback speed and object transforms work in the editor', async ({ page }, testInfo) => {
const session = await gotoApp(page)
await page.getByRole('tab', { name: 'Video 3D', exact: true }).click()
await page.getByRole('button', { name: 'Close Ask to the Wizard' }).click()
await page.getByRole('button', { name: 'Expand editor', exact: true }).click()
const workspace = page.getByTestId('scene3d-workspace')
await expect(workspace).toBeVisible()
const play = workspace.getByRole('button', { name: 'Play', exact: true })
await expect(play).toBeVisible()
const bounds = await play.boundingBox()
expect(bounds?.height).toBeGreaterThanOrEqual(44)
expect(bounds?.width).toBeGreaterThanOrEqual(140)

await workspace.getByRole('combobox', { name: 'Speed', exact: true }).selectOption('2')
await expect(workspace.getByText('Output: 3.00 s')).toBeVisible()
await play.click()
await expect(workspace.getByRole('button', { name: 'Pause', exact: true })).toBeVisible()
await expect(workspace.getByLabel('Size', { exact: true })).toBeDisabled()
await workspace.getByRole('button', { name: 'Pause', exact: true }).click()
await workspace.getByRole('button', { name: 'Back to start' }).click()
await expect(workspace.getByRole('slider', { name: 'Scene position' })).toHaveValue('0')

await workspace.getByRole('button', { name: '+ Larger', exact: true }).click()
await expect(workspace.getByLabel('Size', { exact: true })).toHaveValue('1.25')
await workspace.getByLabel('Position (m) X', { exact: true }).fill('1.2')
await workspace.getByLabel('Position (m) Y', { exact: true }).fill('0.5')
await workspace.getByLabel('Position (m) Z', { exact: true }).fill('-0.8')
await workspace.getByLabel('Rotation Y (°)', { exact: true }).fill('45')
await workspace.getByRole('button', { name: 'Reset transform' }).click()
await expect(workspace.getByLabel('Position (m) X', { exact: true })).toHaveValue('-0.95')

// Drag the real X handle in the WebGL viewport, then verify the document field.
const scene = applyScene3DTemplate('two-shot')
const canvas = workspace.locator('canvas').first()
await canvas.scrollIntoViewIfNeeded()
const box = await canvas.boundingBox()
expect(box).not.toBeNull()
const eye = cameraEyeAtTime(scene.camera, 0, scene.duration, scene.slots)
const look = cameraLookAtTime(scene.camera, 0, scene.duration, scene.slots)
const origin = scene.slots[0].position
const distance = Math.hypot(...origin.map((value, i) => value - eye[i]))
const handleScale = distance * Math.min(1.9 * Math.tan(Math.PI * scene.camera.fov / 360), 7) * 0.85 / 4
const point = projectPoint([origin[0] + handleScale * 0.4, origin[1], origin[2]], eye, look, scene.camera.fov, box!.width / box!.height)!
const x = box!.x + point.x * box!.width
const y = box!.y + point.y * box!.height
await page.mouse.move(x, y)
await page.mouse.down()
await page.mouse.move(x + 65, y, { steps: 10 })
await page.mouse.up()
await expect(workspace.getByLabel('Position (m) X', { exact: true })).not.toHaveValue('-0.95')
await workspace.getByRole('button', { name: 'Reset transform' }).click()

await workspace.getByLabel('Rotation Y (°)', { exact: true }).fill('0')
await workspace.getByRole('button', { name: 'Scale', exact: true }).click()
await canvas.scrollIntoViewIfNeeded()
const scaleBox = (await canvas.boundingBox())!
const scaleX = scaleBox.x + point.x * scaleBox.width
const scaleY = scaleBox.y + point.y * scaleBox.height
await page.mouse.move(scaleX, scaleY)
await page.mouse.down()
await page.mouse.move(scaleX + 40, scaleY, { steps: 10 })
await page.mouse.up()
await expect(workspace.getByLabel('Size', { exact: true })).not.toHaveValue('1')
await workspace.getByRole('button', { name: 'Reset transform' }).click()

await workspace.locator('summary').click()
await workspace.getByRole('searchbox', { name: 'Search templates' }).fill('portrait')
await workspace.getByTestId('world3d-template-portrait-arc').click()
await workspace.locator('summary').click()
await expect(workspace.getByRole('slider', { name: 'Scene position' })).toHaveValue('0')
await expect(workspace.getByRole('combobox', { name: 'Speed', exact: true })).toHaveValue('2')
await workspace.getByRole('button', { name: 'Scale', exact: true }).click()
await expect(workspace.getByRole('button', { name: 'Scale', exact: true })).toHaveAttribute('aria-pressed', 'true')
await workspace.getByRole('button', { name: 'Move', exact: true }).click()
await page.getByTestId('world3d-editor').evaluate(element => { element.scrollTop = 0 })
await page.screenshot({ path: testInfo.outputPath('video3d-editor-desktop.png'), fullPage: true })

// Mobile controls must remain reachable without horizontal page overflow.
await page.getByRole('button', { name: 'Exit fullscreen', exact: true }).click()
await expect(page.getByRole('button', { name: 'Expand editor', exact: true })).toBeVisible()
await page.setViewportSize({ width: 390, height: 844 })
await page.getByRole('button', { name: 'Expand editor', exact: true }).click()
await expect(play).toBeVisible()
await workspace.getByRole('button', { name: 'Scale', exact: true }).click()
await expect(workspace.getByLabel('Size', { exact: true })).toBeVisible()
const overflow = await workspace.evaluate(element => element.scrollWidth - element.clientWidth)
expect(overflow).toBeLessThanOrEqual(2)
await page.getByTestId('world3d-editor').evaluate(element => { element.scrollTop = 0 })
await page.screenshot({ path: testInfo.outputPath('video3d-editor-mobile.png'), fullPage: true })
await closeApp(page, session)
})

test('speed is baked into a decodable MP4 and its scene metadata', async ({ page }) => {
const session = await gotoApp(page)
await page.getByRole('tab', { name: 'Video 3D', exact: true }).click()
await page.getByRole('button', { name: 'Close Ask to the Wizard' }).click()
const available = await page.evaluate(async () => {
if (typeof VideoEncoder === 'undefined') return false
const result = await VideoEncoder.isConfigSupported({ codec: 'avc1.640028', width: 1280, height: 720, bitrate: 5_000_000, framerate: 30, avc: { format: 'avc' } })
return result.supported
})
test.skip(!available, 'This Chromium does not provide an H.264 encoder')
let metadata: { scene: { duration: number }; recipe: { document: { playbackSpeed: number } } } | undefined
await page.route('**/api/v1/scenes/recordings', async route => {
const payload = route.request().postDataBuffer()!.toString('utf8')
const field = payload.split('name="metadata"\r\n\r\n')[1].split('\r\n--')[0]
metadata = JSON.parse(field)
await route.fulfill({ json: { name: 'speed-check.mp4', type: 'video', url: '/api/v1/file/speed-check.mp4' } })
})
const workspace = page.getByTestId('scene3d-workspace')
await workspace.getByRole('combobox', { name: 'Speed', exact: true }).selectOption('4')
await workspace.getByTestId('world3d-export').click()
await expect(workspace.getByRole('button', { name: 'Play', exact: true })).toBeDisabled()
await expect(workspace.getByTestId('world3d-export-note')).toContainText('speed-check.mp4', { timeout: 25_000 })
expect(metadata?.scene.duration).toBe(1.5)
expect(metadata?.recipe.document.playbackSpeed).toBe(4)
const decoded = await page.evaluate(async () => {
const blob = (window as Window & { __world3dLastMp4?: Blob }).__world3dLastMp4!
const url = URL.createObjectURL(blob)
const video = document.createElement('video')
try {
return await new Promise<{ duration: number; width: number; height: number }>((resolve, reject) => {
video.onloadedmetadata = () => resolve({ duration: video.duration, width: video.videoWidth, height: video.videoHeight })
video.onerror = () => reject(new Error('Exported MP4 cannot be decoded'))
video.src = url
})
} finally { video.removeAttribute('src'); video.load(); URL.revokeObjectURL(url) }
})
expect(decoded.duration).toBeCloseTo(1.5, 1)
expect(decoded.width).toBe(1280)
expect(decoded.height).toBe(720)
await closeApp(page, session)
})
2 changes: 1 addition & 1 deletion ui/scripts/check-i18n-catalogs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const NAMESPACES = ['common', 'navigation', 'settings', 'wizard', 'activity', 'extraInfo', 'storyLab', 'director', 'seriesLab', 'videoEditor', 'workspaces', 'styleSheet', 'projects', 'auditDev', 'scene3d', 'shell', 'characters', 'comics', 'studio']
const NAMESPACES = ['common', 'navigation', 'settings', 'wizard', 'activity', 'extraInfo', 'storyLab', 'director', 'seriesLab', 'videoEditor', 'workspaces', 'styleSheet', 'projects', 'auditDev', 'scene3d', 'scene3dEditor', 'shell', 'characters', 'comics', 'studio']
const LANGUAGES = ['en', 'es']

function load(language, namespace) {
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/Sidebar/SceneAnimatorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2993,7 +2993,7 @@ export function SceneAnimatorPanel() {

return <div className="flex min-h-[620px] flex-col overflow-hidden rounded-xl border border-border bg-bg-tertiary xl:flex-row">
<section className="flex min-w-0 flex-1 flex-col p-3 md:p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2"><div className="flex items-center gap-1.5 text-xs font-medium"><Film size={15} className="text-accent-blue" /><input value={scene.name} onChange={event => updateScene(current => ({ ...current, name: event.target.value }))} aria-label={t('animator.sceneNameAria')} className="w-44 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs font-medium hover:border-border focus:border-accent-blue focus:outline-none" /><span className="text-[10px] font-normal text-text-muted">{scene.width}×{scene.height}</span></div><div className="flex flex-wrap gap-2"><button type="button" onClick={() => setLibraryOpen(true)} disabled={playing || recording || publishing} className="rounded border border-border bg-bg-primary px-2.5 py-1.5 text-[10px] flex items-center gap-1 disabled:opacity-50"><FolderOpen size={12} /> {t('animator.openScene')}</button><button type="button" onClick={() => void persistScene()} disabled={saving || !scene.layers.length || playing || recording || publishing} className="rounded border border-accent-blue/40 bg-accent-blue/10 px-2.5 py-1.5 text-[10px] text-accent-blue flex items-center gap-1 disabled:opacity-50">{saving ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}{saving ? t('animator.saving') : t('animator.saveScene')}</button><button onClick={play} disabled={!scene.layers.length || playing || recording || publishing} className="rounded border border-border bg-bg-primary px-2.5 py-1.5 text-[10px] flex items-center gap-1 disabled:opacity-50"><Play size={12} /> {t('animator.preview')}</button><button onClick={record} disabled={recording || playing || publishing} className="rounded bg-cta px-2.5 py-1.5 text-[10px] text-white flex items-center gap-1 disabled:opacity-50">{recording || publishing ? <Loader2 size={12} className="animate-spin" /> : <Download size={12} />}{recording ? t('animator.recording') : publishing ? t('animator.savingMp4') : t('animator.exportMp4')}</button></div></div>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2"><div className="flex items-center gap-1.5 text-xs font-medium"><Film size={15} className="text-accent-blue" /><input value={scene.name} onChange={event => updateScene(current => ({ ...current, name: event.target.value }))} aria-label={t('animator.sceneNameAria')} className="w-44 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs font-medium hover:border-border focus:border-accent-blue focus:outline-none" /><span className="text-[10px] font-normal text-text-muted">{scene.width}×{scene.height}</span></div><div className="flex flex-wrap gap-2"><button type="button" onClick={() => setLibraryOpen(true)} disabled={playing || recording || publishing} className="rounded border border-border bg-bg-primary px-2.5 py-1.5 text-[10px] flex items-center gap-1 disabled:opacity-50"><FolderOpen size={12} /> {t('animator.openScene')}</button><button type="button" onClick={() => void persistScene()} disabled={saving || !scene.layers.length || playing || recording || publishing} className="rounded border border-accent-blue/40 bg-accent-blue/10 px-2.5 py-1.5 text-[10px] text-accent-blue flex items-center gap-1 disabled:opacity-50">{saving ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}{saving ? t('animator.saving') : t('animator.saveScene')}</button><button onClick={play} disabled={!scene.layers.length || playing || recording || publishing} className="min-h-12 min-w-36 rounded-lg bg-cyan-300 px-5 py-3 text-sm font-bold text-slate-950 shadow-lg flex items-center justify-center gap-2 hover:bg-cyan-200 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-cyan-200 disabled:opacity-50"><Play size={22} fill="currentColor" /> {t('animator.preview')}</button><button onClick={record} disabled={recording || playing || publishing} className="rounded bg-cta px-2.5 py-1.5 text-[10px] text-white flex items-center gap-1 disabled:opacity-50">{recording || publishing ? <Loader2 size={12} className="animate-spin" /> : <Download size={12} />}{recording ? t('animator.recording') : publishing ? t('animator.savingMp4') : t('animator.exportMp4')}</button></div></div>
<div className="mb-2 flex items-center justify-end gap-1.5"><button type="button" onClick={undoScene} disabled={!canUndo} title={t('animator.undoTitle')} className="rounded border border-border bg-bg-primary p-1.5 disabled:opacity-30"><Undo2 size={12} /></button><button type="button" onClick={redoScene} disabled={!canRedo} title={t('animator.redoTitle')} className="rounded border border-border bg-bg-primary p-1.5 disabled:opacity-30"><Redo2 size={12} /></button><span className="ml-1 text-[8px] text-text-muted">{lastAutosaveAt ? t('animator.autosaved', { time: new Date(lastAutosaveAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }) : t('animator.autosaveWaiting')}</span></div>
<div className="mb-3 flex flex-wrap items-center gap-1">{RESOLUTIONS.map(([label, width, height]) => <button key={label} disabled={playing || recording} onClick={() => 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'}`)}</button>)}<span className="ml-auto flex items-center gap-1 pl-2 text-[8px] text-text-muted">{t('animator.frameRate')}{([30, 60] as SceneFrameRate[]).map(rate => <button key={rate} type="button" disabled={playing || recording} onClick={() => 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 })}</button>)}</span></div>
<div className="mb-3 flex flex-wrap items-center gap-1.5 rounded border border-border bg-bg-secondary p-1.5">
Expand Down
Loading
Loading