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
7 changes: 7 additions & 0 deletions ui/src/features/scene3d/Scene3DStage.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -157,6 +158,12 @@ export const Scene3DStage = forwardRef<Scene3DStageHandle, Props>(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
Expand Down
2 changes: 1 addition & 1 deletion ui/src/features/scene3d/Scene3DWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion ui/src/features/scene3d/camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -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]
}
Expand Down
5 changes: 4 additions & 1 deletion ui/src/features/scene3d/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
63 changes: 63 additions & 0 deletions ui/src/features/scene3d/dressing.ts
Original file line number Diff line number Diff line change
@@ -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)
}
6 changes: 4 additions & 2 deletions ui/src/features/scene3d/gpu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -59,6 +59,8 @@ export type GpuWorld = {
scene: Scene
camera: PerspectiveCamera
dir: DirectionalLight
floor: Mesh
dressing: Object3D | null
slots: Map<string, SlotGpu>
}

Expand Down Expand Up @@ -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) {
Expand Down
57 changes: 54 additions & 3 deletions ui/src/features/scene3d/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Scene3DTemplateId, Partial<Record<Scene3DSlotId, Pick<Scene3DSlot, 'position' | 'rotationY' | 'scale'>>>> = {
Expand Down Expand Up @@ -56,6 +62,30 @@ const LAYOUTS: Record<Scene3DTemplateId, Partial<Record<Scene3DSlotId, Pick<Scen
subject_1: { position: [0, 0, 0], rotationY: 1.57, scale: 1 },
background: { position: [0, 0, 0], rotationY: 0, scale: 1 },
},
'neon-run': {
subject_1: { position: [0, 0, 0], rotationY: 1.57, scale: 1 },
background: { position: [0, 0, 0], rotationY: 0, scale: 1 },
},
'block-street': {
subject_1: { position: [0, 0, 0], rotationY: 1.57, scale: 1 },
background: { position: [0, 0, 0], rotationY: 0, scale: 1 },
},
'space-float': {
subject_1: { position: [0, 0.7, 0], rotationY: 0.35, scale: 1 },
background: { position: [0, 0, 0], rotationY: 0, scale: 1 },
},
'walk-void': {
subject_1: { position: [0, 0, 0], rotationY: 0.25, scale: 1 },
background: { position: [0, 0, 0], rotationY: 0, scale: 1 },
},
'dance-orbit': {
subject_1: { position: [0, 0, 0], rotationY: 0, scale: 1 },
background: { position: [0, 0, 0], rotationY: 0, scale: 1 },
},
'dance-stage': {
subject_1: { position: [0, 0, 0], rotationY: 0.2, scale: 1 },
background: { position: [0, 0, 0], rotationY: 0, scale: 1 },
},
}

function emptySlot(id: Scene3DSlotId): Scene3DSlot {
Expand All @@ -80,19 +110,40 @@ export function applyScene3DTemplate(id: Scene3DTemplateId): Scene3DDocument {
document.camera = {
...document.camera,
family: template.camera,
...(template.camera === 'side' ? { fov: 38 } : {}),
}
document.slots = template.slots.map(slotId => {
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<Record<Scene3DTemplateId, { speed: number }>> = {
'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<Record<Scene3DTemplateId, Scene3DDocument['dressing']>> = {
'run-loop': 'treadmill',
'neon-run': 'treadmill',
'block-street': 'treadmill',
'space-float': 'space',
'dance-stage': 'street',
}

export function patchScene3DSlot(
document: Scene3DDocument,
slotId: string,
Expand Down
10 changes: 10 additions & 0 deletions ui/src/features/scene3d/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type Scene3DCameraFamily =
| 'pursuit'
| 'product'
| 'musical'
| 'side'

export type Scene3DSlotId = 'subject_1' | 'subject_2' | 'background' | 'prop'

Expand All @@ -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]
Expand All @@ -37,6 +44,8 @@ export type Scene3DLoop = {
speed: number
}

export type Scene3DDressing = 'none' | 'street' | 'space' | 'treadmill'

export type Scene3DSlot = {
id: string
slot: Scene3DSlotId
Expand Down Expand Up @@ -77,6 +86,7 @@ export type Scene3DDocument = {
templateId: Scene3DTemplateId
camera: Scene3DCamera
light: Scene3DLight
dressing?: Scene3DDressing
slots: Scene3DSlot[]
}

Expand Down
11 changes: 9 additions & 2 deletions ui/src/i18n/locales/en/scene3d.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -929,6 +929,7 @@
"reveal": "Crane reveal",
"encounter": "Two-shot / OTS",
"pursuit": "Pursuit / run",
"side": "Side profile",
"musical": "Musical orbit"
},
"template": {
Expand All @@ -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"
}
}
}
11 changes: 9 additions & 2 deletions ui/src/i18n/locales/es/scene3d.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand All @@ -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"
}
}
}
25 changes: 21 additions & 4 deletions ui/tests/scene3dStage.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -108,18 +108,20 @@ 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)
assert.ok(background)
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)
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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')
Expand Down
Loading