feat(video-canvas): connect real 3d director and validate media chain
This commit is contained in:
parent
598f5f617a
commit
38be09de9c
@ -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",
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
45
app/api/canvas/director/upload-model/route.ts
Normal file
45
app/api/canvas/director/upload-model/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
438
components/canvas/director/DirectorStudioDialog.tsx
Normal file
438
components/canvas/director/DirectorStudioDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
359
components/canvas/director/DirectorViewport.tsx
Normal file
359
components/canvas/director/DirectorViewport.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
49
docs/video-canvas-director-chain-2026-09-16.md
Normal file
49
docs/video-canvas-director-chain-2026-09-16.md
Normal 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。测试结束后禁用临时账户并清理隔离模型目录及临时服务。
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
42
lib/video-canvas/director-glb.ts
Normal file
42
lib/video-canvas/director-glb.ts
Normal 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;
|
||||
}
|
||||
124
lib/video-canvas/director-scene.ts
Normal file
124
lib/video-canvas/director-scene.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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/,
|
||||
|
||||
106
tests/routes/video-canvas-director.test.ts
Normal file
106
tests/routes/video-canvas-director.test.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user