From a2f8cd2efe4bd7d650f243e622b940d2ea3421ae Mon Sep 17 00:00:00 2001 From: IAnMove <216241348+IAnMove@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:18:54 +0200 Subject: [PATCH] feat(video3d): add music-video templates with a side-on run camera Run templates keep the subject still. The camera is chest-height profile, the floor is hidden, and the cylinder scrolls the opposite way. Extra templates cover space, void, and the two hip-hop clips without binding clip names. Follows merged #198; does not touch the 2.5D compositor. --- ui/src/features/scene3d/Scene3DStage.tsx | 7 +++ ui/src/features/scene3d/Scene3DWorkspace.tsx | 2 +- ui/src/features/scene3d/camera.ts | 5 +- ui/src/features/scene3d/document.ts | 5 +- ui/src/features/scene3d/dressing.ts | 63 ++++++++++++++++++++ ui/src/features/scene3d/gpu.ts | 6 +- ui/src/features/scene3d/templates.ts | 57 +++++++++++++++++- ui/src/features/scene3d/types.ts | 10 ++++ ui/src/i18n/locales/en/scene3d.json | 11 +++- ui/src/i18n/locales/es/scene3d.json | 11 +++- ui/tests/scene3dStage.test.mjs | 25 ++++++-- 11 files changed, 186 insertions(+), 16 deletions(-) create mode 100644 ui/src/features/scene3d/dressing.ts diff --git a/ui/src/features/scene3d/Scene3DStage.tsx b/ui/src/features/scene3d/Scene3DStage.tsx index 08d4e5b6..5327b4bb 100644 --- a/ui/src/features/scene3d/Scene3DStage.tsx +++ b/ui/src/features/scene3d/Scene3DStage.tsx @@ -1,6 +1,7 @@ import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' import { TextureLoader } from 'three' import { GLTFLoader, type GLTF } from 'three/addons/loaders/GLTFLoader.js' +import { syncDressing } from './dressing.ts' import { applyLight, catalogFromClips, @@ -157,6 +158,12 @@ export const Scene3DStage = forwardRef(function Scene applyLight(world.dir, document.light) }, [document.light]) + useEffect(() => { + const world = worldRef.current + if (!world) return + syncDressing(world, document.dressing) + }, [document.dressing]) + useEffect(() => { const world = worldRef.current if (!world) return diff --git a/ui/src/features/scene3d/Scene3DWorkspace.tsx b/ui/src/features/scene3d/Scene3DWorkspace.tsx index 7b7161a9..c87ac0e8 100644 --- a/ui/src/features/scene3d/Scene3DWorkspace.tsx +++ b/ui/src/features/scene3d/Scene3DWorkspace.tsx @@ -13,7 +13,7 @@ import { applyScene3DTemplate, patchScene3DSlot, SCENE3D_TEMPLATES, type Scene3D import type { Scene3DCameraFamily, Scene3DClipCatalogEntry, Scene3DDocument, Scene3DLoop, Scene3DSlot } from './types.ts' import { documentFromWorld3DRequest, listenForWorld3DWorkflow } from './world3dAgent.ts' -const FAMILIES = ['establishment', 'orbit', 'follow', 'pursuit', 'product', 'reveal', 'encounter', 'musical'] as const satisfies readonly Scene3DCameraFamily[] +const FAMILIES = ['establishment', 'orbit', 'follow', 'pursuit', 'side', 'product', 'reveal', 'encounter', 'musical'] as const satisfies readonly Scene3DCameraFamily[] type Props = { width: number diff --git a/ui/src/features/scene3d/camera.ts b/ui/src/features/scene3d/camera.ts index 87153468..cd937b86 100644 --- a/ui/src/features/scene3d/camera.ts +++ b/ui/src/features/scene3d/camera.ts @@ -66,7 +66,7 @@ export function cameraLookAtTime( duration: number, slots: readonly Scene3DSlot[] = [], ): Vec3 { - if (camera.family === 'follow' || camera.family === 'pursuit') { + if (camera.family === 'follow' || camera.family === 'pursuit' || camera.family === 'side') { return slotLook(slots, 'subject_1', camera.look) } if (camera.family === 'encounter') { @@ -98,6 +98,9 @@ export function cameraEyeAtTime( const musicalTurns = camera.family === 'musical' ? turns * 2 : turns return orbitEye(look, radius, productHeight, s * musicalTurns * Math.PI * 2) } + if (camera.family === 'side') { + return [look[0], look[1] - 0.18, look[2] + 3.55] + } if (camera.family === 'follow') { return [look[0], look[1] + 0.35, look[2] + radius] } diff --git a/ui/src/features/scene3d/document.ts b/ui/src/features/scene3d/document.ts index 6ad433dd..666322ee 100644 --- a/ui/src/features/scene3d/document.ts +++ b/ui/src/features/scene3d/document.ts @@ -80,5 +80,8 @@ export function parseScene3DDocument(raw: unknown): Scene3DDocument | null { && (SCENE3D_TEMPLATE_IDS as readonly string[]).includes(value.templateId) ? value.templateId as Scene3DTemplateId : 'two-shot' - return { ...value, slots, templateId } as Scene3DDocument + const dressing = value.dressing === 'street' || value.dressing === 'space' || value.dressing === 'treadmill' + ? value.dressing + : undefined + return { ...value, slots, templateId, dressing } as Scene3DDocument } diff --git a/ui/src/features/scene3d/dressing.ts b/ui/src/features/scene3d/dressing.ts new file mode 100644 index 00000000..5def1f37 --- /dev/null +++ b/ui/src/features/scene3d/dressing.ts @@ -0,0 +1,63 @@ +import { + BoxGeometry, + Color, + Group, + IcosahedronGeometry, + Mesh, + MeshStandardMaterial, + type Object3D, +} from 'three' +import type { GpuWorld } from './gpu.ts' +import type { Scene3DDressing } from './types.ts' + +function dropDressing(world: GpuWorld) { + if (!world.dressing) return + world.scene.remove(world.dressing) + world.dressing.traverse(child => { + if (!(child instanceof Mesh)) return + child.geometry.dispose() + const materials = Array.isArray(child.material) ? child.material : [child.material] + for (const material of materials) material.dispose() + }) + world.dressing = null +} + +function streetGroup(): Object3D { + const root = new Group() + const brick = new MeshStandardMaterial({ color: 0x3a3340, roughness: 0.88 }) + const glass = new MeshStandardMaterial({ color: 0x1a2438, roughness: 0.28, metalness: 0.55 }) + const blocks = [ + [-5.4, 2.2, -4.2, 1.6, 4.4, 1.6], + [5.8, 3.1, -3.6, 1.8, 6.2, 1.8], + [-6.2, 1.6, 3.4, 1.4, 3.2, 1.5], + [6.4, 2.4, 4.1, 1.7, 4.8, 1.7], + [0.4, 1.2, -7.2, 3.2, 2.4, 1.2], + [-2.8, 0.9, 6.8, 2.2, 1.8, 1.1], + ] + for (const [x, y, z, w, h, d] of blocks) { + const mesh = new Mesh(new BoxGeometry(w, h, d), Math.abs(x) > 5 ? glass : brick) + mesh.position.set(x, y, z) + root.add(mesh) + } + return root +} + +function spaceGroup(): Object3D { + const root = new Group() + const rock = new MeshStandardMaterial({ color: new Color(0x2a2030), roughness: 0.95, emissive: 0x140818, emissiveIntensity: 0.35 }) + const spots = [[-3.2, 0.4, -2.4, 0.7], [3.6, 0.8, -1.6, 0.9], [1.2, 0.2, 3.1, 0.5], [-2.1, 1.4, 2.6, 0.4]] + for (const [x, y, z, r] of spots) { + const mesh = new Mesh(new IcosahedronGeometry(r, 0), rock) + mesh.position.set(x, y, z) + root.add(mesh) + } + return root +} + +export function syncDressing(world: GpuWorld, kind: Scene3DDressing | undefined) { + dropDressing(world) + world.floor.visible = kind !== 'space' && kind !== 'treadmill' + if (kind === 'street') world.dressing = streetGroup() + if (kind === 'space') world.dressing = spaceGroup() + if (world.dressing) world.scene.add(world.dressing) +} diff --git a/ui/src/features/scene3d/gpu.ts b/ui/src/features/scene3d/gpu.ts index bc804879..d6723462 100644 --- a/ui/src/features/scene3d/gpu.ts +++ b/ui/src/features/scene3d/gpu.ts @@ -33,7 +33,7 @@ import { scene3dSlotColor } from './document.ts' import type { Scene3DClipCatalogEntry, Scene3DDocument, Scene3DLight, Scene3DSlot } from './types.ts' export const CYLINDER_RADIUS = 12 -export const CYLINDER_HEIGHT = 10 +export const CYLINDER_HEIGHT = 18 export const MAX_VIEW_WIDTH = 1280 export const MAX_VIEW_HEIGHT = 720 @@ -59,6 +59,8 @@ export type GpuWorld = { scene: Scene camera: PerspectiveCamera dir: DirectionalLight + floor: Mesh + dressing: Object3D | null slots: Map } @@ -330,7 +332,7 @@ export function createWorld(host: HTMLDivElement, light: Scene3DLight, fov: numb ) floor.rotation.x = -Math.PI / 2 scene.add(floor) - return { renderer, scene, camera, dir, slots: new Map() } + return { renderer, scene, camera, dir, floor, dressing: null, slots: new Map() } } export function disposeWorld(world: GpuWorld) { diff --git a/ui/src/features/scene3d/templates.ts b/ui/src/features/scene3d/templates.ts index 22c39715..c448e47c 100644 --- a/ui/src/features/scene3d/templates.ts +++ b/ui/src/features/scene3d/templates.ts @@ -18,7 +18,13 @@ export const SCENE3D_TEMPLATES: readonly Scene3DTemplate[] = [ { id: 'tracking', camera: 'follow', duration: 6, slots: ['subject_1', 'background'] }, { id: 'crane-reveal', camera: 'reveal', duration: 6, slots: ['subject_1', 'background'] }, { id: 'establishing', camera: 'orbit', duration: 8, slots: ['background', 'prop'] }, - { id: 'run-loop', camera: 'pursuit', duration: 8, slots: ['subject_1', 'background'] }, + { id: 'run-loop', camera: 'side', duration: 8, slots: ['subject_1', 'background'] }, + { id: 'neon-run', camera: 'side', duration: 6, slots: ['subject_1', 'background'] }, + { id: 'block-street', camera: 'side', duration: 6, slots: ['subject_1', 'background'] }, + { id: 'space-float', camera: 'musical', duration: 6, slots: ['subject_1', 'background'] }, + { id: 'walk-void', camera: 'establishment', duration: 6, slots: ['subject_1', 'background'] }, + { id: 'dance-orbit', camera: 'orbit', duration: 6, slots: ['subject_1', 'background'] }, + { id: 'dance-stage', camera: 'musical', duration: 8, slots: ['subject_1', 'background'] }, ] const LAYOUTS: Record>>> = { @@ -56,6 +62,30 @@ const LAYOUTS: Record { const slot = emptySlot(slotId) const pose = layout[slotId] const next = pose ? { ...slot, ...pose } : slot - if (template.id === 'run-loop' && slotId === 'background') { - return { ...next, media: 'image', loop: { cylinder: true, speed: 0.18 } } + const cylinder = CYLINDER_BY_TEMPLATE[template.id] + if (cylinder && slotId === 'background') { + return { ...next, media: 'image', loop: { cylinder: true, speed: cylinder.speed } } } return next }) + document.dressing = DRESSING_BY_TEMPLATE[template.id] return document } +const CYLINDER_BY_TEMPLATE: Partial> = { + 'run-loop': { speed: -0.18 }, + 'neon-run': { speed: -0.26 }, + 'block-street': { speed: -0.16 }, + 'space-float': { speed: 0.05 }, + 'walk-void': { speed: 0.08 }, + 'dance-orbit': { speed: 0.04 }, + 'dance-stage': { speed: 0.03 }, +} + +const DRESSING_BY_TEMPLATE: Partial> = { + 'run-loop': 'treadmill', + 'neon-run': 'treadmill', + 'block-street': 'treadmill', + 'space-float': 'space', + 'dance-stage': 'street', +} + export function patchScene3DSlot( document: Scene3DDocument, slotId: string, diff --git a/ui/src/features/scene3d/types.ts b/ui/src/features/scene3d/types.ts index ea35784e..ee77fb2c 100644 --- a/ui/src/features/scene3d/types.ts +++ b/ui/src/features/scene3d/types.ts @@ -9,6 +9,7 @@ export type Scene3DCameraFamily = | 'pursuit' | 'product' | 'musical' + | 'side' export type Scene3DSlotId = 'subject_1' | 'subject_2' | 'background' | 'prop' @@ -21,6 +22,12 @@ export const SCENE3D_TEMPLATE_IDS = [ 'crane-reveal', 'establishing', 'run-loop', + 'neon-run', + 'block-street', + 'space-float', + 'walk-void', + 'dance-orbit', + 'dance-stage', ] as const export type Scene3DTemplateId = (typeof SCENE3D_TEMPLATE_IDS)[number] @@ -37,6 +44,8 @@ export type Scene3DLoop = { speed: number } +export type Scene3DDressing = 'none' | 'street' | 'space' | 'treadmill' + export type Scene3DSlot = { id: string slot: Scene3DSlotId @@ -77,6 +86,7 @@ export type Scene3DDocument = { templateId: Scene3DTemplateId camera: Scene3DCamera light: Scene3DLight + dressing?: Scene3DDressing slots: Scene3DSlot[] } diff --git a/ui/src/i18n/locales/en/scene3d.json b/ui/src/i18n/locales/en/scene3d.json index 11a928c5..352066e7 100644 --- a/ui/src/i18n/locales/en/scene3d.json +++ b/ui/src/i18n/locales/en/scene3d.json @@ -907,7 +907,7 @@ "exported": "3D MP4 saved in Videos as {{name}}", "exportFailed": "Could not export the 3D scene.", "help": "Metres, Y-up. Clip identity is index plus the exact glTF name. Local GLB files stay in the browser; they are not uploaded.", - "runHelp": "The character stays on the spot. Pick the Running clip by its exact glTF name — do not guess. The world is a cylinder that scrolls behind them.", + "runHelp": "Side-on, chest height. The character stays on the spot; pick the Running clip by its exact glTF name. No floor — only the subject and the cylinder scrolling the other way.", "infinite": "Infinite backdrop", "loopSpeed": "Scroll", "clipIndex": "Clip index", @@ -929,6 +929,7 @@ "reveal": "Crane reveal", "encounter": "Two-shot / OTS", "pursuit": "Pursuit / run", + "side": "Side profile", "musical": "Musical orbit" }, "template": { @@ -939,7 +940,13 @@ "tracking": "Tracking shot", "crane-reveal": "Crane reveal", "establishing": "Establishing", - "run-loop": "Run loop" + "run-loop": "Run loop", + "neon-run": "Neon run", + "block-street": "Block street", + "space-float": "Space float", + "walk-void": "Walk in the void", + "dance-orbit": "Dance orbit", + "dance-stage": "Dance stage" } } } diff --git a/ui/src/i18n/locales/es/scene3d.json b/ui/src/i18n/locales/es/scene3d.json index c2f05bd1..61dfb0a0 100644 --- a/ui/src/i18n/locales/es/scene3d.json +++ b/ui/src/i18n/locales/es/scene3d.json @@ -907,7 +907,7 @@ "exported": "MP4 3D guardado en Vídeos como {{name}}", "exportFailed": "No se pudo exportar la escena 3D.", "help": "Metros, Y-up. La identidad del clip es índice más el nombre glTF exacto. Los GLB locales se quedan en el navegador; no se suben.", - "runHelp": "El personaje se queda en sitio. Elige el clip Running por su nombre glTF exacto; no lo adivines. El mundo es un cilindro que se desplaza detrás.", + "runHelp": "De lado, a la altura del pecho. El personaje se queda en sitio; elige el clip Running por su nombre glTF exacto. Sin suelo: solo el sujeto y el cilindro desplazándose al contrario.", "infinite": "Fondo infinito", "loopSpeed": "Desplazamiento", "clipIndex": "Índice de clip", @@ -929,6 +929,7 @@ "reveal": "Grúa de revelado", "encounter": "Plano de dos / OTS", "pursuit": "Persecución / carrera", + "side": "Perfil de lado", "musical": "Órbita musical" }, "template": { @@ -939,7 +940,13 @@ "tracking": "Travelling", "crane-reveal": "Grúa de revelado", "establishing": "Plano de situación", - "run-loop": "Carrera en bucle" + "run-loop": "Carrera en bucle", + "neon-run": "Carrera neón", + "block-street": "Calle de bloques", + "space-float": "Flotar en el espacio", + "walk-void": "Caminar en el vacío", + "dance-orbit": "Órbita de baile", + "dance-stage": "Escenario de baile" } } } diff --git a/ui/tests/scene3dStage.test.mjs b/ui/tests/scene3dStage.test.mjs index 099d94e6..2ac67d22 100644 --- a/ui/tests/scene3dStage.test.mjs +++ b/ui/tests/scene3dStage.test.mjs @@ -108,7 +108,8 @@ test('establishment camera eases in rather than sitting still', () => { test('run-loop keeps the subject still and scrolls the cylinder world', () => { const document = applyScene3DTemplate('run-loop') assert.equal(document.templateId, 'run-loop') - assert.equal(document.camera.family, 'pursuit') + assert.equal(document.camera.family, 'side') + assert.equal(document.dressing, 'treadmill') const subject = document.slots.find(slot => slot.slot === 'subject_1') const background = document.slots.find(slot => slot.slot === 'background') assert.ok(subject) @@ -116,10 +117,11 @@ test('run-loop keeps the subject still and scrolls the cylinder world', () => { assert.deepEqual(subject.position, [0, 0, 0]) assert.equal(background.media, 'image') assert.equal(background.loop?.cylinder, true) - assert.ok((background.loop?.speed ?? 0) > 0) + assert.ok((background.loop?.speed ?? 0) < 0) assert.equal(subject.clip, null) const restored = parseScene3DDocument(JSON.parse(JSON.stringify(document))) assert.equal(restored?.slots.find(slot => slot.slot === 'background')?.loop?.cylinder, true) + assert.equal(restored?.dressing, 'treadmill') const still = hashSoftwareFrame(renderScene3DSoftware(document, 0)) const later = hashSoftwareFrame(renderScene3DSoftware(document, 1.5)) assert.notEqual(still, later) @@ -128,7 +130,22 @@ test('run-loop keeps the subject still and scrolls the cylinder world', () => { const eye = cameraEyeAtTime(document.camera, 1, document.duration, document.slots) assert.equal(look[0], subject.position[0]) assert.ok(eye[2] > look[2]) - assert.notEqual(cylinderUvOffset(0, 0.18), cylinderUvOffset(1, 0.18)) + assert.ok(Math.abs(eye[1] - look[1]) < 0.25) + assert.notEqual(cylinderUvOffset(0, -0.18), cylinderUvOffset(1, -0.18)) +}) + +test('music-video templates keep clips unbound and dress the world', () => { + const space = applyScene3DTemplate('space-float') + assert.equal(space.dressing, 'space') + assert.equal(space.camera.family, 'musical') + assert.equal(space.slots[0].clip, null) + assert.ok((space.slots.find(slot => slot.slot === 'background')?.loop?.cylinder)) + const street = applyScene3DTemplate('block-street') + assert.equal(street.dressing, 'treadmill') + assert.equal(street.camera.family, 'side') + const dance = applyScene3DTemplate('dance-orbit') + assert.equal(dance.camera.family, 'orbit') + assert.equal(dance.slots[0].clip, null) }) test('world3d export plan is independent of compositor layers', () => { @@ -156,7 +173,7 @@ test('wizard can mount the run-loop cylinder template', () => { }, }) assert.equal(document.templateId, 'run-loop') - assert.equal(document.camera.family, 'pursuit') + assert.equal(document.camera.family, 'side') assert.equal(document.slots[0].sourceUrl, '/api/v1/file/hero.glb') assert.equal(document.slots[0].clip, null) const background = document.slots.find(slot => slot.slot === 'background')