feat(video-canvas): connect real 3d director and validate media chain

This commit is contained in:
134 2026-09-17 00:02:03 +08:00
parent 598f5f617a
commit 38be09de9c
14 changed files with 1320 additions and 158 deletions

View File

@ -14,6 +14,7 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
"worldlabs.ai",
"s3.1080p.hk",
"img.yydd.ai",
"scqny.jiansun.vip",
"blob.vercel-storage.com",
"aliyuncs.com",
"amazonaws.com",

View File

@ -102,7 +102,7 @@ export async function GET(
// 更新画布(支持 upsert:如果不存在则创建)
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
@ -116,29 +116,33 @@ export async function PUT(
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 },
);
}
const { title, description, thumbnail, canvasData } = body;
const baseCanvasRevision = body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
const baseCanvasRevision =
body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
const workspaceType =
body && Object.prototype.hasOwnProperty.call(body, "workspaceType")
? resolveWorkspaceType(body.workspaceType)
: undefined;
// 先检查画布是否存在
const [existingProject] = await db
.select()
.from(canvasProject)
.where(
and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id)
return await db.transaction(async (tx) => {
const [existingProject] = await tx
.select()
.from(canvasProject)
.where(
and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id),
),
)
);
if (existingProject) {
.for("update");
if (
existingProject &&
canvasData !== undefined &&
baseCanvasRevision &&
isStaleCanvasSaveBaseRevision({
@ -149,101 +153,64 @@ export async function PUT(
) {
return NextResponse.json(
buildStaleCanvasSaveConflictPayload(existingProject.updatedAt),
{ status: 409 }
{ status: 409 },
);
}
if (!existingProject) {
// The parent must exist before node/asset foreign keys are synchronized.
const [created] = await tx
.insert(canvasProject)
.values({
id,
userId: session.user.id,
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
workspaceType: workspaceType || "canvas",
canvasData: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
.onConflictDoNothing({ target: canvasProject.id })
.returning();
if (!created)
return NextResponse.json(
{ success: false, error: "画布已被创建,请重新加载后保存" },
{ status: 409 },
);
}
const syncedCanvasData =
canvasData !== undefined
? await syncCanvasNodesForProject({
projectId: id,
userId: session.user.id,
canvasData,
})
? await syncCanvasNodesForProject(
{ projectId: id, userId: session.user.id, canvasData },
tx,
)
: undefined;
// 更新现有画布
const updateData: any = { updatedAt: new Date() };
if (title !== undefined) updateData.title = title;
if (description !== undefined) updateData.description = description;
if (thumbnail !== undefined) updateData.thumbnail = thumbnail;
if (workspaceType !== undefined) updateData.workspaceType = workspaceType;
if (syncedCanvasData !== undefined) updateData.canvasData = syncedCanvasData;
const [updatedProject] = await db
if (syncedCanvasData !== undefined)
updateData.canvasData = syncedCanvasData;
const [saved] = await tx
.update(canvasProject)
.set(updateData)
.where(
and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id)
)
eq(canvasProject.userId, session.user.id),
),
)
.returning();
return NextResponse.json({ success: true, data: updatedProject });
} else {
const syncedCanvasData =
canvasData !== undefined
? await syncCanvasNodesForProject({
projectId: id,
userId: session.user.id,
canvasData,
})
: undefined;
const nextWorkspaceType = workspaceType || "canvas";
const nextCanvasData = syncedCanvasData || {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
};
const nextTitle = title || getDefaultWorkspaceTitle(nextWorkspaceType);
const insertData: any = {
id, // 使用指定的 ID
userId: session.user.id,
title: nextTitle,
workspaceType: nextWorkspaceType,
canvasData: nextCanvasData,
};
const conflictUpdateData: any = {
title: nextTitle,
workspaceType: nextWorkspaceType,
canvasData: nextCanvasData,
updatedAt: new Date(),
};
if (description !== undefined) {
insertData.description = description;
conflictUpdateData.description = description;
}
if (thumbnail !== undefined) {
insertData.thumbnail = thumbnail;
conflictUpdateData.thumbnail = thumbnail;
}
// 创建新画布(使用指定 ID),并兼容同一用户并发创建同一画布的场景。
const [newProject] = await db
.insert(canvasProject)
.values(insertData)
.onConflictDoUpdate({
target: canvasProject.id,
set: conflictUpdateData,
where: eq(canvasProject.userId, session.user.id),
})
.returning();
if (!newProject) {
return NextResponse.json(
{ success: false, error: '画布ID已被占用' },
{ status: 409 }
);
}
return NextResponse.json({ success: true, data: newProject });
}
return NextResponse.json({ success: true, data: saved });
});
} catch (error) {
console.error('更新/创建画布失败:', error);
return NextResponse.json({ success: false, error: '更新画布失败' }, { status: 500 });
console.error("更新/创建画布失败:", error);
return NextResponse.json(
{ success: false, error: "更新画布失败" },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import {
DIRECTOR_MAX_GLB_BYTES,
validateDirectorGlb,
} from "@/lib/video-canvas/director-glb";
export const runtime = "nodejs";
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const form = await request.formData();
const file = form.get("file");
if (
!(file instanceof File) ||
!/\.glb$/i.test(file.name) ||
file.size > DIRECTOR_MAX_GLB_BYTES
)
return NextResponse.json(
{ error: "请上传 25 MiB 以内的 GLB 模型" },
{ status: 400 },
);
const buffer = await file.arrayBuffer();
try {
validateDirectorGlb(buffer);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "无效 GLB" },
{ status: 400 },
);
}
const result = await uploadFile(
`director-${crypto.randomUUID()}.glb`,
buffer,
);
return NextResponse.json({ success: true, data: result });
} catch {
return NextResponse.json(
{ error: "模型上传失败,请重试" },
{ status: 500 },
);
}
}

View File

