diff --git a/app/api/canvas/[id]/route.ts b/app/api/canvas/[id]/route.ts index e76915da0..3d8063edf 100644 --- a/app/api/canvas/[id]/route.ts +++ b/app/api/canvas/[id]/route.ts @@ -186,7 +186,7 @@ export async function PUT( tx, ) : undefined; - const updateData: any = { updatedAt: new Date() }; + const updateData: any = { updatedAt: new Date(Math.max(Date.now(), (existingProject?.updatedAt.getTime() || 0) + 1)) }; if (title !== undefined) updateData.title = title; if (description !== undefined) updateData.description = description; if (thumbnail !== undefined) updateData.thumbnail = thumbnail; diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 50c4c0b84..d07699ed0 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -1,4 +1,5 @@ "use client"; +import { VideoCanvasConflictNotice } from "./VideoCanvasConflictNotice"; import { normalizeVideoAnalysisRows } from "@/lib/video-canvas/video-story"; import dynamic from "next/dynamic"; import { signOut } from "next-auth/react"; @@ -1401,8 +1402,6 @@ function serializeNodes(nodes: CloneNode[]) { }, }, connections: [], - createdAt: new Date(), - updatedAt: new Date(), })); } @@ -4802,14 +4801,33 @@ export function LiblibVideoCanvasReplica({ .finally(() => { videoArchiveRunning.current = false; setVideoArchiveTick(value => value + 1); }); }, [nodes, loadedProject, isProjectLoading, videoArchiveTick]); - const canvasSnapshot = useMemo(() => JSON.stringify({ - title: projectTitle, - canvasData: { - nodes: serializeNodes(nodes), edges: serializeEdges(edges), - selectedNodeIds: selectedNodeId ? [selectedNodeId] : [], viewport, tool: "select", - }, - }), [nodes, edges, selectedNodeId, viewport, projectTitle]); - const persistence = useVideoCanvasPersistence(canvasId, loadedProject, canvasSnapshot); + const canvasSnapshot = useMemo( + () => + JSON.stringify({ + title: projectTitle, + canvasData: { + nodes: serializeNodes(nodes), + edges: serializeEdges(edges), + selectedNodeIds: selectedNodeId ? [selectedNodeId] : [], + viewport, + tool: "select", + }, + }), + [nodes, edges, selectedNodeId, viewport, projectTitle], + ); + const applyMergedSnapshot = useCallback((value: string) => { + const merged = JSON.parse(value); + setNodes(restoreNodes(merged.canvasData)); + setEdges(restoreEdges(merged.canvasData)); + setProjectTitle(normalizeCanvasTitle(merged.title)); + setDraftTitle(normalizeCanvasTitle(merged.title)); + }, []); + const persistence = useVideoCanvasPersistence( + canvasId, + loadedProject, + canvasSnapshot, + applyMergedSnapshot, + ); const commitTitle = useCallback(() => { const nextTitle = draftTitle.trim() || "未命名"; @@ -10614,6 +10632,31 @@ export function LiblibVideoCanvasReplica({ /> ) : null} + { + const id = path.match(/nodes\[([^\]]+)\]/)?.[1], + node = nodes.find((n) => n.id === id); + const last = path.split(".").pop() || "内容"; + const names: Record = { + title: "名称", + x: "横向位置", + y: "纵向位置", + position: "位置", + prompt: "提示词", + content: "文本", + rows: "镜头", + mediaUrl: "素材", + url: "素材", + motion: "运镜", + directorScene: "3D 场景", + width: "宽度", + height: "高度", + }; + return `${node?.title || "画布"} · ${names[last] || "内容或结构"}`; + })} + onResolve={persistence.resolveConflict} + onDownload={persistence.downloadDraft} + /> Promise | undefined; + onDownload: () => void; +}) { + const [busy, setBusy] = useState(false); + if (!fields.length) return null; + const resolve = async (choice: CanvasConflictChoice) => { + setBusy(true); + try { + await onResolve(choice); + } finally { + setBusy(false); + } + }; + return ( +
+

其他页面修改了相同内容,当前草稿已保留

+

+ 共有 {fields.length} 处冲突。不同位置的修改会自动合并;以下选择仅处理冲突部分。 +

+
    + {fields.map((field, index) => ( +
  • {field}
  • + ))} +
+
+ + + +
+
+ ); +} diff --git a/components/canvas/director/DirectorMotionPanel.tsx b/components/canvas/director/DirectorMotionPanel.tsx index 483b724cd..71f37fcc5 100644 --- a/components/canvas/director/DirectorMotionPanel.tsx +++ b/components/canvas/director/DirectorMotionPanel.tsx @@ -1,4 +1,5 @@ "use client"; +import { transformDirectorMotion } from "@/lib/video-canvas/director-extensions"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { @@ -37,6 +38,8 @@ export function DirectorMotionPanel({ onCancel: () => void; onOverview: () => void; }) { + const [autoKeyframe, setAutoKeyframe] = useState(false); + const previousPose = useRef(JSON.stringify(pose)); const [time, setTime] = useState(0), [playing, setPlaying] = useState(false), [preview, setPreview] = useState(false); @@ -127,6 +130,12 @@ export function DirectorMotionPanel({ ), }); }; + useEffect(() => { + const signature = JSON.stringify(pose), + changed = signature !== previousPose.current; + previousPose.current = signature; + if (autoKeyframe && changed && !preview && !disabled) add(); + }, [pose, autoKeyframe, preview, disabled]); const downloadJson = () => { const url = URL.createObjectURL( new Blob([JSON.stringify({ version: 1, motion }, null, 2)], { @@ -279,6 +288,39 @@ export function DirectorMotionPanel({ 循环 + +
+ + +
diff --git a/components/canvas/director/DirectorStudioDialog.tsx b/components/canvas/director/DirectorStudioDialog.tsx index 899aca9bc..1d890070d 100644 --- a/components/canvas/director/DirectorStudioDialog.tsx +++ b/components/canvas/director/DirectorStudioDialog.tsx @@ -1,4 +1,12 @@ "use client"; +import { + commitDirectorScene, + resolveDirectorCamera, + cameraAngles, + rotateDirectorCamera, + directorBodies, + createDirectorCrowd, +} from "@/lib/video-canvas/director-extensions"; import { directorPoses, type DirectorPoseName } from "@/lib/video-canvas/director-poses"; import { useEffect, useRef, useState } from "react"; import { Camera, Plus, Upload, X, Trash2 } from "lucide-react"; @@ -57,7 +65,9 @@ export function DirectorStudioDialog({ const [scene, setScene] = useState(() => readDirectorScene(initialScene)); const current = useRef(scene); current.current = scene; - const commit = (next: DirectorScene) => { + const commit = (input: DirectorScene) => { + const next = commitDirectorScene(current.current, input); + if (next.activeCameraId !== current.current.activeCameraId) setPreviewTime(null); current.current = next; setScene(next); onChange(next); @@ -114,7 +124,15 @@ export function DirectorStudioDialog({ if (busy) return; setBusy(true); try { - const motion = current.current.motion; + const motion = { + ...current.current.motion, + keyframes: current.current.motion.keyframes.map((k) => ({ + ...k, + ...resolveDirectorCamera(current.current, k), + id: k.id, + time: k.time, + })), + }; const pose = sampleDirectorMotion(motion, 0); if (!pose || motion.keyframes.length < 2) throw Error("至少需要两个关键帧"); const image = await viewport.current!.capture(pose); @@ -143,8 +161,10 @@ export function DirectorStudioDialog({ setBusy(false); } }; + const backgroundInput = useRef(null); const fileInput = useRef(null); - const camera = scene.cameras.find((c) => c.id === scene.activeCameraId) || scene.cameras[0]; + const camera = resolveDirectorCamera(scene); + const angles = cameraAngles(camera); const object = scene.objects.find((o) => o.id === selected); const patchObject = (id: string, patch: Partial) => commit({ @@ -155,7 +175,14 @@ export function DirectorStudioDialog({ commit({ ...current.current, cameras: current.current.cameras.map((c) => - c.id === current.current.activeCameraId ? mergeDirectorCameraPose(c, patch) : c, + c.id === current.current.activeCameraId + ? { + ...mergeDirectorCameraPose(c, patch), + ...(Object.hasOwn(patch, "followObjectId") + ? { followObjectId: patch.followObjectId } + : {}), + } + : c, ), }); const addObject = (kind: DirectorObject["kind"], name: string, url?: string) => { @@ -286,6 +313,31 @@ export function DirectorStudioDialog({ 上传 GLB 模型 + + @@ -388,6 +440,55 @@ export function DirectorStudioDialog({ ))} + +
+ {(["yaw", "pitch"] as const).map((axis) => ( + + ))} +