import { test, expect } from "@playwright/test"; import { defaultDirectorScene, directorImageSize, readDirectorScene, } from "@/lib/video-canvas/director-scene"; test("director preserves real model, camera and screenshot through JSON save/reload", () => { const scene = defaultDirectorScene(); scene.objects.push({ id: "model-1", name: "Cube", kind: "model", url: "https://example.com/cube.glb", position: [2, 0.5, 0], rotation: [0, 1, 0], scale: [1, 2, 1], }); scene.cameras.push({ id: "side", name: "侧面", position: [7, 2, 0], target: [0, 1, 0], fov: 35, }); scene.activeCameraId = "side"; scene.ratio = "9:16"; scene.snapshots.push({ id: "shot", title: "侧面截图", url: "https://example.com/actual.png", width: 720, height: 1280, cameraId: "side", }); expect(readDirectorScene(JSON.parse(JSON.stringify(scene)))).toEqual(scene); }); test("director scene appearance controls persist and reject malformed colors", () => { const scene = defaultDirectorScene(); scene.skyColor = "#123456"; scene.gridVisible = false; scene.groundVisible = false; expect(readDirectorScene(JSON.parse(JSON.stringify(scene)))).toEqual(scene); expect(readDirectorScene({ ...scene, skyColor: "red;url(x)", gridVisible: "false", groundVisible: null })).toMatchObject({ skyColor: "#080808", gridVisible: true, groundVisible: true, }); }); test("corrupt persisted camera/transform data cannot enter the 3D renderer", () => { const scene = readDirectorScene({ objects: [ { id: "bad", name: "bad", kind: "box", position: [Infinity, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1], }, ], cameras: [], activeCameraId: "missing", ratio: "bad", }); expect(scene.objects).toEqual([]); expect(scene.cameras).toEqual(defaultDirectorScene().cameras); expect(scene.activeCameraId).toBe("camera-1"); expect(directorImageSize(scene.ratio)).toEqual({ width: 1280, height: 720 }); }); test("portrait and square screenshot dimensions preserve selected composition", () => { expect(directorImageSize("9:16")).toEqual({ width: 720, height: 1280 }); expect(directorImageSize("1:1")).toEqual({ width: 1280, height: 1280 }); expect(directorImageSize("3:4")).toEqual({ width: 960, height: 1280 }); }); import { validateDirectorGlb } from "@/lib/video-canvas/director-glb"; function glb(document: unknown) { const json = JSON.stringify(document); const data = new TextEncoder().encode(json + " ".repeat((4 - (json.length % 4)) % 4)); const buffer = new ArrayBuffer(20 + data.length); const view = new DataView(buffer); [0x46546c67, 2, buffer.byteLength, data.length, 0x4e4f534a].forEach((n, i) => view.setUint32(i * 4, n, true), ); new Uint8Array(buffer, 20).set(data); return buffer; } test("GLB upload requires an intact self-contained 2.0 model", () => { const document = { asset: { version: "2.0" }, meshes: [{}], buffers: [{ byteLength: 8 }], }; expect(validateDirectorGlb(glb(document))).toEqual(document); expect(() => validateDirectorGlb(new ArrayBuffer(10))).toThrow(); expect(() => validateDirectorGlb( glb({ ...document, images: [{ uri: "https://example.com/private-texture.png" }], }), ), ).toThrow("嵌入"); const broken = glb(document); new DataView(broken).setUint32(8, 25, true); expect(() => validateDirectorGlb(broken)).toThrow("GLB 2.0"); }); import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url"; test("models stored on the active media CDN use the authenticated same-origin proxy", () => { const url = "https://scqny.jiansun.vip/uploads/director.glb"; expect(buildThreeAssetUrl(url)).toBe("/api/canvas/3d/model-proxy?url=" + encodeURIComponent(url)); }); import { defaultDirectorMotion, readDirectorMotion, sampleDirectorMotion, resizeDirectorMotion, presetDirectorMotion, directorMotionPrompt, validDirectorPose, } from "@/lib/video-canvas/director-motion"; const camera = { position: [0, 2, 8], target: [0, 1, 0], fov: 50 } as const; const pose = () => ({ position: [...camera.position], target: [...camera.target], fov: 50, }) as import("@/lib/video-canvas/director-motion").DirectorPose; test("legacy scenes acquire an empty timeline without altering cameras", () => { const { motion, ...legacy } = defaultDirectorScene(); expect(readDirectorScene(legacy).motion).toEqual(defaultDirectorMotion()); expect(readDirectorScene(legacy).cameras).toEqual(legacy.cameras); }); test("motion survives serialization and duration changes preserve relative keyframe timing", () => { const scene = defaultDirectorScene(); scene.motion = presetDirectorMotion(pose(), "orbit", 5); scene.motion.loop = true; expect(readDirectorScene(JSON.parse(JSON.stringify(scene)))).toEqual(scene); const resized = resizeDirectorMotion(scene.motion, 10); expect(resized.keyframes[4].time).toBe(5); expect(resized.keyframes.at(-1)?.time).toBe(10); expect(resized.keyframes[4].position).toEqual(scene.motion.keyframes[4].position); }); test("timeline sampling clamps endpoints, holds outside keyframes and differentiates easing", () => { const motion = presetDirectorMotion(pose(), "push", 5); motion.easing = "linear"; expect(sampleDirectorMotion(motion, -20)?.position).toEqual(motion.keyframes[0].position); expect(sampleDirectorMotion(motion, 30)?.position).toEqual(motion.keyframes[1].position); expect(sampleDirectorMotion(motion, 2.5)?.position[2]).toBe(6); const linear = sampleDirectorMotion(motion, 1)?.position[2] ?? 0; motion.easing = "smooth"; expect(sampleDirectorMotion(motion, 1)?.position[2]).toBeGreaterThan(linear); motion.keyframes[0].time = 1; motion.keyframes[1].time = 4; expect(sampleDirectorMotion(motion, 0)?.position).toEqual(motion.keyframes[0].position); expect(sampleDirectorMotion(motion, 5)?.position).toEqual(motion.keyframes[1].position); expect(sampleDirectorMotion(defaultDirectorMotion(), 0)).toBeNull(); }); test("invalid, repeated, out of range or coincident keyframes are rejected", () => { const m = presetDirectorMotion(pose(), "pull"); const first = m.keyframes[0]; m.keyframes.push( { ...first, id: "duplicate-time" }, { ...first, id: "nan", time: NaN }, { ...first, id: "out", time: 16 }, { ...first, id: "broken", time: 2, position: [Infinity, 0, 1] }, { ...first, id: "coincident", time: 3, position: [...first.target] }, ); const read = readDirectorMotion(m); expect(read.keyframes).toHaveLength(2); expect(read.keyframes[0].id).toBe(first.id); }); test("all motion presets remain finite and differ from their initial camera", () => { for (const kind of ["push", "pull", "orbit", "pan"] as const) { const m = presetDirectorMotion(pose(), kind, 5); for (let t = 0; t <= 5; t += 0.025) expect(validDirectorPose(sampleDirectorMotion(m, t)!)).toBe(true); expect(sampleDirectorMotion(m, 5)?.position).not.toEqual(sampleDirectorMotion(m, 0)?.position); expect(directorMotionPrompt(m)).toContain("5 秒"); expect(directorMotionPrompt(m)).toContain("5.00秒"); } }); test("look-at remains defined when opposing keyframes cross their target", () => { const m = presetDirectorMotion(pose(), "push"); m.easing = "linear"; m.keyframes[0].position = [-1, 0, 0]; m.keyframes[0].target = [0, 0, 0]; m.keyframes[1].position = [1, 0, 0]; m.keyframes[1].target = [0, 0, 0]; expect(validDirectorPose(sampleDirectorMotion(m, 2.5)!)).toBe(true); }); import { recordDirectorVideo } from "@/lib/video-canvas/director-recording"; test("recording stops tracks and rejects partial exports on cancel or background switch", async () => { for (const outcome of [ "complete", "complete-webm", "cancel", "hidden", "render-error", "warmup-cancel", "warmup-hidden", ] as const) { const saved = new Map(); const install = (key: string, value: unknown) => { saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); Object.defineProperty(globalThis, key, { configurable: true, writable: true, value, }); }; const doc = new EventTarget() as EventTarget & { hidden: boolean }; doc.hidden = false; const callbacks = new Map(); let id = 0, stopped = 0; class Recorder { static isTypeSupported(type: string) { return type === (outcome === "complete-webm" ? "video/webm;codecs=vp8" : "video/mp4"); } state = "inactive"; mimeType: string; constructor(_stream: unknown, options: { mimeType: string }) { this.mimeType = options.mimeType; } onstop?: () => void; ondataavailable?: (e: { data: Blob }) => void; start() { this.state = "recording"; } stop() { this.state = "inactive"; this.ondataavailable?.({ data: new Blob(["recorded"]) }); queueMicrotask(() => this.onstop?.()); } } install("document", doc); install("MediaRecorder", Recorder); install("requestAnimationFrame", (fn: FrameRequestCallback) => { callbacks.set(++id, fn); return id; }); install("cancelAnimationFrame", (n: number) => callbacks.delete(n)); try { const controller = new AbortController(); const rendered: number[] = []; const canvas = { captureStream: () => ({ getTracks: () => [{ stop: () => stopped++ }] }), } as unknown as HTMLCanvasElement; const result = recordDirectorVideo({ canvas, duration: 2, signal: controller.signal, render: (t) => { if (outcome === "render-error" && t > 0) throw new Error("render fault"); rendered.push(t); }, }); const step = (time: number) => { const pending = [...callbacks.values()]; callbacks.clear(); pending.forEach((fn) => fn(time)); }; const warming = outcome.startsWith("warmup-"); if (!warming) { step(10); step(20); await Promise.resolve(); } if (outcome === "complete" || outcome === "complete-webm") { step(1000); step(3000); step(3033); step(3066); expect((await result).type).toBe( outcome === "complete-webm" ? "video/webm;codecs=vp8" : "video/mp4", ); expect(rendered.at(-1)).toBe(2); } else { const rejected = expect(result).rejects.toThrow(); if (outcome === "cancel" || outcome === "warmup-cancel") controller.abort(); else if (outcome === "hidden" || outcome === "warmup-hidden") { doc.hidden = true; doc.dispatchEvent(new Event("visibilitychange")); } else { step(1000); step(3000); } await rejected; } expect(stopped).toBe(warming ? 0 : 1); expect(callbacks.size).toBe(0); } finally { for (const [key, descriptor] of saved) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); } } } }); test("AI motion prompt describes the movement and stays bounded for large timelines", () => { expect(directorMotionPrompt(presetDirectorMotion(pose(), "push"))).toContain("推进"); expect(directorMotionPrompt(presetDirectorMotion(pose(), "pull"))).toContain("拉远"); expect(directorMotionPrompt(presetDirectorMotion(pose(), "orbit"))).toContain("围绕"); expect(directorMotionPrompt(presetDirectorMotion(pose(), "pan"))).toContain("平移"); const m = presetDirectorMotion(pose(), "orbit", 15); m.keyframes = Array.from({ length: 100 }, (_, i) => ({ id: String(i), time: (i / 99) * 15, ...sampleDirectorMotion(m, (i / 99) * 15)!, })); expect(directorMotionPrompt(m).length).toBeLessThan(1500); }); import { mergeDirectorCameraPose } from "@/lib/video-canvas/director-motion"; test("editing a keyframe cannot replace the active camera identity", () => { const c = defaultDirectorScene().cameras[0]; const key = presetDirectorMotion(pose(), "push").keyframes[1]; const edited = mergeDirectorCameraPose(c, key); expect(edited.id).toBe(c.id); expect(edited.name).toBe(c.name); expect(edited.position).toEqual(key.position); expect(mergeDirectorCameraPose(edited, { fov: 65 }).fov).toBe(65); }); test("director appearance, pose, visibility and locking survive save/reload without changing transforms", () => { const scene = defaultDirectorScene(); Object.assign(scene.objects[0], { pose: "跑步", color: "#123abc", visible: false, locked: true }); expect(readDirectorScene(JSON.parse(JSON.stringify(scene)))).toEqual(scene); const invalid = JSON.parse(JSON.stringify(scene)); invalid.objects[0].pose = "unknown"; invalid.objects[0].color = "url(evil)"; const loaded = readDirectorScene(invalid); expect(loaded.objects[0].pose).toBeUndefined(); expect(loaded.objects[0].color).toBeUndefined(); expect(loaded.objects[0].position).toEqual(scene.objects[0].position); }); import { commitDirectorScene, resolveDirectorCamera, cameraAngles, rotateDirectorCamera, createDirectorCrowd, transformDirectorMotion, directorBodies, } from "@/lib/video-canvas/director-extensions"; test("each camera retains its own motion track across switching and reload", () => { let scene = defaultDirectorScene(); scene.motion = presetDirectorMotion(scene.cameras[0], "push", 5); const original = scene.motion; scene = commitDirectorScene(scene, { ...scene, cameras: [...scene.cameras, { ...scene.cameras[0], id: "camera-2", name: "second" }], activeCameraId: "camera-2", }); expect(scene.motion.keyframes).toEqual([]); const second = presetDirectorMotion(scene.cameras[1], "orbit", 5); scene = commitDirectorScene(scene, { ...scene, motion: second }); scene = commitDirectorScene(scene, { ...scene, activeCameraId: "camera-1" }); expect(scene.motion).toEqual(original); expect(scene.motionTracks?.["camera-2"]).toEqual(second); const restored = readDirectorScene(JSON.parse(JSON.stringify(scene))); expect(restored.motionTracks?.["camera-2"]).toEqual(second); }); test("following an object updates camera target and follows the same rule during motion export", () => { const scene = defaultDirectorScene(); scene.cameras[0].followObjectId = scene.objects[0].id; scene.objects[0].position = [3, 2, 1]; scene.objects[0].scale = [1, 2, 1]; expect(resolveDirectorCamera(scene).target).toEqual([3, 4, 1]); const pose = { position: [10, 5, 4] as [number, number, number], target: [0, 0, 0] as [number, number, number], fov: 50, }; expect(resolveDirectorCamera(scene, pose)).toMatchObject({ position: [10, 5, 4], target: [3, 4, 1], }); scene.objects = []; expect(readDirectorScene(scene).cameras[0].followObjectId).toBeUndefined(); }); test("camera yaw and pitch preserve distance and remain finite at vertical extremes", () => { const camera = defaultDirectorScene().cameras[0]; const distance = Math.hypot(...camera.target.map((n, i) => n - camera.position[i])); const target = rotateDirectorCamera(camera, 90, 30); const angles = cameraAngles({ ...camera, target }); expect(angles.yaw).toBeCloseTo(90); expect(angles.pitch).toBeCloseTo(30); expect(Math.hypot(...target.map((n, i) => n - camera.position[i]))).toBeCloseTo(distance); expect( cameraAngles({ ...camera, target: rotateDirectorCamera(camera, 0, 200) }).pitch, ).toBeCloseTo(89); }); test("crowd copies have independent IDs and transforms; body selection survives reload", () => { const scene = defaultDirectorScene(); scene.objects[0].body = "幼童"; let id = 0; const copies = createDirectorCrowd(scene.objects[0], 1, () => String(++id)); expect(copies).toHaveLength(8); expect(new Set(copies.map((o) => o.id)).size).toBe(8); expect(copies[7].position).toEqual([3, 0, 3]); expect(() => createDirectorCrowd(scene.objects[0], 199, () => "")).toThrow("200"); expect(Object.keys(directorBodies)).toHaveLength(8); expect(readDirectorScene(scene).objects[0].body).toBe("幼童"); }); test("whole-path transforms change camera and target together while preserving duration and distance", () => { const scene = defaultDirectorScene(), motion = presetDirectorMotion(scene.cameras[0], "push", 5); const moved = transformDirectorMotion(motion, [1, 2, 3], 90); expect(moved.duration).toBe(motion.duration); moved.keyframes.forEach((k, i) => expect(Math.hypot(...k.position.map((n, j) => n - k.target[j]))).toBeCloseTo( Math.hypot(...motion.keyframes[i].position.map((n, j) => n - motion.keyframes[i].target[j])), ), ); expect(moved.keyframes[0].target).toEqual([ motion.keyframes[0].target[0] + 1, motion.keyframes[0].target[1] + 2, motion.keyframes[0].target[2] + 3, ]); }); import { recordObjectFrames, sampleDirectorObjects, readObjectTracks, } from "@/lib/video-canvas/director-object-tracks"; import { parseReconstructedScene, insertReconstructedScene, } from "@/lib/video-canvas/director-reconstruction"; test("object tracks interpolate independently, persist, mute and preserve shortest rotation", () => { let scene = defaultDirectorScene(); const id = scene.objects[0].id; scene = recordObjectFrames(scene, [id], 0, () => "key-0"); scene.objects = scene.objects.map((o) => o.id === id ? { ...o, position: [10, 2, 4], rotation: [0, Math.PI * 2 - 0.2, 0], scale: [2, 3, 4], visible: false, } : o, ); scene = recordObjectFrames(scene, [id], scene.motion.duration, () => "key-1"); const mid = sampleDirectorObjects(scene, scene.motion.duration / 2)[0]; expect(mid.position[0]).toBeCloseTo((scene.objectTracks![id].keyframes[0].position[0] + 10) / 2); expect(mid.rotation[1]).toBeCloseTo(-0.1); expect(mid.visible).toBe(true); expect(sampleDirectorObjects(scene, scene.motion.duration)[0].visible).toBe(false); const restored = readDirectorScene(JSON.parse(JSON.stringify(scene))); expect(restored.objectTracks).toEqual(scene.objectTracks); scene.objectTracks![id].muted = true; expect(sampleDirectorObjects(scene, 0)[0]).toEqual(scene.objects[0]); }); test("malformed/orphan object tracks are excluded and recording replaces exact frame", () => { let scene = defaultDirectorScene(); const id = scene.objects[0].id; scene = recordObjectFrames(scene, [id], 0, () => "one"); scene = recordObjectFrames(scene, [id], 0, () => "two"); expect(scene.objectTracks![id].keyframes).toHaveLength(1); const valid = scene.objectTracks![id]; expect( readObjectTracks( { ghost: valid, [id]: { ...valid, keyframes: [...valid.keyframes, { ...valid.keyframes[0], id: "bad", time: Infinity }], }, }, scene.objects, scene.motion.duration, ), ).toEqual({ [id]: valid }); expect(() => recordObjectFrames(scene, [id], -1, () => "bad")).toThrow(); }); test("reference reconstruction validates geometry and merges without clobbering original objects", () => { const generated = parseReconstructedScene({ summary: "Approximate geometry", objects: [ { name: "table", kind: "box", position: [0, 0.5, 0], rotation: [0, 0, 0], scale: [2, 1, 1], color: "#ffffff", }, ], camera: { position: [3, 3, 3], target: [0, 0.5, 0], fov: 45 }, }); const scene = defaultDirectorScene(); let count = 0; const insert = insertReconstructedScene( scene, generated.directorScene, false, () => `added-${count++}`, ); expect(insert.objects.length).toBe(scene.objects.length + 1); expect(insert.objects[0]).toEqual(scene.objects[0]); expect(new Set(insert.objects.map((o) => o.id)).size).toBe(insert.objects.length); const replaced = insertReconstructedScene( scene, generated.directorScene, true, () => `new-${count++}`, ); expect(replaced.objects).toHaveLength(1); expect(replaced.objectTracks).toEqual({}); expect(() => parseReconstructedScene({ summary: "bad", objects: [], camera: { position: [0, 0, 0], target: [0, 0, 0], fov: 45 }, }), ).toThrow(); }); test("global duration rescales object tracks and deleting an object deletes its track", () => { let scene = defaultDirectorScene(); const id = scene.objects[0].id; scene = recordObjectFrames(scene, [id], scene.motion.duration, () => "last"); const old = scene.motion.duration; const changed = commitDirectorScene(scene, { ...scene, motion: { ...scene.motion, duration: old * 2 }, }); expect(changed.objectTracks![id].keyframes[0].time).toBe(old * 2); expect(commitDirectorScene(changed, { ...changed, objects: [] }).objectTracks).toEqual({}); });