@ -1,4 +1,6 @@
import { generateText } from "ai";
import { createLogger } from "@/lib/observability/logger";
const logger = createLogger("canvas-prompt-tools");
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
@ -50,6 +52,8 @@ export async function POST(request: Request) {
if (!text.trim()) throw new Error("empty model output");
return NextResponse.json({ success: true, data: { text: text.trim(), model: selected.id, modelName: selected.displayName || selected.modelId } });
} catch (error) {
const detail = error as { name?: string; statusCode?: number; cause?: { code?: string }; data?: { error?: { code?: string } } };
logger.warn("Prompt tool request failed", { name: detail?.name, statusCode: detail?.statusCode, code: detail?.data?.error?.code || detail?.cause?.code });
return NextResponse.json({ success: false, error: promptToolErrorMessage(error) }, { status: 502 });
} finally {
running.delete(session.user.id);

View File

@ -1,4 +1,10 @@
"use client";
import dynamic from "next/dynamic";
const DirectorStudioDialog = dynamic(
() => import("@/components/canvas/director/DirectorStudioDialog").then(module => module.DirectorStudioDialog),
{ ssr: false },
);
import type { DirectorSnapshot } from "@/lib/video-canvas/director-scene";
import { filterCanvasModels, getCanvasVideoModes, getCanvasVideoParameters, type CanvasModelKind } from "@/lib/video-canvas/model-matching";
import {
@ -1084,7 +1090,7 @@ const LOCAL_EDIT_COLORS = [
"#8d6e63",
];
const VIDEO_CLIP_FRAME_COUNT = 9;
const VIDEO_CROP_INLINE_LABEL = "1024 x 576";
const VIDEO_CROP_OUTPUT_LABEL = "1024 × 576";
const VIDEO_CROP_RECT = { x: 0.1, y: 0.1, width: 0.8, height: 0.8 };
const STORY_BEAT_GRID_FUNCTION_KEY = "story-beat-grid-4";
@ -2257,6 +2263,13 @@ function getVideoNodeDurationSeconds(node: CloneNode | null | undefined) {
return 5;
}
function getVideoCropSelectionLabel(node: CloneNode) {
const rect = (node.draftValues?.cropRect || VIDEO_CROP_RECT) as GridCropRect;
const dimensions = getDraftString(node, "outputSizeLabel").match(/(\d+)\s*[×x]\s*(\d+)/);
if (!dimensions) return `裁剪区域 ${Math.round(rect.width * 100)}% × ${Math.round(rect.height * 100)}%`;
return `${Math.floor(Number(dimensions[1]) * rect.width / 2) * 2} × ${Math.floor(Number(dimensions[2]) * rect.height / 2) * 2}`;
}
function formatClipDurationLabel(seconds: number) {
const normalized = Math.max(0.1, seconds);
return `${normalized.toFixed(2)} s`;
@ -5004,17 +5017,17 @@ export function LiblibVideoCanvasReplica({
}, []);
const sendDirectorSnapshotsToCanvas = useCallback(
(directorNode: CloneNode, snapshots: Array<{ id: string; title: string }>) => {
(directorNode: CloneNode, snapshots: DirectorSnapshot[]) => {
if (snapshots.length === 0) return;
const nextNodes = snapshots.map((snapshot, index) => {
const created = createMediaResourceNode({
action: "DIRECTOR_CAMERA_SNAPSHOT",
height: DIRECTOR_CAMERA_SNAPSHOT_NODE_HEIGHT,
height: Math.round(350 * snapshot.height / snapshot.width),
mediaType: "image",
mediaUrl: FIRST_FRAME_IMAGE_URL,
mediaUrl: snapshot.url,
title: `${directorNode.title} ${snapshot.title}`,
width: DIRECTOR_CAMERA_SNAPSHOT_NODE_WIDTH,
width: 350,
x: Math.round(directorNode.x + DIRECTOR_CAMERA_SNAPSHOT_NODE_OFFSET_X * (index + 1)),
y: directorNode.y,
});
@ -6377,44 +6390,19 @@ export function LiblibVideoCanvasReplica({
selectedId = video.id;
break;
}
case "首帧生成视频": {
draftUpdates = { action: "VIDEO_GENERATE", params: { prompt: VIDEO_FIRST_FRAME_PROMPT } };
connectToAnchor(
createMediaResourceNode({
mediaType: "image",
mediaUrl: VIDEO_FIRST_FRAME_SOURCE_URL,
title: "首帧",
width: 420,
height: 280,
x: leftOfAnchor(420),
y: centerY(280),
})
);
selectedId = anchor.id;
break;
}
case "首帧生成视频":
case "首尾帧生成视频": {
draftUpdates = { action: "VIDEO_GENERATE", params: { prompt: VIDEO_FIRST_LAST_PROMPT } };
const first = createMediaResourceNode({
mediaType: "image",
mediaUrl: VIDEO_FIRST_LAST_FIRST_URL,
title: "首帧",
width: 420,
height: 240,
x: leftOfAnchor(420),
y: Math.round(anchor.y + 18),
});
const last = createMediaResourceNode({
mediaType: "image",
mediaUrl: VIDEO_FIRST_LAST_LAST_URL,
title: "尾帧",
width: 420,
height: 240,
x: leftOfAnchor(420),
y: Math.round(anchor.y + 286),
});
connectToAnchor(first);
connectToAnchor(last);
const firstOnly = actionLabel === "首帧生成视频";
draftUpdates = {
action: "VIDEO_GENERATE",
params: {
...(anchor.draftValues?.params as Record<string, unknown> | undefined),
mode: firstOnly ? "first-frame" : "first-last-frame",
prompt: firstOnly ? VIDEO_FIRST_FRAME_PROMPT : VIDEO_FIRST_LAST_PROMPT,
},
};
setReferenceSelectionTargetId(anchor.id);
toast.info(firstOnly ? "请选择画布中的首帧图片" : "请依次选择首帧和尾帧图片");
selectedId = anchor.id;
break;
}
@ -8190,6 +8178,7 @@ export function LiblibVideoCanvasReplica({
mode: requestedMode,
preferredModelRef,
prompt: finalPrompt,
generateAudio: savedSettings.audio !== "关闭",
ratio,
resolution,
});
@ -10946,7 +10935,7 @@ export function LiblibVideoCanvasReplica({
<>
<VideoCropSelectionOverlay anchor={selectedVideoAnchor} node={selectedVideoMediaNode} onChange={rect => setNodes(current => current.map(node => node.id === selectedVideoMediaNode.id ? {...node,draftValues:{...node.draftValues,cropRect:rect}} : node))} />
<VideoInlineActionBar
actionLabel={VIDEO_CROP_INLINE_LABEL}
actionLabel={getVideoCropSelectionLabel(selectedVideoMediaNode)}
anchor={selectedVideoAnchor}
applyBusyLabel={
selectedVideoCropGenerateState
@ -11505,12 +11494,11 @@ export function LiblibVideoCanvasReplica({
) : null}
{directorConsoleNode ? (
<DirectorConsole3DDialog
node={directorConsoleNode}
onSendSnapshotsToCanvas={(snapshots) => sendDirectorSnapshotsToCanvas(directorConsoleNode, snapshots)}
onTutorialActiveChange={setDirectorTutorialActive}
<DirectorStudioDialog
initialScene={directorConsoleNode.draftValues?.directorScene}
onChange={scene => setNodes(current => current.map(node => node.id === directorConsoleNode.id ? {...node, draftValues: {...node.draftValues, directorScene: scene}} : node))}
onSend={snapshots => sendDirectorSnapshotsToCanvas(directorConsoleNode, snapshots)}
onClose={() => setDirectorConsoleNodeId(null)}
tutorialActive={directorTutorialActive}
/>
) : null}
@ -12986,7 +12974,7 @@ function VideoCropSelectionOverlay({anchor,node,onChange}:{anchor:PresetControlA
const [drawing,setDrawing]=useState<{x:number;y:number;endX:number;endY:number}|null>(null);
const rect=drawing ? {x:Math.min(drawing.x,drawing.endX),y:Math.min(drawing.y,drawing.endY),width:Math.abs(drawing.endX-drawing.x),height:Math.abs(drawing.endY-drawing.y)} : saved;
const point=(event:ReactPointerEvent<HTMLDivElement>)=>({x:clampUnit((event.clientX-box.left)/box.width),y:clampUnit((event.clientY-box.top)/box.height)});
return <div className="absolute z-[31] cursor-crosshair" data-canvas-overlay="true" aria-label="拖动框选视频裁剪区域" style={{left:box.left,top:box.top,width:box.width,height:box.height}}
return <div className="pointer-events-auto absolute z-[31] cursor-crosshair" data-canvas-overlay="true" aria-label="拖动框选视频裁剪区域" style={{left:box.left,top:box.top,width:box.width,height:box.height}}
onPointerDown={event=>{event.stopPropagation();event.currentTarget.setPointerCapture(event.pointerId);const p=point(event);setDrawing({...p,endX:p.x,endY:p.y});}}
onPointerMove={event=>{if(!drawing)return;event.stopPropagation();const p=point(event);setDrawing({...drawing,endX:p.x,endY:p.y});}}
onPointerUp={event=>{event.stopPropagation();if(drawing&&rect.width>.02&&rect.height>.02)onChange(rect);setDrawing(null);event.currentTarget.releasePointerCapture(event.pointerId);}}
@ -14799,8 +14787,8 @@ function VideoTargetModuleComposer({
const tabItems: Array<{ key: VideoComposerTabKey; label: string; enabled: boolean }> = [
{ key: "text-to-video", label: "文生视频", enabled: referenceCount === 0 },
{ key: "reference-images", label: "全能参考", enabled: referenceCount >= 1 },
{ key: "first-frame", label: "图生视频", enabled: referenceCount >= 1 },
{ key: "first-last-frame", label: "首尾帧", enabled: referenceCount >= 2 },
{ key: "first-frame", label: "图生视频", enabled: true },
{ key: "first-last-frame", label: "首尾帧", enabled: true },
{ key: "image-reference", label: "图片参考", enabled: referenceCount >= 1 },
];
const referenceThumbnails = imageSources
@ -14919,7 +14907,11 @@ function VideoTargetModuleComposer({
const activeModelButtonLabel = getVideoComposerModelShortLabel(getVideoComposerModelLabel(settings, modelOptions));
const activeModelButtonMinWidth =
activeModelButtonLabel === "2.0 Fast" ? 115 : activeModelButtonLabel === "2.0 Mini" ? 113 : 91;
const canSubmit = Boolean(
const missingFrameMessage = activeMode === "first-frame" && referenceCount < 1
? "请选择首帧图片"
: activeMode === "first-last-frame" && referenceCount < 2
? "请选择首帧和尾帧图片" : "";
const canSubmit = !missingFrameMessage && Boolean(
prompt.trim() ||
referenceCount > 0 ||
magicMarks.length > 0 ||
@ -15400,7 +15392,7 @@ function VideoTargetModuleComposer({
tab: activeTab,
})
}
title={canSubmit ? visibleBillingDisplay.message : "请输入提示词、添加参考素材、标记元素或视频特效"}
title={missingFrameMessage || (canSubmit ? visibleBillingDisplay.message : "请输入提示词、添加参考素材、标记元素或视频特效")}
type="button"
>
<ArrowUp className="h-[18px] w-[18px]" aria-hidden="true" />
@ -21561,7 +21553,16 @@ function OfficialAudioCard({
mediaUrl: string;
selected: boolean;
}) {
const durationLabel = `00:00 / ${formatAudioTimestamp(durationSeconds)}`;
const audioRef = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(durationSeconds);
const durationLabel = `${formatAudioTimestamp(currentTime)} / ${formatAudioTimestamp(duration)}`;
useEffect(() => {
setPlaying(false);
setCurrentTime(0);
setDuration(durationSeconds);
}, [mediaUrl, durationSeconds]);
return (
<div
className={cn(
@ -21569,6 +21570,20 @@ function OfficialAudioCard({
selected && "outline outline-2 outline-white/70"
)}
>
<audio
ref={audioRef}
src={mediaUrl || undefined}
preload="metadata"
onLoadedMetadata={(event) => {
const value = event.currentTarget.duration;
if (Number.isFinite(value)) setDuration(value);
}}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => setPlaying(false)}
onError={() => { setPlaying(false); toast.error("音频加载失败,请重试或下载原文件。"); }}
/>
<div className="absolute left-3 right-3 top-3 h-[74px] overflow-hidden rounded-[8px] bg-[#343434]">
<span className="absolute left-0 top-0 h-full w-[3px] bg-[#f23a52]" />
<div className="absolute bottom-3 left-4 right-4 flex h-[42px] items-end gap-[5px]">
@ -21585,11 +21600,21 @@ function OfficialAudioCard({
<span>{durationLabel}</span>
</div>
<button
aria-label={playing ? "暂停音频" : "播放音频"}
className="absolute bottom-3 left-1/2 flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full bg-[#303030] text-white/78 transition hover:bg-black"
onClick={() => toast.info("音频播放控制待接入。")}
disabled={!mediaUrl}
onPointerDown={(event) => event.stopPropagation()}
onClick={async (event) => {
event.stopPropagation();
const audio = audioRef.current;
if (!audio) return;
if (!audio.paused) { audio.pause(); return; }
try { await audio.play(); }
catch { toast.error("音频播放失败,请重试或下载原文件。"); }
}}
type="button"
>
<Play className="ml-0.5 h-4 w-4 fill-current" strokeWidth={0} />
{playing ? <Pause className="h-4 w-4 fill-current" strokeWidth={0} /> : <Play className="ml-0.5 h-4 w-4 fill-current" strokeWidth={0} />}
</button>
{mediaUrl ? (
<a

View File

@ -0,0 +1,438 @@
"use client";
import { useRef, useState } from "react";
import { Camera, Plus, Upload, X, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
DirectorViewport,
type DirectorViewportHandle,
} from "./DirectorViewport";
import {
readDirectorScene,
directorRatios,
type DirectorScene,
type DirectorSnapshot,
type DirectorObject,
type DirectorVector,
} from "@/lib/video-canvas/director-scene";
async function upload(file: File) {
const form = new FormData();
form.append("file", file);
form.append("purpose", "director-studio");
const response = await fetch(
/\.glb$/i.test(file.name)
? "/api/canvas/director/upload-model"
: "/api/files/upload",
{
method: "POST",
body: form,
signal: AbortSignal.timeout(120000),
},
);
const result = await response.json();
const url = result.url || result.data?.url;
if (!response.ok || !url) throw Error(result.error || "上传失败");
return String(url);
}
export function DirectorStudioDialog({
initialScene,
onChange,
onClose,
onSend,
}: {
initialScene: unknown;
onChange: (scene: DirectorScene) => void;
onClose: () => void;
onSend: (snapshots: DirectorSnapshot[]) => void;
}) {
const [scene, setScene] = useState(() => readDirectorScene(initialScene));
const current = useRef(scene);
current.current = scene;
const commit = (next: DirectorScene) => {
current.current = next;
setScene(next);
onChange(next);
};
const [selected, setSelected] = useState(scene.objects[0]?.id || "");
const [mode, setMode] = useState<"translate" | "rotate" | "scale">(
"translate",
);
const [cameraView, setCameraView] = useState(false);
const [busy, setBusy] = useState(false);
const viewport = useRef<DirectorViewportHandle>(null);
const fileInput = useRef<HTMLInputElement>(null);
const camera =
scene.cameras.find((c) => c.id === scene.activeCameraId) ||
scene.cameras[0];
const object = scene.objects.find((o) => o.id === selected);
const patchObject = (id: string, patch: Partial<DirectorObject>) =>
commit({
...current.current,
objects: current.current.objects.map((o) =>
o.id === id ? { ...o, ...patch } : o,
),
});
const patchCamera = (patch: Partial<typeof camera>) =>
commit({
...current.current,
cameras: current.current.cameras.map((c) =>
c.id === current.current.activeCameraId ? { ...c, ...patch } : c,
),
});
const addObject = (
kind: DirectorObject["kind"],
name: string,
url?: string,
) => {
const id = crypto.randomUUID();
commit({
...current.current,
objects: [
...current.current.objects,
{
id,
kind,
name,
url,
position: [current.current.objects.length * 1.2, 0.5, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
],
});
setSelected(id);
};
const screenshot = async () => {
if (busy) return;
setBusy(true);
try {
const image = await viewport.current!.capture();
const url = await upload(
new File([image.blob], `director-${Date.now()}.png`, {
type: "image/png",
}),
);
const active = current.current.cameras.find(
(c) => c.id === current.current.activeCameraId,
)!;
const snapshot = {
id: crypto.randomUUID(),
title: `${active.name}-截图${current.current.snapshots.length + 1}`,
url,
width: image.width,
height: image.height,
cameraId: active.id,
};
commit({
...current.current,
snapshots: [...current.current.snapshots, snapshot],
});
toast.success("真实机位截图已保存");
} catch (e) {
toast.error(e instanceof Error ? e.message : "截图失败");
} finally {
setBusy(false);
}
};
const field = "w-full rounded bg-white/10 px-2 py-1.5 text-sm outline-none";
const button =
"rounded-lg border border-white/10 px-3 py-2 text-sm hover:bg-white/10 disabled:opacity-40";
return (
<div
role="dialog"
aria-label="3D导演台"
data-canvas-overlay="true"
className="fixed inset-0 z-[600] flex flex-col bg-[#141414] text-white"
onPointerDown={(e) => e.stopPropagation()}
>
<header className="flex h-16 shrink-0 items-center justify-between border-b border-white/10 px-5">
<strong>3D导演台</strong>
<div className="flex gap-2">
<button
className={button}
aria-pressed={!cameraView}
onClick={() => setCameraView(false)}
>
导演视角
</button>
<button
className={button}
aria-pressed={cameraView}
onClick={() => setCameraView(true)}
>
机位视角
</button>
</div>
<button aria-label="关闭导演台" className={button} onClick={onClose}>
<X size={18} />
</button>
</header>
<div className="grid min-h-0 flex-1 grid-cols-[210px_minmax(0,1fr)_260px]">
<aside className="overflow-y-auto border-r border-white/10 p-4">
<div className="mb-3 text-sm text-white/60">场景对象</div>
<div className="space-y-2">
{scene.objects.map((o) => (
<button
className={`${button} w-full text-left ${selected === o.id ? "bg-white/15" : ""}`}
key={o.id}
onClick={() => setSelected(o.id)}
>
{o.name}
</button>
))}
</div>
<div className="mt-5 flex flex-col gap-2">
<button
className={button}
onClick={() => addObject("actor", "基础人偶")}
>
添加人偶
</button>
<button
className={button}
onClick={() => addObject("box", "立方体")}
>
添加立方体
</button>
<button
className={button}
onClick={() => addObject("sphere", "球体")}
>
添加球体
</button>
<button
className={button}
disabled={busy}
onClick={() => fileInput.current?.click()}
>
<Upload size={14} className="mr-2 inline" />
上传 GLB 模型
</button>
<button
className={button}
disabled
title="尚未接入图片识别与场景重建"
>
AI 识图导入(未接通)
</button>
</div>
<p className="mt-4 text-xs leading-5 text-white/45">
左键旋转视角,右键平移,滚轮缩放。选择对象后使用变换轴调整。
</p>
</aside>
<main className="relative flex min-h-0 items-center justify-center overflow-hidden bg-[#080808]">
<div
className="h-full max-h-full w-full"
style={{ aspectRatio: scene.ratio.replace(":", " / ") }}
>
<DirectorViewport
ref={viewport}
scene={scene}
selected={selected}
mode={mode}
cameraView={cameraView}
onTransform={patchObject}
onCamera={(position, target) => patchCamera({ position, target })}
/>
</div>
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-1 rounded-xl bg-black/75 p-2">
{(["translate", "rotate", "scale"] as const).map((m, i) => (
<button
key={m}
className={`${button} ${mode === m ? "bg-white/20" : ""}`}
onClick={() => setMode(m)}
>
{["移动", "旋转", "缩放"][i]}
</button>
))}
<button className={button} disabled={busy} onClick={screenshot}>
<Camera className="mr-1 inline" size={16} />
{busy ? "处理中..." : "截图"}
</button>
</div>
</main>
<aside className="space-y-4 overflow-y-auto border-l border-white/10 p-4">
<label className="block text-sm">
机位
<select
aria-label="机位"
className={`${field} mt-2`}
value={scene.activeCameraId}
onChange={(e) =>
commit({ ...current.current, activeCameraId: e.target.value })
}
>
{scene.cameras.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<div className="flex flex-wrap gap-1">
{[
["正面", [0, 2, 7]],
["侧面", [7, 2, 0]],
["俯视", [0, 8, 0.1]],
].map(([name, position]) => (
<button
className={button}
key={String(name)}
onClick={() => {
const id = crypto.randomUUID();
commit({
...current.current,
activeCameraId: id,
cameras: [
...current.current.cameras,
{
id,
name: `${name}机位${current.current.cameras.length + 1}`,
position: position as DirectorVector,
target: [0, 1, 0],
fov: 50,
},
],
});
}}
>
<Plus size={12} className="inline" />
{String(name)}
</button>
))}
</div>
<label className="block text-sm">
视角 FOV
<input
aria-label="视角 FOV"
className={`${field} mt-2`}
type="number"
min={15}
max={90}
value={camera.fov}
onChange={(e) => {
const n = Number(e.target.value);
if (n >= 15 && n <= 90) patchCamera({ fov: n });
}}
/>
</label>
<label className="block text-sm">
截图比例
<select
aria-label="截图比例"
className={`${field} mt-2`}
value={scene.ratio}
onChange={(e) =>
commit({ ...current.current, ratio: e.target.value })
}
>
{directorRatios.map((r) => (
<option key={r}>{r}</option>
))}
</select>
</label>
{object ? (
<section className="space-y-2 border-t border-white/10 pt-3">
<div className="flex items-center justify-between text-sm">
<span>{object.name}</span>
<button
aria-label="删除所选场景对象"
onClick={() => {
commit({
...current.current,
objects: current.current.objects.filter(
(o) => o.id !== object.id,
),
});
setSelected("");
}}
>
<Trash2 size={14} />
</button>
</div>
{(["position", "rotation", "scale"] as const).map((key, j) => (
<div key={key}>
<div className="mb-1 text-xs text-white/50">
{["位置", "旋转(弧度)", "缩放"][j]}
</div>
<div className="grid grid-cols-3 gap-1">
{object[key].map((v, i) => (
<input
key={i}
aria-label={`${object.name}-${key}-${"XYZ"[i]}`}
className={field}
type="number"
step="0.1"
value={Number(v.toFixed(2))}
onChange={(e) => {
const n = Number(e.target.value);
if (
!Number.isFinite(n) ||
(key === "scale" && n <= 0)
)
return;
const values = [...object[key]] as DirectorVector;
values[i] = n;
patchObject(object.id, { [key]: values });
}}
/>
))}
</div>
</div>
))}
</section>
) : null}
<section className="space-y-2 border-t border-white/10 pt-3">
<div className="text-sm">机位截图({scene.snapshots.length})</div>
{scene.snapshots.map((s) => (
<div key={s.id} className="rounded-lg border border-white/10 p-2">
<img src={s.url} alt={s.title} className="w-full rounded" />
<div className="mt-1 truncate text-xs">
{s.title} · {s.width}×{s.height}
</div>
<button
className={`${button} mt-2 w-full`}
onClick={() => {
onSend([s]);
toast.success("截图已发送到画布");
}}
>
发送到画布
</button>
</div>
))}
</section>
</aside>
</div>
<input
ref={fileInput}
className="hidden"
type="file"
accept=".glb,model/gltf-binary"
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
if (!/\.glb$/i.test(file.name) || file.size > 25 * 1024 * 1024) {
toast.error("请上传 25 MiB 以内的 GLB 模型");
return;
}
setBusy(true);
try {
const header = new DataView(await file.slice(0, 12).arrayBuffer());
if (
header.byteLength < 12 ||
header.getUint32(0, true) !== 0x46546c67
)
throw Error("文件不是有效 GLB 模型");
const url = await upload(file);
addObject("model", file.name, url);
} catch (e) {
toast.error(e instanceof Error ? e.message : "模型上传失败");
} finally {
setBusy(false);
}
}}
/>
</div>
);
}

View File

@ -0,0 +1,359 @@
"use client";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import * as THREE from "three";
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { TransformControls } from "three/examples/jsm/controls/TransformControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import {
directorImageSize,
type DirectorScene,
type DirectorObject,
} from "@/lib/video-canvas/director-scene";
export type DirectorViewportHandle = {
capture: () => Promise<{ blob: Blob; width: number; height: number }>;
};
type Props = {
scene: DirectorScene;
selected: string;
mode: "translate" | "rotate" | "scale";
cameraView: boolean;
onTransform: (id: string, patch: Partial<DirectorObject>) => void;
onCamera: (
position: [number, number, number],
target: [number, number, number],
) => void;
};
function dispose(root: THREE.Object3D) {
root.traverse((o) => {
if (o instanceof THREE.Mesh) {
o.geometry.dispose();
for (const m of Array.isArray(o.material) ? o.material : [o.material]) {
for (const value of Object.values(m))
if (value instanceof THREE.Texture) value.dispose();
m.dispose();
}
}
});
}
export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
function DirectorViewport(props, ref) {
const host = useRef<HTMLDivElement>(null);
const latest = useRef(props);
latest.current = props;
const runtime = useRef<{
renderer: THREE.WebGLRenderer;
world: THREE.Scene;
camera: THREE.PerspectiveCamera;
orbit: OrbitControls;
transform: TransformControls;
helper: THREE.Object3D;
grid: THREE.GridHelper;
objects: THREE.Group;
pending: number;
resize: () => void;
} | null>(null);
const [ready, setReady] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!host.current) return;
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({
antialias: true,
preserveDrawingBuffer: true,
});
} catch {
setError("无法初始化 3D 视图,请检查浏览器 WebGL 支持");
return;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setClearColor("#c5cbd3");
host.current.appendChild(renderer.domElement);
renderer.domElement.setAttribute("aria-label", "3D 导演台场景");
Object.assign(renderer.domElement.style, {
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
});
const world = new THREE.Scene();
world.background = new THREE.Color("#c5cbd3");
world.add(new THREE.HemisphereLight(0xffffff, 0x647080, 2.4));
const light = new THREE.DirectionalLight(0xffffff, 3);
light.position.set(4, 8, 5);
world.add(light);
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(100, 100),
new THREE.MeshStandardMaterial({ color: 0xb1b6be, roughness: 1 }),
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.02;
world.add(floor);
const grid = new THREE.GridHelper(30, 30, 0x596573, 0x929daa);
world.add(grid);
const camera = new THREE.PerspectiveCamera(50, 1, 0.05, 500);
camera.position.set(4, 3, 7);
const orbit = new OrbitControls(camera, renderer.domElement);
orbit.target.set(0, 1, 0);
orbit.minDistance = 0.3;
orbit.maxDistance = 80;
const transform = new TransformControls(camera, renderer.domElement);
const helper = transform.getHelper();
world.add(helper);
const objects = new THREE.Group();
world.add(objects);
transform.addEventListener("dragging-changed", (e) => {
orbit.enabled = !e.value && !latest.current.cameraView;
});
transform.addEventListener("mouseUp", () => {
const o = transform.object;
if (o)
latest.current.onTransform(o.name, {
position: o.position.toArray(),
rotation: [o.rotation.x, o.rotation.y, o.rotation.z],
scale: o.scale.toArray(),
});
});
orbit.addEventListener("end", () =>
latest.current.onCamera(
camera.position.toArray(),
orbit.target.toArray(),
),
);
const resize = () => {
if (!host.current) return;
const { width, height } = host.current.getBoundingClientRect();
const size = directorImageSize(latest.current.scene.ratio);
const aspect = size.width / size.height;
const fitWidth = Math.max(1, Math.min(width, height * aspect));
renderer.setSize(fitWidth, fitWidth / aspect);
camera.aspect = aspect;
camera.updateProjectionMatrix();
};
const state = {
renderer,
world,
camera,
orbit,
transform,
helper,
grid,
objects,
pending: 0,
resize,
};
runtime.current = state;
const observer = new ResizeObserver(resize);
observer.observe(host.current);
resize();
renderer.setAnimationLoop(() => {
orbit.update();
renderer.render(world, camera);
});
setReady(true);
return () => {
runtime.current = null;
observer.disconnect();
renderer.setAnimationLoop(null);
transform.dispose();
orbit.dispose();
dispose(world);
renderer.dispose();
renderer.domElement.remove();
};
}, []);
useEffect(() => {
const r = runtime.current;
if (!r || !ready) return;
let active = true;
r.transform.detach();
r.objects.children.slice().forEach((o) => {
dispose(o);
r.objects.remove(o);
});
setError("");
r.pending = 0;
const attach = (object: THREE.Object3D, item: DirectorObject) => {
if (!active) {
dispose(object);
return;
}
object.name = item.id;
object.position.fromArray(item.position);
object.rotation.set(...item.rotation);
object.scale.fromArray(item.scale);
r.objects.add(object);
if (item.id === latest.current.selected && !latest.current.cameraView)
r.transform.attach(object);
};
for (const item of props.scene.objects) {
if (item.kind === "model" && item.url) {
r.pending++;
new GLTFLoader().load(
buildThreeAssetUrl(item.url),
(g) => {
if (active) {
const box = new THREE.Box3().setFromObject(g.scene);
const size = box.getSize(new THREE.Vector3());
const scale = 2 / Math.max(size.x, size.y, size.z, 0.001);
g.scene.scale.setScalar(scale);
const normalized = new THREE.Group();
normalized.add(g.scene);
const fitted = new THREE.Box3().setFromObject(normalized);
const center = fitted.getCenter(new THREE.Vector3());
g.scene.position.set(-center.x, -fitted.min.y, -center.z);
attach(normalized, item);
r.pending--;
setLoading(r.pending > 0);
} else dispose(g.scene);
},
undefined,
() => {
if (active) {
r.pending--;
setLoading(r.pending > 0);
setError("模型加载失败,请检查 GLB 文件及资源访问权限");
}
},
);
continue;
}
const mat = new THREE.MeshStandardMaterial({
color: item.kind === "actor" ? 0x687bb0 : 0xc3664c,
roughness: 0.6,
});
let object: THREE.Object3D;
if (item.kind === "actor") {
const group = new THREE.Group();
const body = new THREE.Mesh(
new THREE.CapsuleGeometry(0.32, 0.7, 6, 16),
mat,
);
body.position.y = 1.05;
group.add(body);
const head = new THREE.Mesh(
new THREE.SphereGeometry(0.24, 24, 16),
mat.clone(),
);
head.position.y = 1.95;
group.add(head);
for (const x of [-0.18, 0.18]) {
const leg = new THREE.Mesh(
new THREE.CapsuleGeometry(0.12, 0.55, 4, 12),
mat.clone(),
);
leg.position.set(x, 0.42, 0);
group.add(leg);
}
object = group;
} else {
object = new THREE.Mesh(
item.kind === "sphere"
? new THREE.SphereGeometry(0.5, 32, 20)
: new THREE.BoxGeometry(1, 1, 1),
mat,
);
}
attach(object, item);
}
setLoading(r.pending > 0);
return () => {
active = false;
};
}, [props.scene.objects, ready]);
useEffect(() => {
const r = runtime.current;
if (!r) return;
const camera =
props.scene.cameras.find((c) => c.id === props.scene.activeCameraId) ||
props.scene.cameras[0];
r.camera.position.fromArray(camera.position);
r.camera.fov = camera.fov;
r.camera.updateProjectionMatrix();
r.orbit.target.fromArray(camera.target);
r.orbit.enabled = !props.cameraView;
r.orbit.update();
r.transform.setMode(props.mode);
const selected = r.objects.getObjectByName(props.selected);
if (selected && !props.cameraView) r.transform.attach(selected);
else r.transform.detach();
}, [
props.scene.cameras,
props.scene.activeCameraId,
props.selected,
props.mode,
props.cameraView,
ready,
]);
useEffect(() => {
runtime.current?.resize();
}, [props.scene.ratio, ready]);
useImperativeHandle(
ref,
() => ({
capture: async () => {
const r = runtime.current;
if (!r || !ready || r.pending || error)
throw Error(error || "3D 场景尚未准备好");
const { width, height } = directorImageSize(
latest.current.scene.ratio,
);
const previousSize = r.renderer.getSize(new THREE.Vector2());
const pixelRatio = r.renderer.getPixelRatio();
const aspect = r.camera.aspect;
r.helper.visible = false;
r.grid.visible = false;
r.renderer.setPixelRatio(1);
r.renderer.setSize(width, height, false);
r.camera.aspect = width / height;
r.camera.updateProjectionMatrix();
r.renderer.render(r.world, r.camera);
try {
const blob = await new Promise<Blob>((resolve, reject) =>
r.renderer.domElement.toBlob(
(b) => (b ? resolve(b) : reject(Error("截图失败"))),
"image/png",
),
);
return { blob, width, height };
} finally {
r.renderer.setPixelRatio(pixelRatio);
r.renderer.setSize(previousSize.x, previousSize.y);
r.camera.aspect = aspect;
r.camera.updateProjectionMatrix();
r.grid.visible = true;
r.helper.visible = true;
}
},
}),
[ready, error],
);
return (
<div className="relative h-full w-full" data-testid="director-viewport">
<div ref={host} className="h-full w-full" />
{error ? (
<div
role="alert"
className="absolute inset-x-4 top-4 rounded bg-red-950/90 p-3 text-sm text-white"
>
{error}
</div>
) : loading ? (
<div className="absolute left-4 top-4 rounded bg-black/60 p-2 text-white">
正在加载模型...
</div>
) : null}
</div>
);
},
);

View File

@ -0,0 +1,49 @@
# 视频画布与 3D 导演台串联验收(2026-09-16 至 09-17)
测试范围:本地隔离工作树、真实登录、真实阿里云模型与现有对象存储。未部署线上,未修改线上模型目录。临时模型目录仅含官方千问 3.8 Max 和万相 `wan2.6-i2v-flash`。
## 实测发现与修复
- 旧导演台只有 CSS 场景、计时器和固定示例截图。活动入口替换为延迟加载的 Three.js 导演台:对象变换、机位、真实截图、GLB 导入及场景持久化。
- 通用上传拒绝 GLB。增加登录校验的专用接口,限制 25 MiB,验证 GLB 2.0 头部、总长度、JSON 场景和嵌入资源;明确拒绝外部纹理/缓冲区。
- 当前七牛存储域名未提供浏览器跨域响应头,GLB 上传成功后仍无法渲染。使用现有登录保护的模型代理,并加入该存储域名的匹配。
- 新画布首次携带节点保存时,先写节点再建父记录导致外键失败。改为事务内创建父记录、同步节点和关系、更新画布;已有画布加行锁校验版本。
- 主视频编辑器声音开关未传给生成接口,现已传递 `generateAudio`。
- 首帧/首尾帧快捷入口自动插入示例图片且未固定模式。改为选择画布真实图片,显式设置模式;缺少首帧/尾帧时禁止提交。
- 裁剪尺寸一直显示固定的 1024×576,且覆盖层继承 `pointer-events: none` 导致拖框实际移动节点。恢复裁剪层交互,尺寸随选择区域与素材尺寸变化;未知尺寸时显示区域百分比。
- 音轨卡片播放按钮只有占位提示。接入真实 audio 元素、播放/暂停、进度和错误反馈。
- 提示词接口补充不含密钥和提示词正文的失败类型/状态码日志,便于区分模型与传输错误。
## 验收记录
| 步骤 | 实际结果 |
|---|---|
| 首次带节点 PUT 保存 | HTTP 200,节点与画布均落库 |
| 同版本并发保存 | 一次 200、一次 409,未覆盖较新版本 |
| 真实 3D 渲染 | 人偶与上传的橙色立方体 GLB 可见;位置修改生效 |
| 多机位截图 | 正面 1280×720 / 40,614 B;侧面 720×1280 / 38,032 B;文件哈希不同 |
| 场景持久化 | 数据库保存后完整刷新页面,恢复 2 对象、3 机位、2 截图、9:16 比例及模型 X=1.8/Y=0;GLB 重新可见 |
| 千问优化 | 首次页面调用失败,原文保留;重试成功,HTTP 200 / 54 秒,结果应用到视频节点 |
| 真实截图引用 | 节点参考缩略图 URL 与导演台截图逐一一致 |
| 万相生成 | HTTP 200 / 35.0 秒;1280×720,5.008 秒,708,505 B,H.264 + AAC 双声道 |
| 浏览器播放 | `paused=false`、播放时间推进、无视频错误 |
| 自动归档 | HTTP 200 / 23.8 秒,换成持久存储地址 |
| 剪辑 | 以正确生成视频为输入,1280×720、1.734 秒、H.264 + AAC;HTTP 200 / 32.6 秒 |
| 裁剪 | 在剪辑结果上真实拖框,标签和导出均为 638×358,1.734 秒、H.264 + AAC;HTTP 200 / 19.8 秒 |
| 音视频分离 | 裁剪结果生成 638×358、1.734 秒无音轨视频,以及 1.719 秒 AAC 双声道 M4A |
| 完整刷新恢复 | 全部结果节点和 6 条处理连线恢复;独立音频与无声视频均播放成功、时间推进且无媒体错误 |
| 代码验证 | TypeScript 源码检查通过,6 组共 40 条定向回归测试通过 |
第一轮回归过程中,遗留示例节点与导演台截图重叠导致误选,生成了汽车背景视频。这轮被判为串联失败,不能作为导演台成功证据。清理旧示例后,从修复的新入口重跑,并核对真实参考 URL,再生成上述 1280×720 视频。
## 能力边界
AI 识图重建尚未接入,入口禁用并标明“未接通”;不再以计时器假装完成。当前验证的是基础场景与自包含 GLB,不包括复杂压缩模型、骨骼动画、完整素材库和参考站全部界面的像素验收。视频超分、智能去字幕等未配置服务的功能不计为通过。
本地冷编译和 SSH 数据库连接抖动明显增加了初次加载耗时。测试中恢复连接并重试失败保存后完成后续流程;这些本地耗时不能当作线上性能结论。原提示词优化首次失败没有足够诊断信息,不能断言具体根因。
## 串联顺序与留档
实际画布连线为:导演台 → 真实机位截图 → 万相首帧视频 → 剪辑 → 裁剪 → 无声视频 + 独立音轨。每一步均使用上一步真实产物。
本地 `.qa/video-chain/` 保存了截图、FFprobe 检查结果及实际 MP4/M4A;测试账户、密钥、签名地址、数据库快照不提交 Git。测试结束后禁用临时账户并清理隔离模型目录及临时服务。

View File

@ -7,6 +7,7 @@ const PROXY_HOST_KEYWORDS = [
"marble",
"s3.1080p.hk",
"img.yydd.ai",
"scqny.jiansun.vip",
"blob.vercel-storage.com",
"aliyuncs.com",
"amazonaws.com",

View File

@ -1238,13 +1238,13 @@ export async function getMediaAssetByUserAndUrl(params: {
export async function getMediaAssetsByUserAndUrls(params: {
userId: string;
urls: string[];
}): Promise<MediaAsset[]> {
}, executor: Pick<typeof db, "select" | "insert" | "delete"> = db): Promise<MediaAsset[]> {
const normalizedUrls = Array.from(
new Set(params.urls.map((url) => normalizeMediaUrl(url)).filter(Boolean)),
);
if (!normalizedUrls.length) return [];
try {
return await db
return await executor
.select()
.from(mediaAsset)
.where(
@ -1397,9 +1397,9 @@ export async function createMediaAssetRelation(params: {
sourceNodeId?: string | null;
confidence?: number | null;
metadata?: any;
}): Promise<MediaAssetRelation | null> {
}, executor: Pick<typeof db, "select" | "insert" | "delete"> = db): Promise<MediaAssetRelation | null> {
try {
const [result] = await db
const [result] = await executor
.insert(mediaAssetRelation)
.values({
userId: params.userId,
@ -1589,7 +1589,7 @@ export async function syncCanvasNodesForProject(params: {
edges?: any[];
viewport?: { x: number; y: number; zoom: number };
} | null | undefined;
}) {
}, executor: Pick<typeof db, "select" | "insert" | "delete"> = db) {
const nodes = Array.isArray(params.canvasData?.nodes) ? params.canvasData?.nodes : [];
const assetUrls = Array.from(
new Set(
@ -1602,7 +1602,7 @@ export async function syncCanvasNodesForProject(params: {
? await getMediaAssetsByUserAndUrls({
userId: params.userId,
urls: assetUrls,
})
}, executor)
: [];
const assetByUrl = new Map<string, MediaAsset>();
for (const asset of matchedAssets) {
@ -1626,7 +1626,7 @@ export async function syncCanvasNodesForProject(params: {
};
});
await db.delete(mediaAssetRelation).where(
await executor.delete(mediaAssetRelation).where(
and(
eq(mediaAssetRelation.projectId, params.projectId),
eq(mediaAssetRelation.relationType, "located_on_canvas"),
@ -1664,7 +1664,7 @@ export async function syncCanvasNodesForProject(params: {
});
if (insertableNodes.length > 0) {
await db
await executor
.insert(canvasNode)
.values(insertableNodes)
.onConflictDoUpdate({
@ -1682,14 +1682,14 @@ export async function syncCanvasNodesForProject(params: {
});
const activeNodeIds = insertableNodes.map((node) => node.id);
await db.delete(canvasNode).where(
await executor.delete(canvasNode).where(
and(
eq(canvasNode.projectId, params.projectId),
notInArray(canvasNode.id, activeNodeIds),
),
);
} else {
await db.delete(canvasNode).where(eq(canvasNode.projectId, params.projectId));
await executor.delete(canvasNode).where(eq(canvasNode.projectId, params.projectId));
}
for (const node of enrichedNodes) {
@ -1710,7 +1710,7 @@ export async function syncCanvasNodesForProject(params: {
position: node.position || null,
size: node.size || null,
},
});
}, executor);
}
return {

View File

@ -0,0 +1,42 @@
export const DIRECTOR_MAX_GLB_BYTES = 25 * 1024 * 1024;
/** Only self-contained GLB 2.0 files are accepted; external buffers/textures are not portable. */
export function validateDirectorGlb(buffer: ArrayBuffer) {
if (buffer.byteLength < 20 || buffer.byteLength > DIRECTOR_MAX_GLB_BYTES)
throw new Error("请上传 25 MiB 以内的有效 GLB 模型");
const view = new DataView(buffer);
if (
view.getUint32(0, true) !== 0x46546c67 ||
view.getUint32(4, true) !== 2 ||
view.getUint32(8, true) !== buffer.byteLength
)
throw new Error("文件不是有效的 GLB 2.0 模型");
const length = view.getUint32(12, true);
if (
view.getUint32(16, true) !== 0x4e4f534a ||
length % 4 ||
length > buffer.byteLength - 20
)
throw new Error("GLB 场景数据不完整");
let document: any;
try {
document = JSON.parse(
new TextDecoder().decode(new Uint8Array(buffer, 20, length)),
);
} catch {
throw new Error("GLB 场景数据无效");
}
if (
document?.asset?.version !== "2.0" ||
!Array.isArray(document.meshes) ||
!document.meshes.length
)
throw new Error("GLB 中没有可显示的网格");
for (const resource of [
...(document.buffers || []),
...(document.images || []),
]) {
if (resource.uri && !/^data:/i.test(resource.uri))
throw new Error("请将纹理和缓冲区嵌入 GLB 后重新上传");
}
return document;
}

View File

@ -0,0 +1,124 @@
export type DirectorVector = [number, number, number];
export type DirectorObject = {
id: string;
name: string;
kind: "actor" | "box" | "sphere" | "model";
url?: string;
position: DirectorVector;
rotation: DirectorVector;
scale: DirectorVector;
};
export type DirectorCamera = {
id: string;
name: string;
position: DirectorVector;
target: DirectorVector;
fov: number;
};
export type DirectorSnapshot = {
id: string;
title: string;
url: string;
width: number;
height: number;
cameraId: string;
};
export type DirectorScene = {
objects: DirectorObject[];
cameras: DirectorCamera[];
activeCameraId: string;
ratio: string;
snapshots: DirectorSnapshot[];
};
export const directorRatios = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"];
export function directorImageSize(ratio: string) {
const [w, h] = (directorRatios.includes(ratio) ? ratio : "16:9")
.split(":")
.map(Number);
return w >= h
? { width: 1280, height: Math.round((1280 * h) / w) }
: { width: Math.round((1280 * w) / h), height: 1280 };
}
export function defaultDirectorScene(): DirectorScene {
return {
objects: [
{
id: "actor-a",
name: "基础人偶",
kind: "actor",
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
],
cameras: [
{
id: "camera-1",
name: "机位1",
position: [4, 3, 7],
target: [0, 1, 0],
fov: 50,
},
],
activeCameraId: "camera-1",
ratio: "16:9",
snapshots: [],
};
}
export function readDirectorScene(value: unknown): DirectorScene {
const fallback = defaultDirectorScene();
if (!value || typeof value !== "object") return fallback;
const v = value as DirectorScene;
const vector = (a: unknown): a is DirectorVector =>
Array.isArray(a) &&
a.length === 3 &&
a.every((n) => typeof n === "number" && Number.isFinite(n));
const cameras = Array.isArray(v.cameras)
? v.cameras.filter(
(c) =>
c &&
typeof c.id === "string" &&
typeof c.name === "string" &&
vector(c.position) &&
vector(c.target) &&
Number.isFinite(c.fov) &&
c.fov >= 15 &&
c.fov <= 90,
)
: [];
const objects = Array.isArray(v.objects)
? v.objects.filter(
(o) =>
o &&
typeof o.id === "string" &&
typeof o.name === "string" &&
["actor", "box", "sphere", "model"].includes(o.kind) &&
vector(o.position) &&
vector(o.rotation) &&
vector(o.scale) &&
o.scale.every((n) => n > 0) &&
(o.kind !== "model" || /^https?:\/\//i.test(o.url || "")),
)
: fallback.objects;
const snapshots = Array.isArray(v.snapshots)
? v.snapshots.filter(
(s) =>
s &&
typeof s.id === "string" &&
typeof s.title === "string" &&
/^https?:\/\//i.test(s.url) &&
s.width > 0 &&
s.height > 0,
)
: [];
const validCameras = cameras.length ? cameras : fallback.cameras;
return {
objects,
cameras: validCameras,
activeCameraId: validCameras.some((c) => c.id === v.activeCameraId)
? v.activeCameraId
: validCameras[0].id,
ratio: directorRatios.includes(v.ratio) ? v.ratio : "16:9",
snapshots,
};
}

View File

@ -3,6 +3,7 @@ import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
testMatch: [
/routes\/video-canvas-director\.test\.ts/,
/routes\/aliyun-canvas-video\.test\.ts/,
/routes\/video-canvas-followup\.test\.ts/,
/routes\/video-canvas-tools\.test\.ts/,

View File

@ -0,0 +1,106 @@
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("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),
);
});