feat: add Claw video uploads and complete video canvas workflows

This commit is contained in:
134 2026-07-28 15:33:04 +08:00
parent 78b0da908c
commit f13b4e8909
156 changed files with 76383 additions and 3546 deletions

6
.gitignore vendored
View File

@ -87,6 +87,12 @@ checkpoints/
# 第三方参考源码(不提交到git)
reference-sources/
# Development-only upload fallback.
public/uploads/local/
# Local isolated database and video-canvas test fixtures.
.local/
# Local self-hosted Pascal Editor checkout.
.pascal-editor/

View File

@ -5739,44 +5739,53 @@ export async function POST(request: Request) {
);
const assistantMessageId = generateUUID();
await saveMessages({
messages: [
{
id: assistantMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: assistantText }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
try {
await saveMessages({
messages: [
{
id: assistantMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: assistantText }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
await saveGeneratedImages(
assistantText,
session.user.id,
id,
assistantMessageId,
params.promptForPersistence || baseReplacementPrompt,
resolvedModelRef,
{
sourceAssetIds,
sourceImageUrls: params.sourceImageUrls,
relationType: "edited_from",
sourceType: "detail_replacement",
projectId: contextPayload?.projectId || null,
userPrompt: requestTextContent,
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
requestedImageCount: 1,
aspectRatio: resolvedAspectRatio,
imageSize: persistedImageSize,
},
);
await saveGeneratedImages(
assistantText,
session.user.id,
id,
assistantMessageId,
params.promptForPersistence || baseReplacementPrompt,
resolvedModelRef,
{
sourceAssetIds,
sourceImageUrls: params.sourceImageUrls,
relationType: "edited_from",
sourceType: "detail_replacement",
projectId: contextPayload?.projectId || null,
userPrompt: requestTextContent,
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
requestedImageCount: 1,
aspectRatio: resolvedAspectRatio,
imageSize: persistedImageSize,
},
);
} catch (persistError) {
chatApiLogger.warn("[Chat API] Failed to persist direct detail replacement result; streaming response anyway:", {
chatId: id,
referenceId: params.reference.id,
referenceLabel: params.reference.label,
error: persistError,
});
}
writeAssistantTextChunk(
writer,
@ -5827,6 +5836,19 @@ export async function POST(request: Request) {
return;
}
const fallbackSourceImageUrls = Array.from(
new Set([
...productInputImageUrls,
...(modelInputImageUrl ? [modelInputImageUrl] : []),
...(reference.rerunReferenceImages || [])
.map((image) => String(image.url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
.slice(0, 6),
reference.imageUrl,
]),
);
try {
const replacementExecutionPlan = await analyzeReplacementExecutionPlan({
modeType: "detail_replacement",
replacementStrategy: detailReplacementStrategy,
@ -6067,6 +6089,29 @@ export async function POST(request: Request) {
});
successCount += 1;
}
} catch (replacementPlanError) {
chatApiLogger.error("[Chat API] Direct detail replacement item failed before generation:", {
referenceIndex,
referenceLabel: reference.label,
error: replacementPlanError,
});
await emitReplacementResultMessage({
reference,
bodyText: `生成失败,当前先沿用参考图:${
replacementPlanError instanceof Error
? replacementPlanError.message
: "请稍后重试"
}`,
image: {
originalUrl: reference.imageUrl,
thumbnailUrl: reference.imageUrl,
},
modelDisplayName: null,
sourceImageUrls: fallbackSourceImageUrls,
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
});
successCount += 1;
}
});
} finally {
stopKeepalive();

View File

@ -21,6 +21,7 @@ import {
maybeEnqueueStorageMigrationTask,
resolveS3UploadTarget,
} from "@/lib/storage/service";
import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
const logger = createLogger("direct-upload-api");
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
@ -39,6 +40,13 @@ const allowedDirectUploadTypes = new Set([
"image/png",
"image/webp",
]);
const allowedOpenClawVideoUploadTypes = new Set([
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-m4v",
"video/x-matroska",
]);
const InitiateSchema = z.object({
action: z.literal("initiate"),
@ -109,8 +117,12 @@ function assertUserOwnedKey(key: string, userId: string) {
return normalized;
}
function isAllowedDirectUpload(contentType: string) {
return allowedDirectUploadTypes.has(contentType);
function isAllowedDirectUpload(contentType: string, purpose?: string) {
return (
allowedDirectUploadTypes.has(contentType) ||
(purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
allowedOpenClawVideoUploadTypes.has(contentType))
);
}
function computePartSize(size: number) {
@ -204,7 +216,7 @@ export async function POST(request: Request) {
payload.filename
);
if (!isAllowedDirectUpload(contentType)) {
if (!isAllowedDirectUpload(contentType, payload.purpose)) {
logger.warn("unsupported_direct_upload_type", {
contentType,
filenameExtension: payload.filename.toLowerCase().split(".").pop() || null,
@ -213,7 +225,10 @@ export async function POST(request: Request) {
clientUploadId: payload.clientUploadId || null,
});
return NextResponse.json(
{ error: "Direct upload only supports JPG, PNG and WebP images" },
{
error:
"直传仅支持 JPG、PNG、WebP 图片以及 Claw 对话的 MP4、MOV、WebM、M4V、MKV 视频",
},
{ status: 400 }
);
}
@ -329,9 +344,12 @@ export async function POST(request: Request) {
const key = assertUserOwnedKey(payload.key, session.user.id);
const contentType = normalizeContentType(payload.contentType, payload.filename);
if (!isAllowedDirectUpload(contentType)) {
if (!isAllowedDirectUpload(contentType, payload.purpose)) {
return NextResponse.json(
{ error: "Direct upload only supports JPG, PNG and WebP images" },
{
error:
"直传仅支持 JPG、PNG、WebP 图片以及 Claw 对话的 MP4、MOV、WebM、M4V、MKV 视频",
},
{ status: 400 }
);
}
@ -390,15 +408,21 @@ export async function POST(request: Request) {
const assetStartedAt = Date.now();
const asset = await upsertMediaAsset({
userId: session.user.id,
mediaType: "image",
sourceType: "upload",
mediaType: resolvedContentType.startsWith("video/") ? "video" : "image",
sourceType:
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
? "openclaw_chat_video_upload"
: "upload",
status: "ready",
originalUrl: url,
previewUrl: url,
thumbnailUrl: url,
mimeType: resolvedContentType,
displayName: payload.filename,
sourceTable: "direct_upload",
sourceTable:
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
? "openclaw_chat_video_upload"
: "direct_upload",
metadata: {
pathname: key,
size: actualSize ?? payload.size,

View File

@ -3,10 +3,24 @@ import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images";
import { upsertMediaAsset } from "@/lib/db/queries";
import { getSystemSetting, upsertMediaAsset } from "@/lib/db/queries";
import { isSiteAdminType } from "@/lib/middleware/admin";
import { getContentType, uploadFile } from "@/lib/storage/service";
import { createLogger } from "@/lib/observability/logger";
import {
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
} from "@/lib/video-canvas/config";
import {
VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE,
} from "@/lib/video-canvas/subjects";
import {
VIDEO_CANVAS_RESOURCE_UPLOAD_PURPOSE,
VIDEO_CANVAS_RESOURCE_UPLOAD_SOURCE_TYPE,
} from "@/lib/video-canvas/uploaded-resource";
import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
const logger = createLogger("upload-api");
const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
@ -18,11 +32,29 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
"image/heic",
"image/heif",
]);
const ALLOWED_UPLOAD_AUDIO_TYPES = new Set([
"audio/mpeg",
"audio/mp3",
"audio/mp4",
"audio/wav",
"audio/x-wav",
"audio/webm",
"audio/ogg",
"audio/aac",
]);
const ALLOWED_UPLOAD_VIDEO_TYPES = new Set([
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-m4v",
"video/x-matroska",
]);
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
const GAUSSIAN_SPLAT_EXTENSION_RE = /\.(ply|splat|spz|ksplat)$/i;
function normalizeUploadContentType(contentType: string, filename: string) {
const normalized = contentType.trim().toLowerCase();
const normalized = contentType.split(";", 1)[0].trim().toLowerCase();
if (normalized && normalized !== "application/octet-stream") {
return normalized;
}
@ -30,6 +62,15 @@ function normalizeUploadContentType(contentType: string, filename: string) {
return getContentType(filename);
}
function getAcceptedMimeTypes(accept: string) {
return new Set(
accept
.split(",")
.map((item) => item.trim().toLowerCase())
.filter((item) => item.includes("/") && !item.endsWith("/*")),
);
}
function createUnsupportedImageTypeMessage(contentType: string, filename: string) {
const extension = filename.toLowerCase().split(".").pop() || "unknown";
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
@ -154,23 +195,16 @@ function toArrayBuffer(buffer: Buffer): ArrayBuffer {
}
// Use Blob instead of File since File is not available in Node.js environment
const FileSchema = z.object({
file: z
.instanceof(Blob)
.refine((file) => file.size <= 50 * 1024 * 1024, {
message: "File size should be less than 50MB",
function createFileSchema(maxBytes: number, maxSizeLabel: string) {
return z.object({
file: z.instanceof(Blob).refine((value) => value.size <= maxBytes, {
message: `File size should be less than ${maxSizeLabel}`,
}),
});
});
}
export async function POST(request: Request) {
logger.debug("[Upload API] Received upload request");
const session = await auth();
if (!session) {
logger.debug("[Upload API] Unauthorized - no session");
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (request.body === null) {
logger.debug("[Upload API] Request body is empty");
@ -197,12 +231,88 @@ export async function POST(request: Request) {
let filename = originalFilename;
let contentType = originalContentType;
const uploadPurpose = String(formData.get("purpose") || "");
const isDirectorGaussianUpload = uploadPurpose === "director-gaussian";
const isCanvasAudioUpload = uploadPurpose === "canvas-audio-process";
const isCanvasVideoUpload = uploadPurpose === "canvas-video-process";
const isOpenClawChatVideoUpload =
uploadPurpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE;
const isVideoCanvasAssetLibraryUpload =
uploadPurpose === "video-canvas-asset-library";
const isVideoCanvasResourceUpload =
uploadPurpose === VIDEO_CANVAS_RESOURCE_UPLOAD_PURPOSE;
const isVideoCanvasSubjectUpload =
uploadPurpose === "video-canvas-subject";
const isVideoCanvasSubjectVoiceUpload =
uploadPurpose === "video-canvas-subject-voice";
const videoCanvasSetting =
isDirectorGaussianUpload ||
isVideoCanvasResourceUpload ||
isVideoCanvasSubjectUpload
? await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(
() => undefined,
)
: undefined;
const videoCanvasConfig = videoCanvasSetting
? normalizeVideoCanvasFunctionConfig(videoCanvasSetting.value)
: defaultVideoCanvasFunctionConfig;
const gaussianConfig = videoCanvasConfig.directorDesk.gaussian;
const subjectLibraryConfig = videoCanvasConfig.subjectLibrary;
if (
isDirectorGaussianUpload &&
!GAUSSIAN_SPLAT_EXTENSION_RE.test(originalFilename)
) {
return NextResponse.json(
{ error: `高斯泼溅仅支持 ${gaussianConfig.uploadAccept}` },
{ status: 400 },
);
}
if (isVideoCanvasSubjectUpload) {
const acceptedTypes = new Set([
...getAcceptedMimeTypes(subjectLibraryConfig.imageAccept),
...getAcceptedMimeTypes(subjectLibraryConfig.videoAccept),
]);
if (!acceptedTypes.has(originalContentType)) {
return NextResponse.json(
{ error: "主体素材仅支持 JPG、PNG、WebP、MP4 或 MOV" },
{ status: 400 },
);
}
}
if (
isVideoCanvasSubjectVoiceUpload &&
!ALLOWED_UPLOAD_AUDIO_TYPES.has(originalContentType) &&
!ALLOWED_UPLOAD_VIDEO_TYPES.has(originalContentType)
) {
return NextResponse.json(
{ error: "音色素材仅支持 MP3、WAV、MP4 或 MOV" },
{ status: 400 },
);
}
const requestHostname = new URL(request.url).hostname.toLowerCase();
const isLoopbackRequest =
requestHostname === "localhost" ||
requestHostname === "127.0.0.1" ||
requestHostname === "::1";
const isLocalDevelopmentCanvasUpload =
(process.env.NODE_ENV !== "production" || isLoopbackRequest) &&
process.env.USE_REMOTE_STORAGE_IN_DEV !== "true" &&
(uploadPurpose === "canvas-upload" ||
isCanvasAudioUpload ||
isCanvasVideoUpload ||
isOpenClawChatVideoUpload ||
isDirectorGaussianUpload);
const session = isLocalDevelopmentCanvasUpload ? null : await auth();
if (!isLocalDevelopmentCanvasUpload && !session) {
logger.debug("[Upload API] Unauthorized - no session");
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const clientUploadId =
String(formData.get("clientUploadId") || "").trim() || null;
const isSvgUpload = contentType === "image/svg+xml";
if (
isSvgUpload &&
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type))
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session?.user.type))
) {
return NextResponse.json(
{ error: "SVG uploads are only allowed for administrator icon settings" },
@ -216,7 +326,19 @@ export async function POST(request: Request) {
sizeInMB: (file.size / 1024 / 1024).toFixed(2) + "MB"
});
const validatedFile = FileSchema.safeParse({ file });
const maxFileSizeMb = isDirectorGaussianUpload
? gaussianConfig.maxFileSizeMb
: isVideoCanvasResourceUpload
? videoCanvasConfig.nodeCreation.upload.maxFileSizeMb
: isVideoCanvasSubjectUpload
? originalContentType.startsWith("video/")
? subjectLibraryConfig.videoMaxFileSizeMb
: subjectLibraryConfig.imageMaxFileSizeMb
: 50;
const validatedFile = createFileSchema(
maxFileSizeMb * 1024 * 1024,
`${maxFileSizeMb}MB`,
).safeParse({ file });
if (!validatedFile.success) {
const errorMessage = validatedFile.error.errors
@ -227,7 +349,36 @@ export async function POST(request: Request) {
return NextResponse.json({ error: errorMessage }, { status: 400 });
}
if (!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType)) {
if (
!isDirectorGaussianUpload &&
!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) &&
!(isCanvasAudioUpload && ALLOWED_UPLOAD_AUDIO_TYPES.has(contentType)) &&
!(isCanvasVideoUpload && ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType)) &&
!(
isOpenClawChatVideoUpload &&
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType)
) &&
!(
isVideoCanvasAssetLibraryUpload &&
(ALLOWED_UPLOAD_AUDIO_TYPES.has(contentType) ||
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType))
) &&
!(
isVideoCanvasResourceUpload &&
(ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) ||
ALLOWED_UPLOAD_AUDIO_TYPES.has(contentType) ||
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType))
) &&
!(
isVideoCanvasSubjectUpload &&
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType)
) &&
!(
isVideoCanvasSubjectVoiceUpload &&
(ALLOWED_UPLOAD_AUDIO_TYPES.has(contentType) ||
ALLOWED_UPLOAD_VIDEO_TYPES.has(contentType))
)
) {
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
logger.warn("[Upload API] Unsupported upload content type", {
contentType,
@ -248,7 +399,7 @@ export async function POST(request: Request) {
let uploadBuffer: Buffer = Buffer.from(fileBuffer);
let convertedFrom: string | null = null;
if (isHeicUpload(contentType, filename)) {
if (!isDirectorGaussianUpload && isHeicUpload(contentType, filename)) {
try {
const convertedBuffer = await convertHeicToJpeg(uploadBuffer);
logger.info("[Upload API] HEIC image converted to JPEG", {
@ -289,7 +440,7 @@ export async function POST(request: Request) {
let assetId: string | null = null;
try {
const asset = await upsertMediaAsset({
userId: session.user.id,
userId: session?.user.id || "",
mediaType: "image",
sourceType: "agent_workflow_preview_upload",
status: "ready",
@ -335,7 +486,10 @@ export async function POST(request: Request) {
// ✅ 优化原图(保持JPG/PNG格式)
// WebP格式不优化(用于预览图,保持原样)
const shouldOptimizeImage =
!isCanvasAudioUpload &&
!isCanvasVideoUpload &&
uploadPurpose !== "canvas-upload" &&
!isDirectorGaussianUpload &&
(contentType === 'image/jpeg' || contentType === 'image/jpg' || contentType === 'image/png');
if (shouldOptimizeImage) {
@ -343,7 +497,13 @@ export async function POST(request: Request) {
} else if (contentType === 'image/webp' || contentType === 'image/svg+xml') {
// WebP/SVG 不优化;SVG 是矢量文件,交给前端按状态反色渲染。
logger.debug(`[Upload] ${contentType}格式,跳过优化,直接上传`);
} else if (uploadPurpose === "canvas-upload") {
} else if (
uploadPurpose === "canvas-upload" ||
isCanvasAudioUpload ||
isCanvasVideoUpload ||
isOpenClawChatVideoUpload ||
isDirectorGaussianUpload
) {
logger.debug("[Upload] Canvas upload, skip server-side image optimization for faster paste/drop");
}
@ -358,33 +518,83 @@ export async function POST(request: Request) {
const data = await uploadFile(filename, optimizedArrayBuffer);
let assetId: string | null = null;
try {
const asset = await upsertMediaAsset({
userId: session.user.id,
mediaType: "image",
sourceType: "upload",
status: "ready",
originalUrl: data.url,
previewUrl: data.url,
thumbnailUrl: data.url,
mimeType: data.contentType || contentType || null,
displayName: filename,
sourceTable: "upload",
metadata: {
pathname: data.pathname,
size: optimizedBuffer.length,
clientUploadId,
convertedFrom,
},
});
assetId = asset?.id ?? null;
} catch (assetError) {
logger.warn("[Upload API] Failed to register uploaded image as media asset:", assetError);
const isLocalDevelopmentUpload = data.pathname.startsWith("uploads/local/");
if (
!isLocalDevelopmentUpload ||
isVideoCanvasResourceUpload ||
isVideoCanvasAssetLibraryUpload ||
isVideoCanvasSubjectUpload ||
isVideoCanvasSubjectVoiceUpload
) {
try {
const uploadedMediaType = isDirectorGaussianUpload
? "model"
: isCanvasAudioUpload || contentType.startsWith("audio/")
? "audio"
: isCanvasVideoUpload || contentType.startsWith("video/")
? "video"
: "image";
const asset = await upsertMediaAsset({
userId: session?.user.id || "",
mediaType: uploadedMediaType,
sourceType: isDirectorGaussianUpload
? "director_gaussian_upload"
: isCanvasAudioUpload
? "canvas_audio_process"
: isCanvasVideoUpload
? "canvas_video_process"
: isOpenClawChatVideoUpload
? "openclaw_chat_video_upload"
: isVideoCanvasResourceUpload
? VIDEO_CANVAS_RESOURCE_UPLOAD_SOURCE_TYPE
: isVideoCanvasAssetLibraryUpload
? "video_canvas_asset_library_upload"
: isVideoCanvasSubjectUpload
? VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE
: isVideoCanvasSubjectVoiceUpload
? VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE
: "upload",
status: "ready",
originalUrl: data.url,
previewUrl: isDirectorGaussianUpload ? null : data.url,
thumbnailUrl: isDirectorGaussianUpload ? null : data.url,
mimeType: contentType || data.contentType || null,
displayName: filename,
sourceTable: isDirectorGaussianUpload
? "director_gaussian_upload"
: isCanvasAudioUpload
? "canvas_audio_process"
: isCanvasVideoUpload
? "canvas_video_process"
: isOpenClawChatVideoUpload
? "openclaw_chat_video_upload"
: isVideoCanvasResourceUpload
? VIDEO_CANVAS_RESOURCE_UPLOAD_SOURCE_TYPE
: isVideoCanvasSubjectUpload
? VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE
: isVideoCanvasSubjectVoiceUpload
? VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE
: "upload",
metadata: {
pathname: data.pathname,
size: optimizedBuffer.length,
clientUploadId,
convertedFrom,
purpose: uploadPurpose || null,
category:
String(formData.get("assetCategory") || "").trim() || null,
},
});
assetId = asset?.id ?? null;
} catch (assetError) {
logger.warn("[Upload API] Failed to register uploaded image as media asset:", assetError);
}
}
logger.debug("[Upload API] Upload successful:", data.url);
return NextResponse.json({
...data,
contentType,
assetId,
clientUploadId,
convertedFrom,

View File

@ -3,11 +3,17 @@ import { auth } from '@/app/(auth)/auth';
import { db } from '@/lib/db';
import { canvasProject } from '@/lib/db/schema';
import { syncCanvasNodesForProject } from '@/lib/db/queries';
import { eq, and } from 'drizzle-orm';
import { eq, and, inArray } from 'drizzle-orm';
import {
buildStaleCanvasSaveConflictPayload,
isStaleCanvasSaveBaseRevision,
} from '@/lib/canvas/canvas-save-conflict';
import { normalizeCanvasProjectTitle } from '@/lib/canvas/project-title';
import {
getExplicitVideoCanvasWorkspaceMeta,
getVideoCanvasWorkspaceProjects,
withVideoCanvasWorkspaceMeta,
} from '@/lib/canvas/video-workspace';
function resolveWorkspaceType(input: unknown) {
return input === "agent" || input === "video" ? input : "canvas";
@ -98,18 +104,56 @@ export async function PUT(
);
}
const explicitWorkspaceMeta = getExplicitVideoCanvasWorkspaceMeta(
existingProject.canvasData
);
const canvasDataWithWorkspace =
canvasData !== undefined && explicitWorkspaceMeta
? withVideoCanvasWorkspaceMeta(canvasData, explicitWorkspaceMeta)
: canvasData;
const syncedCanvasData =
canvasData !== undefined
canvasDataWithWorkspace !== undefined
? await syncCanvasNodesForProject({
projectId: id,
userId: session.user.id,
canvasData,
canvasData: canvasDataWithWorkspace,
})
: undefined;
// 更新现有画布
const updateData: any = { updatedAt: new Date() };
if (title !== undefined) updateData.title = title;
if (title !== undefined) {
updateData.title = normalizeCanvasProjectTitle(
title,
existingProject.title
);
if (existingProject.workspaceType === "video") {
const videoProjects = await db
.select({
canvasData: canvasProject.canvasData,
id: canvasProject.id,
})
.from(canvasProject)
.where(
and(
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "video")
)
);
const workspaceProjectIds = getVideoCanvasWorkspaceProjects(
videoProjects,
existingProject
)
.map((project) => project.id)
.filter((projectId) => projectId !== id);
if (workspaceProjectIds.length > 0) {
await db
.update(canvasProject)
.set({ title: updateData.title, updatedAt: new Date() })
.where(inArray(canvasProject.id, workspaceProjectIds));
}
}
}
if (description !== undefined) updateData.description = description;
if (thumbnail !== undefined) updateData.thumbnail = thumbnail;
if (workspaceType !== undefined) updateData.workspaceType = workspaceType;
@ -143,7 +187,10 @@ export async function PUT(
.values({
id, // 使用指定的 ID
userId: session.user.id,
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
title: normalizeCanvasProjectTitle(
title,
getDefaultWorkspaceTitle(workspaceType || "canvas")
),
description,
workspaceType: workspaceType || "canvas",
canvasData: syncedCanvasData || {

View File

@ -0,0 +1,266 @@
import { and, desc, eq } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
duplicateVideoCanvasData,
getExplicitVideoCanvasWorkspaceMeta,
getNextVideoCanvasName,
getVideoCanvasCopyName,
getVideoCanvasWorkspaceProjects,
normalizeVideoCanvasName,
resolveVideoCanvasWorkspaceMeta,
withVideoCanvasWorkspaceMeta,
} from "@/lib/canvas/video-workspace";
import { db } from "@/lib/db";
import { syncCanvasNodesForProject } from "@/lib/db/queries";
import { canvasProject } from "@/lib/db/schema";
type CanvasProjectRow = typeof canvasProject.$inferSelect;
function toCanvasItem(project: CanvasProjectRow, currentId: string) {
const meta = resolveVideoCanvasWorkspaceMeta(project);
return {
createdAt: project.createdAt.toISOString(),
id: project.id,
isCurrent: project.id === currentId,
name: meta.canvasName,
};
}
async function getWorkspaceContext(userId: string, currentId: string) {
const projects = await db
.select()
.from(canvasProject)
.where(
and(
eq(canvasProject.userId, userId),
eq(canvasProject.workspaceType, "video"),
),
)
.orderBy(desc(canvasProject.createdAt));
const currentProject = projects.find((project) => project.id === currentId);
if (!currentProject) return null;
return {
currentProject,
projects: getVideoCanvasWorkspaceProjects(projects, currentProject),
};
}
async function ensureExplicitWorkspaceMetadata(projects: CanvasProjectRow[]) {
for (const project of projects) {
if (getExplicitVideoCanvasWorkspaceMeta(project.canvasData)) continue;
const canvasData = withVideoCanvasWorkspaceMeta(
project.canvasData,
resolveVideoCanvasWorkspaceMeta(project),
);
await db
.update(canvasProject)
.set({ canvasData })
.where(eq(canvasProject.id, project.id));
project.canvasData = canvasData;
}
}
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { id } = await params;
const context = await getWorkspaceContext(session.user.id, id);
if (!context) {
return NextResponse.json(
{ success: false, error: "画布不存在" },
{ status: 404 },
);
}
return NextResponse.json({
success: true,
data: {
canvases: context.projects.map((project) => toCanvasItem(project, id)),
currentCanvasId: id,
workspaceTitle: context.currentProject.title,
},
});
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
if (body?.action !== "create" && body?.action !== "copy") {
return NextResponse.json(
{ success: false, error: "不支持的画布操作" },
{ status: 400 },
);
}
const action = body.action;
const context = await getWorkspaceContext(session.user.id, id);
if (!context) {
return NextResponse.json(
{ success: false, error: "画布不存在" },
{ status: 404 },
);
}
await ensureExplicitWorkspaceMetadata(context.projects);
const currentMeta = resolveVideoCanvasWorkspaceMeta(context.currentProject);
const existingNames = context.projects.map(
(project) => resolveVideoCanvasWorkspaceMeta(project).canvasName,
);
const source =
action === "copy"
? context.projects.find((project) => project.id === body?.targetId)
: context.currentProject;
if (!source) {
return NextResponse.json(
{ success: false, error: "要复制的画布不存在" },
{ status: 404 },
);
}
const canvasName =
action === "copy"
? getVideoCanvasCopyName(
resolveVideoCanvasWorkspaceMeta(source).canvasName,
existingNames,
)
: getNextVideoCanvasName(existingNames);
const meta = {
canvasName,
workspaceId: currentMeta.workspaceId,
};
const canvasData =
action === "copy"
? duplicateVideoCanvasData(source.canvasData, meta)
: withVideoCanvasWorkspaceMeta(null, meta);
const [insertedProject] = await db
.insert(canvasProject)
.values({
canvasData,
description: context.currentProject.description,
title: context.currentProject.title,
userId: session.user.id,
workspaceType: "video",
})
.returning();
const syncedCanvasData = await syncCanvasNodesForProject({
canvasData,
projectId: insertedProject.id,
userId: session.user.id,
});
const [createdProject] = await db
.update(canvasProject)
.set({ canvasData: syncedCanvasData })
.where(eq(canvasProject.id, insertedProject.id))
.returning();
return NextResponse.json({
success: true,
data: toCanvasItem(createdProject || insertedProject, id),
});
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const context = await getWorkspaceContext(session.user.id, id);
if (!context) {
return NextResponse.json(
{ success: false, error: "画布不存在" },
{ status: 404 },
);
}
const target = context.projects.find((project) => project.id === body?.targetId);
if (!target) {
return NextResponse.json(
{ success: false, error: "画布不存在" },
{ status: 404 },
);
}
const currentMeta = resolveVideoCanvasWorkspaceMeta(context.currentProject);
const targetMeta = resolveVideoCanvasWorkspaceMeta(target);
const canvasName = normalizeVideoCanvasName(body?.name, targetMeta.canvasName);
const [updatedProject] = await db
.update(canvasProject)
.set({
canvasData: withVideoCanvasWorkspaceMeta(target.canvasData, {
canvasName,
workspaceId: currentMeta.workspaceId,
}),
updatedAt: new Date(),
})
.where(
and(
eq(canvasProject.id, target.id),
eq(canvasProject.userId, session.user.id),
),
)
.returning();
return NextResponse.json({
success: true,
data: toCanvasItem(updatedProject, id),
});
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const context = await getWorkspaceContext(session.user.id, id);
if (!context) {
return NextResponse.json(
{ success: false, error: "画布不存在" },
{ status: 404 },
);
}
const target = context.projects.find((project) => project.id === body?.targetId);
if (!target) {
return NextResponse.json(
{ success: false, error: "画布不存在" },
{ status: 404 },
);
}
if (context.projects.length <= 1) {
return NextResponse.json(
{ success: false, error: "至少保留一个画布" },
{ status: 409 },
);
}
await db
.delete(canvasProject)
.where(
and(
eq(canvasProject.id, target.id),
eq(canvasProject.userId, session.user.id),
),
);
const remaining = context.projects.filter((project) => project.id !== target.id);
return NextResponse.json({
success: true,
data: {
nextCanvasId: target.id === id ? remaining[0]?.id || null : id,
},
});
}

View File

@ -0,0 +1,16 @@
import { auth } from "@/app/(auth)/auth";
import { proxyCanvasAudioSource } from "@/lib/video-canvas/audio-source-proxy";
export async function GET(request: Request) {
const requestHostname = new URL(request.url).hostname.toLowerCase();
const isLoopbackRequest =
requestHostname === "localhost" ||
requestHostname === "127.0.0.1" ||
requestHostname === "::1";
const session = isLoopbackRequest ? null : await auth();
if (!isLoopbackRequest && !session?.user?.id) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
return proxyCanvasAudioSource(request);
}

View File

@ -26,6 +26,11 @@ import {
buildOpenClawMessageWithSpreadsheetMarkdown,
buildOpenClawSpreadsheetMarkdown,
} from "@/lib/openclaw-chat/spreadsheet-markdown";
import {
normalizeOpenClawChatMediaAttachments,
OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS,
type OpenClawChatMediaAttachment,
} from "@/lib/openclaw-chat/media-attachments";
import { generateUUID } from "@/lib/utils";
const CLAW_UPGRADE_RETRY_MESSAGE = "claw正在升级更新稍等5分钟再试";
@ -36,9 +41,18 @@ const requestSchema = z.object({
displayMessage: z.string().trim().max(8000).optional(),
userMessageId: z.string().uuid().optional(),
metadata: z.record(z.unknown()).optional(),
attachments: z
.array(z.unknown())
.max(OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS)
.optional()
.default([]),
});
type ParsedClawdChatRequest = z.infer<typeof requestSchema> & {
type ParsedClawdChatRequest = Omit<
z.infer<typeof requestSchema>,
"attachments"
> & {
attachments: OpenClawChatMediaAttachment[];
tableFiles: File[];
};
@ -86,14 +100,23 @@ async function parseClawdChatRequest(
const contentType = request.headers.get("content-type") || "";
if (!contentType.includes("multipart/form-data")) {
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
return parsed.success ? { ...parsed.data, tableFiles: [] } : null;
if (!parsed.success) return null;
const attachments = normalizeOpenClawChatMediaAttachments(
parsed.data.attachments
);
if (attachments.length !== parsed.data.attachments.length) {
return null;
}
return { ...parsed.data, attachments, tableFiles: [] };
}
const formData = await request.formData().catch(() => null);
if (!formData) return null;
const metadataValue = formData.get("metadata");
const attachmentsValue = formData.get("attachments");
let metadata: Record<string, unknown> | undefined;
let attachments: unknown[] = [];
if (typeof metadataValue === "string" && metadataValue.trim()) {
try {
const parsedMetadata = JSON.parse(metadataValue);
@ -106,6 +129,17 @@ async function parseClawdChatRequest(
return null;
}
}
if (typeof attachmentsValue === "string" && attachmentsValue.trim()) {
try {
const parsedAttachments = JSON.parse(attachmentsValue);
if (!Array.isArray(parsedAttachments)) {
return null;
}
attachments = parsedAttachments;
} catch {
return null;
}
}
const parsed = requestSchema.safeParse({
id: formData.get("id"),
@ -113,14 +147,25 @@ async function parseClawdChatRequest(
displayMessage: formData.get("displayMessage") || undefined,
userMessageId: formData.get("userMessageId") || undefined,
metadata,
attachments,
});
if (!parsed.success) return null;
const normalizedAttachments = normalizeOpenClawChatMediaAttachments(
parsed.data.attachments
);
if (normalizedAttachments.length !== parsed.data.attachments.length) {
return null;
}
const tableFiles = formData
.getAll("tableFiles")
.filter((entry): entry is File => isRequestFile(entry) && entry.size > 0);
return { ...parsed.data, tableFiles };
return {
...parsed.data,
attachments: normalizedAttachments,
tableFiles,
};
}
async function readOpenClawConfig() {
@ -164,8 +209,15 @@ export async function POST(request: Request) {
if (!parsed) {
return NextResponse.json({ error: "请求参数不正确" }, { status: 400 });
}
if (!parsed.message && parsed.tableFiles.length === 0) {
return NextResponse.json({ error: "请输入问题或上传表格" }, { status: 400 });
if (
!parsed.message &&
parsed.tableFiles.length === 0 &&
parsed.attachments.length === 0
) {
return NextResponse.json(
{ error: "请输入问题或上传附件" },
{ status: 400 }
);
}
const config = await readOpenClawConfig();
@ -280,6 +332,8 @@ export async function POST(request: Request) {
skill_tag_id: skillTagId || undefined,
skill_tag_name: skillTagName || undefined,
message: upstreamMessage,
attachments:
parsed.attachments.length > 0 ? parsed.attachments : undefined,
metadata: {
source: "ai_chatbot_canvas",
page: "/canvas",
@ -293,6 +347,8 @@ export async function POST(request: Request) {
user_department: openClawDepartmentName,
skill_tag_id: skillTagId || undefined,
skill_tag_name: skillTagName || undefined,
media_attachments:
parsed.attachments.length > 0 ? parsed.attachments : undefined,
spreadsheet_files: spreadsheetMarkdownResult?.files || undefined,
spreadsheet_markdown_truncated:
spreadsheetMarkdownResult?.truncated || undefined,
@ -384,7 +440,15 @@ export async function POST(request: Request) {
chatId: parsed.id,
role: "user",
parts: [{ type: "text", text: displayMessage }],
attachments: [],
attachments: parsed.attachments.map((attachment) => ({
name:
attachment.name ||
(attachment.type === "video" ? "视频附件" : "图片附件"),
url: attachment.url,
contentType:
attachment.content_type ||
(attachment.type === "video" ? "video/mp4" : "image/jpeg"),
})),
createdAt: userCreatedAt,
videoStorageStatus: null,
temporaryVideoUrl: null,
@ -393,6 +457,12 @@ export async function POST(request: Request) {
videoMetadata: {
composerMode: "clawd",
source: "openclaw",
imageAttachmentCount: parsed.attachments.filter(
(attachment) => attachment.type === "image"
).length,
videoAttachmentCount: parsed.attachments.filter(
(attachment) => attachment.type === "video"
).length,
skillTagId: skillTagId || null,
skillTagName: skillTagName || null,
},
@ -437,6 +507,7 @@ export async function POST(request: Request) {
createdAt: userMessage.createdAt.toISOString(),
composerMode: "clawd",
},
attachments: userMessage.attachments,
videoMetadata: userMessage.videoMetadata,
},
{

View File

@ -30,6 +30,12 @@ function normalizeBillingFeature(
}
function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImageRequest {
const requestedQuality =
body.providerImageParams &&
typeof body.providerImageParams === "object" &&
!Array.isArray(body.providerImageParams)
? String(body.providerImageParams.quality || "").trim().toLowerCase()
: "";
return {
prompt: String(body.prompt || "").trim(),
negativePrompt:
@ -49,6 +55,11 @@ function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImage
: 1,
promptExtend:
typeof body.promptExtend === "boolean" ? body.promptExtend : undefined,
providerImageParams: new Set(["low", "medium", "high"]).has(
requestedQuality,
)
? { quality: requestedQuality }
: undefined,
referenceImage:
typeof body.referenceImage === "string" ? body.referenceImage : undefined,
referenceImages: Array.isArray(body.referenceImages)

View File

@ -1,23 +1,61 @@
import { and, eq, inArray } from "drizzle-orm";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video";
import {
resolveVideoReferenceSelectionRule,
selectVideoReferenceImages,
} from "@/lib/canvas/video-reference-selection";
import { getSystemSetting } from "@/lib/db/queries";
import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema";
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
import {
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
import { normalizeVideoCanvasGenerationRequestCount } from "@/lib/video-canvas/video-generation-parameters";
import {
buildVideoCanvasSubjectProviderReferences,
composeVideoCanvasSubjectPrompt,
normalizeVideoCanvasAttachedSubjects,
supportsVideoCanvasSubjectUsage,
toVideoCanvasAttachedSubject,
VIDEO_CANVAS_SUBJECT_SOURCE_TYPE,
type VideoCanvasAttachedSubject,
} from "@/lib/video-canvas/subjects";
import {
composeVideoCanvasSmartStoryboardPrompt,
normalizeVideoCanvasSmartStoryboard,
supportsVideoCanvasSmartStoryboard,
validateVideoCanvasSmartStoryboard,
type VideoCanvasSmartStoryboard,
} from "@/lib/video-canvas/smart-storyboard";
import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai";
export const maxDuration = 300;
interface GenerateVideoRequest {
autoValidateMaterial?: boolean;
prompt?: string;
mode?: VideoGenerationMode;
images?: string[];
audioUrls?: string[];
resolution?: VideoResolution;
ratio?: VideoRatio;
duration?: number;
generateAudio?: boolean;
count?: number;
watermark?: boolean;
cameraFixed?: boolean;
seed?: number;
returnLastFrame?: boolean;
modelLabel?: string;
preferredModelRef?: string;
searchEnabled?: boolean;
smartStoryboard?: VideoCanvasSmartStoryboard;
subjects?: VideoCanvasAttachedSubject[];
}
function inferMode(images: string[]): VideoGenerationMode {
@ -39,18 +77,142 @@ export async function POST(request: Request) {
const body = (await request.json().catch(() => ({}))) as GenerateVideoRequest;
const prompt = String(body.prompt || "").trim();
if (!prompt) {
const count = normalizeVideoCanvasGenerationRequestCount(body.count, 1);
if (count === null) {
return NextResponse.json(
{ success: false, error: "生成数量仅支持 1、2 或 4 个" },
{ status: 400 },
);
}
const generateAudio = body.generateAudio !== false;
const setting = await getSystemSetting({
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
}).catch(() => null);
const config = setting?.value
? normalizeVideoCanvasFunctionConfig(setting.value)
: defaultVideoCanvasFunctionConfig;
const autoValidateMaterial =
typeof body.autoValidateMaterial === "boolean"
? body.autoValidateMaterial
: config.videoAdvancedSettings.defaultAutoValidateMaterial;
const searchEnabled =
typeof body.searchEnabled === "boolean"
? body.searchEnabled
: config.videoAdvancedSettings.defaultSearchEnabled;
const subjectUsageEnabled = supportsVideoCanvasSubjectUsage(
config.subjectLibrary,
`${body.modelLabel || ""} ${body.preferredModelRef || ""}`,
);
const requestedSubjects = subjectUsageEnabled
? normalizeVideoCanvasAttachedSubjects(
body.subjects,
config.subjectLibrary.videoUsage.maxSubjects,
)
: [];
const requestedSubjectIds = requestedSubjects
.map((subject) => subject.id)
.filter((id) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id));
const ownedSubjectRows = requestedSubjectIds.length > 0
? await db
.select({
description: mediaAsset.summary,
displayName: mediaAsset.displayName,
id: mediaAsset.id,
metadata: mediaAsset.metadata,
originalUrl: mediaAsset.originalUrl,
previewUrl: mediaAsset.previewUrl,
thumbnailUrl: mediaAsset.thumbnailUrl,
})
.from(mediaAsset)
.where(
and(
inArray(mediaAsset.id, requestedSubjectIds),
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.mediaType, "subject"),
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_SOURCE_TYPE),
eq(mediaAsset.status, "ready"),
),
)
: [];
const ownedSubjectsById = new Map(
ownedSubjectRows.map((subject) => [subject.id, subject]),
);
const subjects = requestedSubjects.flatMap((requestedSubject) => {
const ownedSubject = ownedSubjectsById.get(requestedSubject.id);
const normalized = ownedSubject
? toVideoCanvasAttachedSubject(ownedSubject)
: null;
return normalized ? [normalized] : [];
});
const subjectReferences = buildVideoCanvasSubjectProviderReferences(subjects);
const smartStoryboardSupported = supportsVideoCanvasSmartStoryboard(
config.smartStoryboard,
`${body.modelLabel || ""} ${body.preferredModelRef || ""}`,
);
const smartStoryboard = normalizeVideoCanvasSmartStoryboard(
body.smartStoryboard,
{
config: config.smartStoryboard,
fallbackPrompt: prompt,
fallbackTotalDurationSeconds: body.duration,
},
);
const storyboardValidation = validateVideoCanvasSmartStoryboard(
smartStoryboard,
);
if (
smartStoryboardSupported &&
smartStoryboard.enabled &&
!storyboardValidation.valid
) {
const message =
storyboardValidation.emptyShotIndexes.length > 0
? "智能分镜存在未填写的镜头描述"
: storyboardValidation.status === "over"
? "智能分镜总时长超出"
: "智能分镜总时长不足";
return NextResponse.json(
{ success: false, error: message },
{ status: 400 },
);
}
const promptWithStoryboard = smartStoryboardSupported
? composeVideoCanvasSmartStoryboardPrompt(
prompt,
smartStoryboard,
config.smartStoryboard,
)
: prompt;
const generationPrompt = composeVideoCanvasSubjectPrompt(
promptWithStoryboard,
subjects,
config.subjectLibrary.videoUsage.promptTemplate,
);
if (!generationPrompt) {
return NextResponse.json(
{ success: false, error: "请输入视频提示词" },
{ status: 400 }
);
}
const inputImages = Array.isArray(body.images)
? body.images.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 4)
const referenceRule = resolveVideoReferenceSelectionRule(
config.videoReferenceSelection,
`${body.modelLabel || ""} ${body.preferredModelRef || ""}`,
);
const rawImages = Array.isArray(body.images)
? body.images.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 20)
: [];
const inputImages = body.mode
? body.mode === "text-to-video" || body.mode === "audio-to-video"
? rawImages.slice(0, 4)
: selectVideoReferenceImages(rawImages, body.mode, referenceRule.maxImages)
: rawImages.slice(0, referenceRule.maxImages);
const { kept: reachableImages, dropped: unreachableImages } =
await filterReachableRemoteUrls(inputImages, { timeoutMs: 3000 });
const inputAudioUrls = Array.isArray(body.audioUrls)
? body.audioUrls.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 3)
: [];
const { kept: reachableAudioUrls, dropped: unreachableAudioUrls } =
await filterReachableRemoteUrls(inputAudioUrls, { timeoutMs: 3000 });
if (inputImages.length > 0 && reachableImages.length === 0) {
return NextResponse.json(
@ -59,17 +221,35 @@ export async function POST(request: Request) {
);
}
const mode = body.mode || inferMode(reachableImages);
const mode = body.mode || (reachableAudioUrls.length > 0 ? "audio-to-video" : inferMode(reachableImages));
const selectedImages =
mode === "text-to-video" || mode === "audio-to-video"
? reachableImages
: selectVideoReferenceImages(
reachableImages,
mode,
referenceRule.maxImages,
);
if (mode === "audio-to-video" && reachableAudioUrls.length === 0) {
return NextResponse.json(
{ success: false, error: "音频生视频至少需要 1 个可访问的音频素材" },
{ status: 400 }
);
}
const result = await executeDirectVideoGeneration({
prompt,
prompt: generationPrompt,
mode,
images: reachableImages,
images: selectedImages,
audioUrls: reachableAudioUrls,
resolution: body.resolution,
ratio: body.ratio,
duration:
typeof body.duration === "number" && Number.isFinite(body.duration)
? body.duration
: undefined,
smartStoryboardSupported && smartStoryboard.enabled
? smartStoryboard.totalDurationSeconds
: typeof body.duration === "number" && Number.isFinite(body.duration)
? body.duration
: undefined,
generateAudio,
watermark: body.watermark,
cameraFixed: body.cameraFixed,
seed:
@ -95,8 +275,20 @@ export async function POST(request: Request) {
modelId: result.modelId,
modelRef: result.modelRef,
mode,
images: reachableImages,
images: selectedImages,
audioUrls: reachableAudioUrls,
autoValidateMaterial,
count,
generateAudio,
droppedImages: unreachableImages,
droppedAudioUrls: unreachableAudioUrls,
searchEnabled,
subjects,
subjectReferences,
smartStoryboard:
smartStoryboardSupported && smartStoryboard.enabled
? smartStoryboard
: undefined,
},
});
} catch (error) {

View File

@ -0,0 +1,16 @@
import { auth } from "@/app/(auth)/auth";
import { proxyCanvasVideoSource } from "@/lib/video-canvas/audio-source-proxy";
export async function GET(request: Request) {
const requestHostname = new URL(request.url).hostname.toLowerCase();
const isLoopbackRequest =
requestHostname === "localhost" ||
requestHostname === "127.0.0.1" ||
requestHostname === "::1";
const session = isLoopbackRequest ? null : await auth();
if (!isLoopbackRequest && !session?.user?.id) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
return proxyCanvasVideoSource(request);
}

View File

@ -9,12 +9,26 @@ import sharp from "sharp";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { getCompatibleTextGenerationTemperature } from "@/lib/ai/model-compat";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { inlineVisionImages } from "@/lib/ai/vision-inline";
import { getAiModelAgentCodingModelId, isAiModelMultimodal } from "@/lib/db/ai-model-flags";
import {
getAgentAiModelWithProvider,
getEnabledAiModelsWithProvider,
getSystemSetting,
} from "@/lib/db/queries";
import { createLogger } from "@/lib/observability/logger";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import {
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
import { normalizeVideoAnalysisTimeline } from "@/lib/video-canvas/video-analysis";
export const runtime = "nodejs";
export const maxDuration = 180;
@ -23,6 +37,11 @@ const execFileAsync = promisify(execFile);
const VIDEO_ANALYSIS_FRAME_COUNT = 4;
const VIDEO_ANALYSIS_MAX_SIDE = 960;
const VIDEO_ANALYSIS_TIMEOUT_MS = 15000;
const logger = createLogger("canvas-workflow-analyze");
type ModelCandidate = Awaited<
ReturnType<typeof getEnabledAiModelsWithProvider>
>[number];
const requestSchema = z.object({
mode: z.enum(["video-analysis", "character-scene-extraction", "story-script", "agent-plan"]),
@ -30,6 +49,7 @@ const requestSchema = z.object({
imageUrls: z.array(z.string().trim()).optional(),
videoUrls: z.array(z.string().trim()).optional(),
nodeTitle: z.string().trim().optional(),
durationSeconds: z.number().positive().max(3600).optional(),
preferredModelRef: z.string().trim().optional(),
model: z.string().trim().optional(),
});
@ -56,6 +76,24 @@ function supportsVision(model: {
);
}
function isAnalysisModelCandidate(candidate: ModelCandidate) {
const descriptor = {
capabilities: candidate.model.capabilities,
modelId: candidate.model.modelId,
providerType: candidate.provider.type,
supportsImageGen: candidate.model.supportsImageGen,
};
return (
candidate.model.isEnabled &&
candidate.provider.isEnabled &&
!candidate.model.isSubModel &&
candidate.model.healthStatus !== "auto_disabled" &&
!isImageGenerationModelDescriptor(descriptor) &&
!isVideoModelDescriptor(descriptor)
);
}
function extractJsonBlock<T = Record<string, unknown>>(text: string): T | null {
const matched = String(text || "").match(/\{[\s\S]*\}/);
if (!matched) return null;
@ -107,9 +145,10 @@ function normalizeShotIdeas(value: unknown) {
function normalizeVideoAnalysisRows(
value: unknown,
framePreviews: string[],
totalDurationSeconds?: number | null,
) {
if (!Array.isArray(value)) return [];
return value
const rows = value
.map((item, index) => {
const raw =
item && typeof item === "object" && !Array.isArray(item)
@ -210,6 +249,8 @@ function normalizeVideoAnalysisRows(
} => Boolean(item),
)
.slice(0, 8);
return normalizeVideoAnalysisTimeline(rows, totalDurationSeconds);
}
function normalizeStoryShot(value: unknown, index: number) {
@ -489,6 +530,7 @@ function buildVideoAnalysisPrompt(params: {
frameCount: number;
imageCount: number;
videoCount: number;
durationSeconds?: number | null;
}) {
return [
"你是视频画布工作流里的视频分析节点,需要为后续分镜和生成节点提供结构化输出。",
@ -496,6 +538,9 @@ function buildVideoAnalysisPrompt(params: {
`上游参考图数量:${params.imageCount}`,
`上游视频数量:${params.videoCount}`,
`已提供按时间顺序抽取的视频关键帧数量:${params.frameCount}`,
params.durationSeconds
? `视频总时长:${Number(params.durationSeconds.toFixed(2))} 秒`
: "视频总时长未知,请按关键帧顺序稳健估算。",
params.prompt ? `上游脚本/说明:\n${params.prompt}` : "上游没有额外脚本,请重点依据参考画面分析。",
"",
"请只输出 JSON,不要输出 markdown,不要输出解释。",
@ -530,6 +575,9 @@ function buildVideoAnalysisPrompt(params: {
"recommendedNextSteps": ["2-4条建议的下游动作"]
}`,
"analysisRows 请按镜头时间顺序输出;短视频可以输出 1 行,复杂视频输出 2-6 行。",
params.durationSeconds
? `所有镜头必须从 0 秒连续覆盖到 ${Number(params.durationSeconds.toFixed(2))} 秒,不能重叠、留空或超出视频总时长。`
: "镜头时间不能重叠,开始时间、结束时间和时长必须相互一致。",
"每行必须覆盖镜号、开始时间、结束时间、时长、画面描述、叙事内容、景别、摄影机角度、摄影机运动、焦距与景深、光线、背景音乐、人声/音效、图像生成提示词、视频运动提示词和关键帧。",
"imagePrompt 和 motionPrompt 优先使用英文;其他字段使用中文;keyframeIndex 使用 1 开始的序号,对应你看到的关键帧顺序。",
"如果没有真实视频帧,只能依据参考图和脚本推断,也要正常输出,但措辞要稳健。",
@ -594,8 +642,9 @@ async function downscaleVideoFrameForVision(inputBuffer: Buffer) {
async function extractVideoFrameDataUrls(videoUrl: string, frameCount = VIDEO_ANALYSIS_FRAME_COUNT) {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "canvas-video-analysis-"));
let durationSeconds: number | null = null;
try {
const durationSeconds = await getVideoDurationSeconds(videoUrl);
durationSeconds = await getVideoDurationSeconds(videoUrl);
const timestamps = buildVideoFrameTimestamps(durationSeconds, frameCount);
const frames = await Promise.all(
timestamps.map(async (timestamp, index) => {
@ -627,13 +676,13 @@ async function extractVideoFrameDataUrls(videoUrl: string, frameCount = VIDEO_AN
return `data:image/jpeg;base64,${processed.toString("base64")}`;
}),
);
return frames.filter(Boolean);
return { durationSeconds, frames: frames.filter(Boolean) };
} catch (error) {
console.warn("[Canvas Workflow Analyze] extract video frames failed:", {
message: error instanceof Error ? error.message : String(error),
videoUrl,
});
return [];
return { durationSeconds, frames: [] as string[] };
} finally {
await rm(tempDir, { force: true, recursive: true }).catch(() => undefined);
}
@ -780,10 +829,13 @@ export async function POST(request: Request) {
const prompt = normalizeText(parsed.data.prompt);
const imageUrls = sanitizeUrls(parsed.data.imageUrls, 4);
const videoUrls = sanitizeUrls(parsed.data.videoUrls, 4);
const extractedVideoFrames =
const extractedVideo =
parsed.data.mode === "video-analysis" && videoUrls.length > 0
? await extractVideoFrameDataUrls(videoUrls[0], VIDEO_ANALYSIS_FRAME_COUNT)
: [];
: { durationSeconds: null, frames: [] as string[] };
const extractedVideoFrames = extractedVideo.frames;
const videoDurationSeconds =
extractedVideo.durationSeconds || parsed.data.durationSeconds || null;
const preferredModelRef = normalizeText(parsed.data.preferredModelRef || parsed.data.model);
if (!prompt && imageUrls.length === 0 && videoUrls.length === 0) {
return NextResponse.json(
@ -797,60 +849,76 @@ export async function POST(request: Request) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const setting = await getSystemSetting({
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
}).catch(() => null);
const videoCanvasConfig = setting?.value
? normalizeVideoCanvasFunctionConfig(setting.value)
: defaultVideoCanvasFunctionConfig;
const functionDefinition = videoCanvasConfig.functions[parsed.data.mode];
if (!videoCanvasConfig.enabled || functionDefinition?.enabled === false) {
return NextResponse.json(
{ success: false, error: `${functionDefinition?.label || "当前功能"}已停用` },
{ status: 403 },
);
}
const pointsCharge = await prepareFeaturePointsCharge({
forceEnabled:
parsed.data.mode === "video-analysis" && (functionDefinition?.cost || 0) > 0,
userId: session.user.id,
unitCost: parsed.data.mode === "video-analysis" ? functionDefinition?.cost || 0 : 0,
reason: parsed.data.mode,
note: functionDefinition?.label || "视频画布工作流分析",
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const [allModels, agentModel] = await Promise.all([
getEnabledAiModelsWithProvider(false),
getAgentAiModelWithProvider(),
]);
const compatibleModels = allModels.filter(
(item) => !item.model.isSubModel && item.model.isEnabled
);
const compatibleModels = allModels.filter(isAnalysisModelCandidate);
const hasVisionInputs = imageUrls.length > 0 || extractedVideoFrames.length > 0;
const requestCandidates = hasVisionInputs
? compatibleModels.filter((item) => supportsVision(item.model))
: compatibleModels;
const preferredModel =
preferredModelRef
? compatibleModels.find(
? requestCandidates.find(
(item) =>
item.model.id === preferredModelRef ||
item.model.modelId === preferredModelRef,
)
: null;
const compatibleAgentModel =
agentModel &&
isAnalysisModelCandidate(agentModel) &&
(!hasVisionInputs || supportsVision(agentModel.model))
? agentModel
: null;
const orderedCandidates = [
preferredModel,
compatibleAgentModel,
...requestCandidates,
].filter(
(candidate, index, items): candidate is ModelCandidate =>
Boolean(candidate) &&
items.findIndex((item) => item?.model.id === candidate?.model.id) === index,
);
const hasVisionInputs = imageUrls.length > 0 || extractedVideoFrames.length > 0;
let selectedModel =
preferredModel && (!hasVisionInputs || supportsVision(preferredModel.model))
? preferredModel
: hasVisionInputs &&
agentModel &&
supportsVision(agentModel.model)
? agentModel
: hasVisionInputs
? compatibleModels.find((item) => supportsVision(item.model))
: agentModel || compatibleModels[0];
if (!selectedModel) {
selectedModel = compatibleModels[0];
}
if (!selectedModel) {
if (orderedCandidates.length === 0) {
return NextResponse.json(
{ success: false, error: "当前站点没有可用的分析模型" },
{ status: 500 }
);
}
const useVision = hasVisionInputs && supportsVision(selectedModel.model);
const codingModelId = getAiModelAgentCodingModelId(selectedModel.model);
const model = await getLanguageModelById(selectedModel.model.id, codingModelId);
if (!model) {
return NextResponse.json(
{ success: false, error: "分析模型初始化失败" },
{ status: 500 }
);
}
const promptText =
parsed.data.mode === "video-analysis"
? buildVideoAnalysisPrompt({
? buildVideoAnalysisPrompt({
durationSeconds: videoDurationSeconds,
frameCount: extractedVideoFrames.length,
nodeTitle: normalizeText(parsed.data.nodeTitle),
prompt,
@ -877,7 +945,7 @@ export async function POST(request: Request) {
videoCount: videoUrls.length,
});
const inlinedImageReferences = useVision
const inlinedImageReferences = hasVisionInputs
? (
await inlineVisionImages(imageUrls, {
maxImages: 4,
@ -888,26 +956,79 @@ export async function POST(request: Request) {
})
).inlineDataUrls
: [];
const referenceImages = useVision
? [...inlinedImageReferences, ...extractedVideoFrames].slice(0, 6)
: [];
const referenceImages = [...inlinedImageReferences, ...extractedVideoFrames].slice(0, 6);
let generatedResult: {
candidate: ModelCandidate;
parsedJson: Record<string, unknown>;
text: string;
} | null = null;
const { text } = await generateText({
model,
messages: [
{
role: "user",
content: [
{ type: "text", text: promptText },
...referenceImages.map((image) => ({ type: "image" as const, image })),
for (const candidate of orderedCandidates.slice(0, 3)) {
try {
const model = await getLanguageModelById(
candidate.model.id,
getAiModelAgentCodingModelId(candidate.model),
);
if (!model) continue;
const { text } = await generateText({
abortSignal: AbortSignal.timeout(55_000),
model,
messages: [
{
role: "user",
content: [
{ type: "text", text: promptText },
...referenceImages.map((image) => ({ type: "image" as const, image })),
],
},
],
},
],
temperature: 0.2,
maxTokens: parsed.data.mode === "story-script" ? 4200 : parsed.data.mode === "video-analysis" ? 2200 : 1600,
} as any);
temperature: getCompatibleTextGenerationTemperature(candidate, 0.2),
maxTokens:
parsed.data.mode === "story-script"
? 4200
: parsed.data.mode === "video-analysis"
? 2200
: 1600,
} as any);
const parsedJson = extractJsonBlock<Record<string, unknown>>(text);
if (!parsedJson) throw new Error("分析模型没有返回有效 JSON");
if (
parsed.data.mode === "video-analysis" &&
normalizeVideoAnalysisRows(
parsedJson.analysisRows,
extractedVideoFrames,
videoDurationSeconds,
).length === 0
) {
throw new Error("分析模型没有返回镜头数据");
}
generatedResult = { candidate, parsedJson, text };
break;
} catch (error) {
logger.warn("workflow analysis model candidate failed", {
error,
modelId: candidate.model.id,
});
}
}
const parsedJson = extractJsonBlock<Record<string, unknown>>(text) || {};
if (!generatedResult) {
return NextResponse.json(
{ success: false, error: "分析模型暂时不可用,请稍后重试" },
{ status: 502 },
);
}
const { candidate: selectedModel, parsedJson, text } = generatedResult;
const chargeResult = await pointsCharge.consume({
modelId: selectedModel.model.modelId,
});
if (pointsCharge.chargeable && chargeResult?.ok === false) {
return NextResponse.json(
{ success: false, error: "积分扣除失败,请刷新余额后重试" },
{ status: 402 },
);
}
const summary =
normalizeText(parsedJson.summary) ||
normalizeText(text).slice(0, 160);
@ -916,6 +1037,7 @@ export async function POST(request: Request) {
const analysisRows = normalizeVideoAnalysisRows(
parsedJson.analysisRows,
extractedVideoFrames,
videoDurationSeconds,
);
const shotIdeas =
normalizeShotIdeas(parsedJson.shotIdeas).length > 0

View File

@ -2,6 +2,7 @@ import { sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { isAgentModeScopedChatId } from "@/lib/chat/messages-route-access";
import { db } from "@/lib/db";
import { clientPerformanceEvent } from "@/lib/db/schema";
@ -167,9 +168,12 @@ export async function POST(request: Request) {
console.warn("[ClientPerformance] prune failed", error);
});
const persistedChatId =
event.chatId && !isAgentModeScopedChatId(event.chatId) ? event.chatId : null;
await db.insert(clientPerformanceEvent).values({
userId: session?.user?.id || null,
chatId: event.chatId || null,
chatId: persistedChatId,
projectId: clampText(event.projectId, 64),
eventType: clampText(event.eventType, 64) || "unknown",
section: event.section || "unknown",

View File

@ -1,9 +1,15 @@
import { NextResponse } from "next/server";
import sharp from "sharp";
import { auth } from "@/app/(auth)/auth";
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
import { getSystemSetting } from "@/lib/db/queries";
import { createLogger } from "@/lib/observability/logger";
import { uploadFile } from "@/lib/storage/service";
import sharp from "sharp";
import {
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
import { readTrustedLocalImage } from "@/lib/video-canvas/local-image-input";
export const runtime = "nodejs";
export const maxDuration = 30;
@ -21,10 +27,12 @@ interface PickObjectRequest {
imageData: string; // 带红色标记的图片 base64
clickX: number; // 点击位置 X (0-1)
clickY: number; // 点击位置 Y (0-1)
mode?: string;
}
interface PickObjectResult {
name: string;
labels: string[];
position: string;
confidence: number;
boundingBox: {
@ -72,7 +80,7 @@ function ensureImageSizeAllowed(byteLength: number) {
}
}
async function downscaleImageForVision(buffer: Buffer) {
async function prepareImageForVision(buffer: Buffer, clickX: number, clickY: number) {
const source = sharp(buffer, { failOn: "none" });
const meta = await source.metadata();
const hasAlpha = Boolean(meta.hasAlpha);
@ -88,41 +96,50 @@ async function downscaleImageForVision(buffer: Buffer) {
withoutEnlargement: true,
});
if (hasAlpha) {
return {
buffer: await resized.png({ compressionLevel: 9 }).toBuffer(),
ext: "png",
width,
height,
};
}
return {
buffer: await resized.jpeg({ quality: 84, mozjpeg: true }).toBuffer(),
ext: "jpg",
width,
height,
};
}
async function uploadVisionAnalysisImage(buffer: Buffer, filenamePrefix: string) {
const { buffer: processedBuffer, ext, width, height } = await downscaleImageForVision(buffer);
const filename = `${filenamePrefix}-${Date.now()}.${ext}`;
const uploaded = await uploadFile(filename, processedBuffer as any);
const resizedBuffer = hasAlpha
? await resized.png({ compressionLevel: 9 }).toBuffer()
: await resized.jpeg({ quality: 84, mozjpeg: true }).toBuffer();
const resizedMeta = await sharp(resizedBuffer).metadata();
const resizedWidth = resizedMeta.width || MAX_VISION_SIDE;
const resizedHeight = resizedMeta.height || MAX_VISION_SIDE;
const markerRadius = Math.max(10, Math.round(Math.min(resizedWidth, resizedHeight) * 0.035));
const markerX = Math.round(Math.max(0, Math.min(1, clickX)) * resizedWidth);
const markerY = Math.round(Math.max(0, Math.min(1, clickY)) * resizedHeight);
const markerSvg = Buffer.from(
`<svg width="${resizedWidth}" height="${resizedHeight}" xmlns="http://www.w3.org/2000/svg">
<circle cx="${markerX}" cy="${markerY}" r="${markerRadius}" fill="none" stroke="#ff2d2d" stroke-width="${Math.max(4, Math.round(markerRadius * 0.28))}" />
<line x1="${markerX - markerRadius}" y1="${markerY}" x2="${markerX + markerRadius}" y2="${markerY}" stroke="#ff2d2d" stroke-width="3" />
<line x1="${markerX}" y1="${markerY - markerRadius}" x2="${markerX}" y2="${markerY + markerRadius}" stroke="#ff2d2d" stroke-width="3" />
</svg>`,
);
const outputBuffer = await sharp(resizedBuffer)
.composite([{ input: markerSvg, left: 0, top: 0 }])
.png({ compressionLevel: 9 })
.toBuffer();
logger.info("vision image prepared", {
originalWidth: width,
originalHeight: height,
maxSide: MAX_VISION_SIDE,
outputExt: ext,
outputBytes: processedBuffer.length,
outputExt: "png",
outputBytes: outputBuffer.length,
});
return uploaded.url;
return `data:image/png;base64,${outputBuffer.toString("base64")}`;
}
async function normalizeImageInput(imageData: string): Promise<string> {
async function normalizeImageInput(
imageData: string,
clickX: number,
clickY: number,
): Promise<string> {
const raw = String(imageData || "").trim();
if (!raw) throw new Error("缺少图片数据");
const localImage = await readTrustedLocalImage(raw);
if (localImage) {
ensureImageSizeAllowed(localImage.byteLength);
return prepareImageForVision(localImage, clickX, clickY);
}
if (/^https?:\/\//i.test(raw)) {
const url = new URL(raw);
if (!["http:", "https:"].includes(url.protocol) || isBlockedImageHost(url.hostname)) {
@ -145,7 +162,7 @@ async function normalizeImageInput(imageData: string): Promise<string> {
}
const arrayBuffer = await response.arrayBuffer();
ensureImageSizeAllowed(arrayBuffer.byteLength);
return uploadVisionAnalysisImage(Buffer.from(arrayBuffer), "pick-object-url");
return prepareImageForVision(Buffer.from(arrayBuffer), clickX, clickY);
} finally {
clearTimeout(timeout);
}
@ -160,7 +177,7 @@ async function normalizeImageInput(imageData: string): Promise<string> {
ensureImageSizeAllowed(Math.ceil((base64Body.length * 3) / 4));
const buffer = Buffer.from(base64Body, "base64");
ensureImageSizeAllowed(buffer.byteLength);
return uploadVisionAnalysisImage(buffer, "pick-object-base64");
return prepareImageForVision(buffer, clickX, clickY);
}
export async function POST(request: Request) {
@ -204,12 +221,19 @@ export async function POST(request: Request) {
}, { status: 400 });
}
const normalizedImage = await normalizeImageInput(imageData);
const normalizedImage = await normalizeImageInput(imageData, clickX, clickY);
const setting = await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(
() => null,
);
const config = setting?.value
? normalizeVideoCanvasFunctionConfig(setting.value)
: defaultVideoCanvasFunctionConfig;
const definition = config.functions["element-mark"];
logger.info("vision model selected", { model: "qwen3-vl" });
logger.info("vision model selected", { model: definition?.model || "qwen3-vl-plus" });
// 构建 prompt - AI 通过红色标记来识别用户选择的物品
const prompt = `
const fallbackPrompt = `
图片中有一个红色圆形标记(带十字线),请识别这个红色标记所指向的具体物品。
【重要】
@ -232,12 +256,17 @@ export async function POST(request: Request) {
【position 格式】固定4字,从以下选择:
${POSITION_OPTIONS.join('、')}
`.trim();
const prompt = String(definition?.prompt || fallbackPrompt).replace(
"{{positionOptions}}",
POSITION_OPTIONS.join("、"),
);
logger.info("vision model call started");
// 调用视觉模型,发送带红色标记的图片
const { text: responseText, model } = await callQwen3Vl({
imageUrl: normalizedImage,
modelIds: definition?.model ? [definition.model] : undefined,
prompt,
temperature: 0.1,
maxTokens: 300,
@ -292,6 +321,13 @@ ${POSITION_OPTIONS.join('、')}
result = {
name,
labels: Array.from(
new Set<string>(
(Array.isArray(parsed.labels) ? parsed.labels : [name])
.map((label: unknown) => String(label || "").trim().slice(0, 8))
.filter(Boolean),
),
).slice(0, 4),
position,
confidence,
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 }, // 不再使用,保持兼容
@ -312,6 +348,7 @@ ${POSITION_OPTIONS.join('、')}
result = {
name: '物品',
labels: ['物品'],
position,
confidence: 0.5,
boundingBox: { x: clickX, y: clickY, width: 0, height: 0 },

View File

@ -6,6 +6,7 @@ import { model3dAsset, paymentOrder, systemSettings } from "@/lib/db/schema";
import {
getPointsLedgerByUserId,
getPointsSystemConfig,
getSystemSetting,
getUserById,
} from "@/lib/db/queries";
import { resolveThreeDModelDisplayName } from "@/lib/model3d/model-alias-config";
@ -18,6 +19,12 @@ import {
normalizeMembershipUserOverrides,
resolveMembershipPlanIdForUser,
} from "@/lib/membership/config";
import {
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
hasEnabledFixedVideoCanvasPointCosts,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
const MEMBERSHIP_CONFIG_KEY = "membership_config";
const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
@ -129,6 +136,7 @@ export async function GET() {
paymentOrders,
configSetting,
overridesSetting,
videoCanvasSetting,
] =
await Promise.all([
getUserById({ id: session.user.id }),
@ -157,6 +165,7 @@ export async function GET() {
.where(eq(systemSettings.key, MEMBERSHIP_USER_OVERRIDES_KEY))
.limit(1)
.then((rows) => rows[0]),
getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(() => null),
]);
const threeDModelAliasConfig = await getThreeDModelAliasConfigForRuntime();
@ -274,6 +283,11 @@ export async function GET() {
].sort((a, b) => {
return new Date(b.date).getTime() - new Date(a.date).getTime();
});
const videoCanvasConfig = videoCanvasSetting?.value
? normalizeVideoCanvasFunctionConfig(videoCanvasSetting.value)
: defaultVideoCanvasFunctionConfig;
const fixedVideoCanvasPointsEnabled =
hasEnabledFixedVideoCanvasPointCosts(videoCanvasConfig);
return NextResponse.json({
profile: {
@ -281,7 +295,7 @@ export async function GET() {
email: dbUser?.email || session.user.email || "",
currentPlanName: currentPlan.name,
currentPlanId,
availablePoints: pointsConfig.enabled
availablePoints: pointsConfig.enabled || fixedVideoCanvasPointsEnabled
? Math.max(0, Number(dbUser?.pointsBalance || 0))
: currentPlan.monthlyCredits,
monthlyCredits: currentPlan.monthlyCredits,

View File

@ -6,9 +6,16 @@ import { systemSettings } from "@/lib/db/schema";
import {
checkUserImageQuota,
getPointsSystemConfig,
getSystemSetting,
getUserById,
getUserMonthlyImageCount,
} from "@/lib/db/queries";
import {
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
hasEnabledFixedVideoCanvasPointCosts,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
import {
defaultMembershipConfig,
defaultMembershipUserOverrides,
@ -29,7 +36,15 @@ export async function GET() {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const [configSetting, overridesSetting, quota, totalImages, pointsConfig, dbUser] = await Promise.all([
const [
configSetting,
overridesSetting,
quota,
totalImages,
pointsConfig,
dbUser,
videoCanvasSetting,
] = await Promise.all([
db
.select()
.from(systemSettings)
@ -46,6 +61,7 @@ export async function GET() {
getUserMonthlyImageCount(session.user.id),
getPointsSystemConfig(),
getUserById({ id: session.user.id }),
getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(() => null),
]);
const config = configSetting?.value
@ -63,7 +79,15 @@ export async function GET() {
);
const currentPlan = getMembershipPlanById(config, currentPlanId);
const currentCredits = pointsConfig.enabled
const videoCanvasConfig = videoCanvasSetting?.value
? normalizeVideoCanvasFunctionConfig(videoCanvasSetting.value)
: defaultVideoCanvasFunctionConfig;
const fixedVideoCanvasPointsEnabled =
hasEnabledFixedVideoCanvasPointCosts(videoCanvasConfig);
const effectivePointsConfig = fixedVideoCanvasPointsEnabled
? { ...pointsConfig, enabled: true }
: pointsConfig;
const currentCredits = effectivePointsConfig.enabled
? Math.max(0, Number(dbUser?.pointsBalance || 0))
: quota.remaining !== null
? Math.max(0, quota.remaining)
@ -71,7 +95,7 @@ export async function GET() {
return NextResponse.json({
config,
pointsSystemConfig: pointsConfig,
pointsSystemConfig: effectivePointsConfig,
isHighVip: Boolean(dbUser?.isHighVip),
currentPlanId,
currentPlan,

View File

@ -0,0 +1,267 @@
import { and, desc, eq, ilike, inArray, ne, sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { mediaAsset } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger";
import {
VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE,
} from "@/lib/video-canvas/subjects";
export const runtime = "nodejs";
const logger = createLogger("video-canvas-assets");
const SUPPORTED_MEDIA_TYPES = new Set(["image", "video", "audio", "model"]);
const ASSET_CATEGORIES = new Set(["其它", "人物", "场景", "物品", "风格", "音效"]);
function normalizeAssetIds(value: unknown) {
if (!Array.isArray(value)) return [];
return Array.from(
new Set(
value
.map((item) => String(item || "").trim())
.filter(Boolean)
.slice(0, 50),
),
);
}
export async function GET(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 },
);
}
const url = new URL(request.url);
const query = url.searchParams.get("q")?.trim().slice(0, 120) || "";
const requestedType = url.searchParams.get("mediaType")?.trim() || "";
const mediaType = SUPPORTED_MEDIA_TYPES.has(requestedType)
? requestedType
: "";
const filters = [
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.status, "ready"),
ne(mediaAsset.mediaType, "subject"),
ne(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE),
ne(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE),
];
if (mediaType) filters.push(eq(mediaAsset.mediaType, mediaType));
if (query) filters.push(ilike(mediaAsset.displayName, `%${query}%`));
const assets = await db
.select({
createdAt: mediaAsset.createdAt,
displayName: mediaAsset.displayName,
durationMs: mediaAsset.durationMs,
id: mediaAsset.id,
mediaType: mediaAsset.mediaType,
metadata: mediaAsset.metadata,
mimeType: mediaAsset.mimeType,
originalUrl: mediaAsset.originalUrl,
previewUrl: mediaAsset.previewUrl,
thumbnailUrl: mediaAsset.thumbnailUrl,
})
.from(mediaAsset)
.where(and(...filters))
.orderBy(desc(mediaAsset.updatedAt))
.limit(100);
return NextResponse.json({ success: true, assets });
} catch (error) {
logger.error("asset list failed", { error });
return NextResponse.json(
{ success: false, error: "资产加载失败" },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 },
);
}
const body = (await request.json().catch(() => null)) as {
action?: string;
name?: string;
} | null;
if (body?.action !== "create-folder") {
return NextResponse.json(
{ success: false, error: "不支持的操作" },
{ status: 400 },
);
}
const name = String(body.name || "").trim().slice(0, 20);
if (!name) {
return NextResponse.json(
{ success: false, error: "请输入文件夹名称" },
{ status: 400 },
);
}
const folderUrl = `asset-folder://${crypto.randomUUID()}`;
const [folder] = await db
.insert(mediaAsset)
.values({
displayName: name,
mediaType: "folder",
metadata: { isFolder: true },
normalizedUrl: folderUrl,
originalUrl: folderUrl,
sourceTable: "video_canvas_asset_library",
sourceType: "video_canvas_asset_folder",
status: "ready",
userId: session.user.id,
})
.returning();
return NextResponse.json({ success: true, asset: folder });
} catch (error) {
logger.error("asset folder creation failed", { error });
return NextResponse.json(
{ success: false, error: "文件夹创建失败" },
{ status: 500 },
);
}
}
export async function PATCH(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 },
);
}
const body = (await request.json().catch(() => null)) as {
action?: string;
assetIds?: unknown;
category?: string;
folderId?: string | null;
} | null;
const assetIds = normalizeAssetIds(body?.assetIds);
if (!assetIds.length) {
return NextResponse.json(
{ success: false, error: "请选择资产" },
{ status: 400 },
);
}
let metadataPatch: Record<string, unknown>;
if (body?.action === "update-category") {
const category = String(body.category || "").trim();
if (!ASSET_CATEGORIES.has(category)) {
return NextResponse.json(
{ success: false, error: "资产标签无效" },
{ status: 400 },
);
}
metadataPatch = { category };
} else if (body?.action === "move-to-folder") {
const folderId = String(body.folderId || "").trim() || null;
if (folderId) {
const [folder] = await db
.select({ id: mediaAsset.id })
.from(mediaAsset)
.where(
and(
eq(mediaAsset.id, folderId),
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.mediaType, "folder"),
),
)
.limit(1);
if (!folder) {
return NextResponse.json(
{ success: false, error: "文件夹不存在" },
{ status: 404 },
);
}
}
metadataPatch = { folderId };
} else {
return NextResponse.json(
{ success: false, error: "不支持的操作" },
{ status: 400 },
);
}
const updated = await db
.update(mediaAsset)
.set({
metadata: sql`coalesce(${mediaAsset.metadata}, '{}'::jsonb) || ${JSON.stringify(metadataPatch)}::jsonb`,
updatedAt: new Date(),
})
.where(
and(
eq(mediaAsset.userId, session.user.id),
inArray(mediaAsset.id, assetIds),
ne(mediaAsset.mediaType, "folder"),
),
)
.returning({ id: mediaAsset.id });
return NextResponse.json({ success: true, updated: updated.length });
} catch (error) {
logger.error("asset batch update failed", { error });
return NextResponse.json(
{ success: false, error: "批量更新失败" },
{ status: 500 },
);
}
}
export async function DELETE(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 },
);
}
const body = (await request.json().catch(() => null)) as {
assetIds?: unknown;
} | null;
const assetIds = normalizeAssetIds(body?.assetIds);
if (!assetIds.length) {
return NextResponse.json(
{ success: false, error: "请选择资产" },
{ status: 400 },
);
}
const removed = await db
.delete(mediaAsset)
.where(
and(
eq(mediaAsset.userId, session.user.id),
inArray(mediaAsset.id, assetIds),
ne(mediaAsset.mediaType, "folder"),
),
)
.returning({ id: mediaAsset.id });
return NextResponse.json({ success: true, removed: removed.length });
} catch (error) {
logger.error("asset batch delete failed", { error });
return NextResponse.json(
{ success: false, error: "批量删除失败" },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,309 @@
import { generateText } from "ai";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { inlineVisionImages } from "@/lib/ai/vision-inline";
import {
getAiModelAgentCodingModelId,
isAiModelMultimodal,
} from "@/lib/db/ai-model-flags";
import {
getAgentAiModelWithProvider,
getEnabledAiModelsWithProvider,
getSystemSetting,
} from "@/lib/db/queries";
import { createLogger } from "@/lib/observability/logger";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import {
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
export const runtime = "nodejs";
export const maxDuration = 90;
const logger = createLogger("video-canvas-director-ai-import");
const requestSchema = z.object({
imageUrl: z.string().trim().min(1).max(8_000_000),
mode: z.enum(["insert", "overwrite"]),
});
type ModelCandidate = Awaited<
ReturnType<typeof getEnabledAiModelsWithProvider>
>[number];
const BODY_TYPES = new Set([
"mannequin",
"female",
"broad",
"muscular",
"slim",
"teen",
"child",
"chibi",
]);
const GEOMETRY_TYPES = new Set([
"box",
"sphere",
"cylinder",
"torus",
"cone",
"pyramid",
]);
function supportsVision(candidate: ModelCandidate) {
return (
candidate.model.supportsVision === true ||
isAiModelMultimodal({
capabilities: candidate.model.capabilities,
defaultParams: candidate.model.defaultParams,
})
);
}
function normalizeModelRef(value: unknown) {
const candidate = String(value || "").trim();
return candidate && candidate !== "auto" ? candidate : "";
}
function extractJson(value: unknown) {
const match = String(value || "").match(/\{[\s\S]*\}/);
if (!match) return null;
try {
const parsed = JSON.parse(match[0]);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function clampNumber(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number(value);
return Number.isFinite(parsed)
? Number(Math.min(max, Math.max(min, parsed)).toFixed(4))
: fallback;
}
function normalizeVector(
value: unknown,
fallback: [number, number, number],
min = -20,
max = 20,
): [number, number, number] {
if (!Array.isArray(value) || value.length < 3) return fallback;
return [
clampNumber(value[0], fallback[0], min, max),
clampNumber(value[1], fallback[1], min, max),
clampNumber(value[2], fallback[2], min, max),
];
}
function normalizeColor(value: unknown, fallback: string) {
const candidate = String(value || "").trim();
return /^#[0-9a-f]{6}$/i.test(candidate) ? candidate : fallback;
}
function normalizeName(value: unknown, fallback: string) {
return String(value || fallback).trim().slice(0, 80) || fallback;
}
function normalizeResult(value: Record<string, unknown>) {
const rawCharacters = Array.isArray(value.characters) ? value.characters : [];
const rawProps = Array.isArray(value.props) ? value.props : [];
const rawCameras = Array.isArray(value.cameras) ? value.cameras : [];
return {
summary: String(value.summary || "").trim().slice(0, 600),
characters: rawCharacters.slice(0, 8).flatMap((item, index) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
const record = item as Record<string, unknown>;
const bodyType = String(record.bodyType || "mannequin");
return [{
bodyType: BODY_TYPES.has(bodyType) ? bodyType : "mannequin",
color: normalizeColor(record.color, ["#7AA7FF", "#E56C5B", "#6CDB7A"][index % 3]!),
name: normalizeName(record.name, `角色${String.fromCharCode(65 + index)}`),
position: normalizeVector(record.position, [0, 0, index * 1.2]),
}];
}),
props: rawProps.slice(0, 12).flatMap((item, index) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
const record = item as Record<string, unknown>;
const geometryType = String(record.geometryType || "box");
return [{
color: normalizeColor(record.color, "#d7e7ff"),
geometryType: GEOMETRY_TYPES.has(geometryType) ? geometryType : "box",
name: normalizeName(record.name, `道具${index + 1}`),
position: normalizeVector(record.position, [0, 0, 0]),
scale: normalizeVector(record.scale, [1, 1, 1], 0.05, 20),
}];
}),
cameras: rawCameras.slice(0, 4).flatMap((item, index) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
const record = item as Record<string, unknown>;
return [{
fov: clampNumber(record.fov, 50, 10, 120),
name: normalizeName(record.name, `机位${index + 1}`),
position: normalizeVector(record.position, [0, 2.2, 8 + index * 1.5], -50, 50),
target: normalizeVector(record.target, [0, 1.2, 0], -50, 50),
}];
}),
};
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const parsed = requestSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
{ status: 400 },
);
}
let imageUrl = parsed.data.imageUrl;
if (!imageUrl.startsWith("data:image/")) {
try {
const resolved = new URL(imageUrl, request.url);
imageUrl = resolved.protocol === "http:" || resolved.protocol === "https:"
? resolved.toString()
: "";
} catch {
imageUrl = "";
}
}
if (!imageUrl) {
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
}
const setting = await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(() => null);
const config = setting?.value
? normalizeVideoCanvasFunctionConfig(setting.value)
: defaultVideoCanvasFunctionConfig;
const aiImportConfig = config.directorDesk.aiImport;
const definition = config.functions[aiImportConfig.functionKey];
if (!config.enabled || !aiImportConfig.enabled || !definition?.enabled) {
return NextResponse.json({ success: false, error: "AI 识图导入已停用" }, { status: 403 });
}
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: aiImportConfig.cost,
reason: "director-ai-import",
note: "3D导演台 AI 识图导入",
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const [models, agentModel] = await Promise.all([
getEnabledAiModelsWithProvider(false),
getAgentAiModelWithProvider(),
]);
const compatible = models.filter(
(candidate) =>
candidate.model.isEnabled &&
candidate.provider.isEnabled &&
!candidate.model.isSubModel &&
candidate.model.healthStatus !== "auto_disabled" &&
supportsVision(candidate),
);
const preferredRef =
normalizeModelRef(definition.model) || normalizeModelRef(config.defaultMultimodalModel);
const preferred = preferredRef
? compatible.find(
(candidate) =>
candidate.model.id === preferredRef ||
candidate.model.modelId === preferredRef,
)
: null;
const ordered = [
preferred,
agentModel && supportsVision(agentModel) ? agentModel : null,
...compatible,
].filter(
(candidate, index, items): candidate is ModelCandidate =>
Boolean(candidate) &&
items.findIndex((item) => item?.model.id === candidate?.model.id) === index,
);
if (ordered.length === 0) {
return NextResponse.json({ success: false, error: "当前站点没有可用的多模态模型" }, { status: 500 });
}
const referenceImages = imageUrl.startsWith("data:image/")
? [imageUrl]
: (
await inlineVisionImages([imageUrl], {
maxImages: 1,
maxSide: 1280,
timeoutMs: 15_000,
retries: 1,
loggerPrefix: "[Director AI Import]",
})
).inlineDataUrls;
if (referenceImages.length === 0) {
return NextResponse.json({ success: false, error: "识图素材读取失败" }, { status: 400 });
}
for (const candidate of ordered.slice(0, 3)) {
try {
const model = await getLanguageModelById(
candidate.model.id,
getAiModelAgentCodingModelId(candidate.model),
);
if (!model) continue;
const { text } = await generateText({
abortSignal: AbortSignal.timeout(45_000),
maxTokens: 2600,
messages: [{
role: "user",
content: [
{
type: "text",
text: `${definition.prompt}\n\n导入模式:${parsed.data.mode === "overwrite" ? "覆盖当前导演台" : "插入当前导演台"}\n只输出 JSON,不要 Markdown。`,
},
{ type: "image", image: referenceImages[0]! },
],
}],
model,
temperature: 0.15,
} as any);
const json = extractJson(text);
if (!json) continue;
const result = normalizeResult(json);
await pointsCharge.consume({ modelId: candidate.model.modelId });
return NextResponse.json({
success: true,
data: {
...result,
model: candidate.model.displayName,
},
});
} catch (error) {
logger.warn("director AI import model failed", {
error,
modelId: candidate.model.id,
});
}
}
return NextResponse.json(
{ success: false, error: "识图模型暂时不可用,请稍后重试" },
{ status: 502 },
);
} catch (error) {
logger.error("director AI import failed", { error });
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "识图导入失败" },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,113 @@
import { unlink } from "node:fs/promises";
import path from "node:path";
import { del } from "@vercel/blob";
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject, mediaAsset } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger";
import {
deleteS3ObjectByPath,
resolveS3UploadTarget,
} from "@/lib/storage/service";
export const runtime = "nodejs";
const logger = createLogger("video-canvas-director-captures");
const deleteSchema = z.object({
assetId: z.string().uuid().optional(),
canvasId: z.string().uuid(),
imageUrl: z.string().trim().max(4096).optional(),
pathname: z
.string()
.trim()
.max(1024)
.transform((value) => value.replace(/^\/+/, ""))
.refine((value) => !value.includes("..") && !value.includes("\\"), "文件路径无效")
.optional(),
});
async function deleteStoredCapture(pathname: string, imageUrl: string) {
if (pathname.startsWith("uploads/local/")) {
const publicRoot = path.resolve(process.cwd(), "public");
const absolutePath = path.resolve(publicRoot, pathname);
if (!absolutePath.startsWith(`${publicRoot}${path.sep}`)) {
throw new Error("文件路径越界");
}
await unlink(absolutePath).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
return;
}
if (
imageUrl.includes(".public.blob.vercel-storage.com/") &&
process.env.BLOB_READ_WRITE_TOKEN
) {
await del(imageUrl, { token: process.env.BLOB_READ_WRITE_TOKEN });
return;
}
const target = await resolveS3UploadTarget();
if (!target) throw new Error("没有可用的对象存储配置");
await deleteS3ObjectByPath(pathname, target.config);
}
export async function DELETE(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const parsed = deleteSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
{ status: 400 },
);
}
const [project] = await db
.select({ id: canvasProject.id })
.from(canvasProject)
.where(
and(
eq(canvasProject.id, parsed.data.canvasId),
eq(canvasProject.userId, session.user.id),
),
)
.limit(1);
if (!project) {
return NextResponse.json({ success: false, error: "画布不存在" }, { status: 404 });
}
if (parsed.data.pathname) {
await deleteStoredCapture(
parsed.data.pathname,
parsed.data.imageUrl || "",
).catch((error) => {
logger.warn("capture file cleanup failed", { error });
});
}
if (parsed.data.assetId) {
await db
.delete(mediaAsset)
.where(
and(
eq(mediaAsset.id, parsed.data.assetId),
eq(mediaAsset.userId, session.user.id),
),
);
}
return NextResponse.json({ success: true });
} catch (error) {
logger.error("capture cleanup failed", { error });
return NextResponse.json(
{ success: false, error: "截图清理失败" },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getSystemSetting } from "@/lib/db/queries";
import {
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
import { rankVideoCanvasEffectPresets } from "@/lib/video-canvas/video-effects";
export const dynamic = "force-dynamic";
const requestSchema = z.object({
query: z.string().trim().max(100).default(""),
});
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const parsed = requestSchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: parsed.error.issues[0]?.message || "搜索参数无效" },
{ status: 400 },
);
}
const setting = await getSystemSetting({
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
}).catch(() => null);
const config = setting?.value
? normalizeVideoCanvasFunctionConfig(setting.value)
: defaultVideoCanvasFunctionConfig;
if (!config.enabled) {
return NextResponse.json(
{ success: false, error: "视频画布功能已停用" },
{ status: 403 },
);
}
return NextResponse.json({
success: true,
data: {
presets: rankVideoCanvasEffectPresets(
config.effectPresets,
parsed.data.query,
),
query: parsed.data.query,
},
});
}

View File

@ -0,0 +1,153 @@
import { and, desc, eq, inArray, ne, sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { getSystemSetting } from "@/lib/db/queries";
import { mediaAsset } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger";
import {
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
import {
VIDEO_CANVAS_HISTORY_HIDDEN_AT_KEY,
VIDEO_CANVAS_HISTORY_MEDIA_TYPES,
normalizeVideoCanvasHistoryAssetIds,
} from "@/lib/video-canvas/history-assets";
import {
VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE,
} from "@/lib/video-canvas/subjects";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const logger = createLogger("video-canvas-history-assets");
async function getHistoryConfig() {
const setting = await getSystemSetting({
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
}).catch(() => null);
return setting?.value
? normalizeVideoCanvasFunctionConfig(setting.value).historyAssets
: defaultVideoCanvasFunctionConfig.historyAssets;
}
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 },
);
}
const [config, assets] = await Promise.all([
getHistoryConfig(),
db
.select({
createdAt: mediaAsset.createdAt,
displayName: mediaAsset.displayName,
durationMs: mediaAsset.durationMs,
height: mediaAsset.height,
id: mediaAsset.id,
mediaType: mediaAsset.mediaType,
metadata: mediaAsset.metadata,
mimeType: mediaAsset.mimeType,
modelId: mediaAsset.modelId,
originalUrl: mediaAsset.originalUrl,
previewUrl: mediaAsset.previewUrl,
prompt: mediaAsset.prompt,
sourceNodeId: mediaAsset.sourceNodeId,
sourceType: mediaAsset.sourceType,
thumbnailUrl: mediaAsset.thumbnailUrl,
width: mediaAsset.width,
})
.from(mediaAsset)
.where(
and(
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.status, "ready"),
inArray(mediaAsset.mediaType, [
...VIDEO_CANVAS_HISTORY_MEDIA_TYPES,
]),
ne(
mediaAsset.sourceType,
VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
),
ne(
mediaAsset.sourceType,
VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE,
),
sql`coalesce(${mediaAsset.metadata}->>${VIDEO_CANVAS_HISTORY_HIDDEN_AT_KEY}, '') = ''`,
),
)
.orderBy(desc(mediaAsset.createdAt))
.limit(500),
]);
return NextResponse.json({
success: true,
assets: config.enabled ? assets.slice(0, config.maxItems) : [],
});
} catch (error) {
logger.error("history asset list failed", { error });
return NextResponse.json(
{ success: false, error: "历史资产加载失败" },
{ status: 500 },
);
}
}
export async function DELETE(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 },
);
}
const body = (await request.json().catch(() => null)) as {
assetIds?: unknown;
} | null;
const assetIds = normalizeVideoCanvasHistoryAssetIds(body?.assetIds);
if (assetIds.length === 0) {
return NextResponse.json(
{ success: false, error: "请选择历史资产" },
{ status: 400 },
);
}
const hiddenAt = new Date().toISOString();
const hidden = await db
.update(mediaAsset)
.set({
metadata: sql`coalesce(${mediaAsset.metadata}, '{}'::jsonb) || ${JSON.stringify({
[VIDEO_CANVAS_HISTORY_HIDDEN_AT_KEY]: hiddenAt,
})}::jsonb`,
updatedAt: new Date(),
})
.where(
and(
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.status, "ready"),
inArray(mediaAsset.id, assetIds),
inArray(mediaAsset.mediaType, [...VIDEO_CANVAS_HISTORY_MEDIA_TYPES]),
sql`coalesce(${mediaAsset.metadata}->>${VIDEO_CANVAS_HISTORY_HIDDEN_AT_KEY}, '') = ''`,
),
)
.returning({ id: mediaAsset.id });
return NextResponse.json({ success: true, removed: hidden.length });
} catch (error) {
logger.error("history asset delete failed", { error });
return NextResponse.json(
{ success: false, error: "历史记录删除失败" },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,226 @@
import { generateText } from "ai";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { getCompatibleTextGenerationTemperature } from "@/lib/ai/model-compat";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { getAiModelAgentCodingModelId } from "@/lib/db/ai-model-flags";
import {
getAgentAiModelWithProvider,
getEnabledAiModelsWithProvider,
getSystemSetting,
} from "@/lib/db/queries";
import { createLogger } from "@/lib/observability/logger";
import {
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
import {
buildVideoPromptStructureInstructions,
injectVideoPromptStructure,
resolveVideoPromptStructure,
} from "@/lib/video-canvas/prompt-structure";
export const runtime = "nodejs";
export const maxDuration = 60;
const logger = createLogger("video-canvas-prompt-tools");
const requestSchema = z.object({
marks: z
.array(
z.object({
id: z.string().trim().min(1).max(100),
label: z.string().trim().min(1).max(100),
})
)
.max(12)
.default([]),
mode: z.enum(["optimize", "translate"]),
prompt: z.string().trim().min(1, "请输入提示词").max(50_000, "提示词过长"),
references: z
.array(
z.object({
id: z.string().trim().min(1).max(100),
index: z.number().int().min(1).max(10),
label: z.string().trim().min(1).max(100),
mediaType: z.enum(["audio", "image", "video"]),
offset: z.number().int().min(0).max(50_000),
})
)
.max(20)
.default([]),
});
type ModelCandidate = Awaited<
ReturnType<typeof getEnabledAiModelsWithProvider>
>[number];
function isTextModelCandidate(candidate: ModelCandidate) {
const descriptor = {
capabilities: candidate.model.capabilities,
modelId: candidate.model.modelId,
providerType: candidate.provider.type,
supportsImageGen: candidate.model.supportsImageGen,
};
return (
candidate.model.isEnabled &&
candidate.provider.isEnabled &&
!candidate.model.isSubModel &&
candidate.model.healthStatus !== "auto_disabled" &&
!isImageGenerationModelDescriptor(descriptor) &&
!isVideoModelDescriptor(descriptor)
);
}
function normalizeModelRef(value: unknown) {
const normalized = String(value || "").trim();
return normalized && normalized !== "auto" ? normalized : "";
}
function normalizeGeneratedText(value: unknown) {
return String(value || "")
.trim()
.replace(/^```(?:text|markdown|md)?\s*/i, "")
.replace(/\s*```$/i, "")
.replace(/^(?:优化后的提示词|译文|translation)\s*[::]\s*/i, "")
.trim();
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const parsed = requestSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
{ status: 400 },
);
}
const setting = await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(
() => null,
);
const config = setting?.value
? normalizeVideoCanvasFunctionConfig(setting.value)
: defaultVideoCanvasFunctionConfig;
const functionKey =
parsed.data.mode === "optimize"
? "video-prompt-optimize"
: "video-prompt-translate";
const definition = config.functions[functionKey];
if (!definition?.enabled) {
return NextResponse.json(
{ success: false, error: `${definition?.label || "当前功能"}已停用` },
{ status: 403 },
);
}
const [allModels, agentModel] = await Promise.all([
getEnabledAiModelsWithProvider(false),
getAgentAiModelWithProvider(),
]);
const candidates = allModels.filter(isTextModelCandidate);
const preferredModelRef =
normalizeModelRef(definition.model) || normalizeModelRef(config.defaultMultimodalModel);
const preferredModel = preferredModelRef
? candidates.find(
(candidate) =>
candidate.model.id === preferredModelRef ||
candidate.model.modelId === preferredModelRef,
)
: null;
const orderedCandidates = [
preferredModel,
agentModel && isTextModelCandidate(agentModel) ? agentModel : null,
...candidates,
].filter(
(candidate, index, items): candidate is ModelCandidate =>
Boolean(candidate) &&
items.findIndex((item) => item?.model.id === candidate?.model.id) === index,
);
if (orderedCandidates.length === 0) {
return NextResponse.json(
{ success: false, error: "当前站点没有可用的提示词处理模型" },
{ status: 500 },
);
}
for (const candidate of orderedCandidates.slice(0, 3)) {
try {
const codingModelId = getAiModelAgentCodingModelId(candidate.model);
const model = await getLanguageModelById(candidate.model.id, codingModelId);
if (!model) continue;
const structuredPrompt = injectVideoPromptStructure(
parsed.data.prompt,
parsed.data.references,
parsed.data.marks,
);
const structureInstructions = buildVideoPromptStructureInstructions(
parsed.data.references,
parsed.data.marks,
);
const { text } = await generateText({
abortSignal: AbortSignal.timeout(18_000),
maxTokens: parsed.data.mode === "optimize" ? 2200 : 1600,
model,
prompt:
parsed.data.mode === "optimize"
? `原始视频提示词:\n${structuredPrompt}\n\n请按照系统规则输出优化后的提示词正文。`
: `待翻译的视频提示词:\n${structuredPrompt}\n\n请按照系统规则只输出译文。`,
system: [definition.prompt, structureInstructions].filter(Boolean).join("\n\n"),
temperature: getCompatibleTextGenerationTemperature(
candidate,
parsed.data.mode === "optimize" ? 0.65 : 0.25,
),
} as any);
const generatedText = normalizeGeneratedText(text);
if (!generatedText) continue;
const result = resolveVideoPromptStructure(
generatedText,
parsed.data.references,
parsed.data.marks,
{ flatten: parsed.data.mode === "optimize" },
);
if (!result.text) continue;
return NextResponse.json({
success: true,
data: {
mentionOffsets: result.mentionOffsets,
model: candidate.model.displayName,
preservedMarkIds: result.preservedMarkIds,
text: result.text,
},
});
} catch (error) {
logger.warn("prompt model candidate failed", {
error,
modelId: candidate.model.id,
});
}
}
return NextResponse.json(
{ success: false, error: "提示词处理模型暂时不可用,请稍后重试" },
{ status: 502 },
);
} catch (error) {
logger.error("prompt tool failed", { error });
const message = error instanceof Error ? error.message : "提示词处理失败";
return NextResponse.json({ success: false, error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,226 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { generateCanvasImage } from "@/lib/canvas/generate-image";
import { db } from "@/lib/db";
import { getPointsSystemConfig, getSystemSetting } from "@/lib/db/queries";
import { mediaAsset } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import {
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
import { VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE } from "@/lib/video-canvas/subjects";
export const runtime = "nodejs";
export const maxDuration = 1200;
const logger = createLogger("video-canvas-subject-view-completion");
const requestSchema = z.object({
primaryAssetId: z.string().uuid(),
});
const SUBJECT_COMPLETION_VIEW_DIRECTIONS = [
"正面近景,突出面部与头部特征",
"正面全身,完整展示服装与体态",
"左前方四十五度全身视角",
"右前方四十五度全身视角",
"左侧面全身视角",
"右侧面全身视角",
"背面全身视角",
"背面回首的全身视角",
"轻微俯视的正面全身视角",
] as const;
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const parsed = requestSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
{ status: 400 },
);
}
const setting = await getSystemSetting({
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
}).catch(() => null);
const config = setting?.value
? normalizeVideoCanvasFunctionConfig(setting.value)
: defaultVideoCanvasFunctionConfig;
const completionConfig = config.subjectLibrary.smartCompletion;
if (!config.subjectLibrary.enabled || !completionConfig.enabled) {
return NextResponse.json(
{ success: false, error: "智能补全已停用" },
{ status: 403 },
);
}
const [primaryAsset] = await db
.select({
id: mediaAsset.id,
originalUrl: mediaAsset.originalUrl,
})
.from(mediaAsset)
.where(
and(
eq(mediaAsset.id, parsed.data.primaryAssetId),
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.mediaType, "image"),
eq(mediaAsset.status, "ready"),
),
)
.limit(1);
if (!primaryAsset) {
return NextResponse.json(
{ success: false, error: "主参考图不存在或已失效" },
{ status: 404 },
);
}
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
modelId: "video-canvas-subject-view-completion",
note: "主体其他视角智能补全消耗积分",
pointsConfig,
reason: "video_canvas_subject_view_completion",
unitCost: completionConfig.cost,
userId: session.user.id,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const referenceImage = new URL(primaryAsset.originalUrl, request.url).toString();
const selectedModel =
completionConfig.model === "auto"
? config.defaultImageModel
: completionConfig.model;
const generatedCandidates = (
await Promise.all(
Array.from({ length: completionConfig.candidateCount }, async (_, index) => {
const viewDirection =
SUBJECT_COMPLETION_VIEW_DIRECTIONS[
index % SUBJECT_COMPLETION_VIEW_DIRECTIONS.length
];
const candidatePrompt = `${completionConfig.prompt}\n本次只生成一张独立候选图。候选方向:${viewDirection}。不要生成拼图、九宫格、文字或边框。`;
for (let attempt = 1; attempt <= 2; attempt += 1) {
try {
const result = await generateCanvasImage({
aspectRatio: completionConfig.aspectRatio,
debugLabel: "video-canvas-subject-view-completion",
debugMeta: {
candidateIndex: index,
candidateViewDirection: viewDirection,
primaryAssetId: primaryAsset.id,
subjectViewCompletion: true,
},
imageCount: 1,
imageSize: completionConfig.imageSize,
model: selectedModel,
ownerKey: `${session.user.id}:subject-view-${index}`,
prompt: candidatePrompt,
promptExtend: false,
referenceImage,
});
const image = result.images[0];
if (!image) throw new Error("模型未返回图片");
return {
image,
model: result.model,
modelRef: result.modelRef,
prompt: result.prompt,
};
} catch (error) {
logger.warn("subject completion candidate failed", {
attempt,
candidateIndex: index,
error,
});
}
}
return null;
}),
)
).filter((candidate): candidate is NonNullable<typeof candidate> => Boolean(candidate));
if (generatedCandidates.length !== completionConfig.candidateCount) {
return NextResponse.json(
{
success: false,
error: `智能补全未能生成全部 ${completionConfig.candidateCount} 张候选图,请重试`,
},
{ status: 502 },
);
}
const generatedRows = await db
.insert(mediaAsset)
.values(
generatedCandidates.map((candidate, index) => ({
displayName: `主体补充视角 ${index + 1}`,
mediaType: "image",
metadata: {
candidateIndex: index,
generatedBy: "subject-smart-completion",
primaryAssetId: primaryAsset.id,
},
mimeType: "image/png",
modelId: candidate.modelRef || candidate.model,
normalizedUrl: candidate.image.originalUrl,
originalUrl: candidate.image.originalUrl,
previewUrl: candidate.image.thumbnailUrl || candidate.image.originalUrl,
prompt: candidate.prompt,
rootAssetId: primaryAsset.id,
sourceTable: "video_canvas_subject",
sourceType: VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
status: "ready",
thumbnailUrl: candidate.image.thumbnailUrl || candidate.image.originalUrl,
userId: session.user.id,
})),
)
.returning({
assetId: mediaAsset.id,
name: mediaAsset.displayName,
url: mediaAsset.originalUrl,
});
const primaryGeneratedCandidate = generatedCandidates[0];
await pointsCharge.consume({
modelId: primaryGeneratedCandidate.modelRef || primaryGeneratedCandidate.model,
}).catch((error) => {
logger.warn("subject completion points consumption failed", { error });
});
return NextResponse.json({
success: true,
data: {
candidates: generatedRows.map((item) => ({
assetId: item.assetId,
mediaType: "image" as const,
name: item.name || "补充视角",
url: item.url,
})),
cost: pointsCharge.unitCost,
maxSelected: completionConfig.maxSelected,
model: primaryGeneratedCandidate.model,
},
});
} catch (error) {
logger.error("subject view completion failed", { error });
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "智能补全失败",
},
{ status: 500 },
);
}
}

View File

@ -0,0 +1,274 @@
import { generateText } from "ai";
import { and, eq, inArray } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { getCompatibleTextGenerationTemperature } from "@/lib/ai/model-compat";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { inlineVisionImages } from "@/lib/ai/vision-inline";
import { db } from "@/lib/db";
import {
getAiModelAgentCodingModelId,
isAiModelMultimodal,
} from "@/lib/db/ai-model-flags";
import {
getAgentAiModelWithProvider,
getEnabledAiModelsWithProvider,
getPointsSystemConfig,
getSystemSetting,
} from "@/lib/db/queries";
import { mediaAsset } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger";
import {
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
import {
normalizeGeneratedSubjectDescription,
resolveVideoCanvasSubjectCategory,
} from "@/lib/video-canvas/subjects";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
export const runtime = "nodejs";
export const maxDuration = 60;
const logger = createLogger("video-canvas-subject-description");
const requestSchema = z.object({
assetIds: z.array(z.string().uuid()).min(1).max(4),
category: z.string().trim().max(20).default("无标签"),
frameDataUrl: z
.string()
.max(12_000_000)
.refine(
(value) => !value || /^data:image\/(?:jpeg|png|webp);base64,/i.test(value),
"视频预览帧无效",
)
.optional(),
});
type ModelCandidate = Awaited<
ReturnType<typeof getEnabledAiModelsWithProvider>
>[number];
function isDescriptionModelCandidate(candidate: ModelCandidate) {
const descriptor = {
capabilities: candidate.model.capabilities,
modelId: candidate.model.modelId,
providerType: candidate.provider.type,
supportsImageGen: candidate.model.supportsImageGen,
};
return (
candidate.model.isEnabled &&
candidate.provider.isEnabled &&
!candidate.model.isSubModel &&
candidate.model.healthStatus !== "auto_disabled" &&
!isImageGenerationModelDescriptor(descriptor) &&
!isVideoModelDescriptor(descriptor) &&
(Boolean(candidate.model.supportsVision) ||
isAiModelMultimodal({
capabilities: candidate.model.capabilities,
defaultParams: candidate.model.defaultParams,
}))
);
}
function normalizeModelRef(value: unknown) {
const normalized = String(value || "").trim();
return normalized && normalized !== "auto" ? normalized : "";
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const parsed = requestSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
{ status: 400 },
);
}
const setting = await getSystemSetting({
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
}).catch(() => null);
const config = setting?.value
? normalizeVideoCanvasFunctionConfig(setting.value)
: defaultVideoCanvasFunctionConfig;
const subjectConfig = config.subjectLibrary;
if (!subjectConfig.enabled || !subjectConfig.smartDescription.enabled) {
return NextResponse.json(
{ success: false, error: "智能生成已停用" },
{ status: 403 },
);
}
const assets = await db
.select({
id: mediaAsset.id,
mediaType: mediaAsset.mediaType,
originalUrl: mediaAsset.originalUrl,
})
.from(mediaAsset)
.where(
and(
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.status, "ready"),
inArray(mediaAsset.id, parsed.data.assetIds),
),
);
const assetById = new Map(assets.map((asset) => [asset.id, asset]));
const orderedAssets = parsed.data.assetIds.flatMap((id) => {
const asset = assetById.get(id);
return asset ? [asset] : [];
});
if (orderedAssets.length !== parsed.data.assetIds.length) {
return NextResponse.json(
{ success: false, error: "主体素材不存在或已失效" },
{ status: 400 },
);
}
const imageUrls = orderedAssets
.filter((asset) => asset.mediaType === "image")
.map((asset) => new URL(asset.originalUrl, request.url).toString());
const inlinedImages = imageUrls.length
? (
await inlineVisionImages(imageUrls, {
loggerPrefix: "[Video Canvas Subject]",
maxImages: subjectConfig.imageMaxCount,
maxSide: 1024,
retries: 1,
timeoutMs: 12_000,
})
).inlineDataUrls
: [];
const references = [
...inlinedImages,
...(parsed.data.frameDataUrl ? [parsed.data.frameDataUrl] : []),
].slice(0, subjectConfig.imageMaxCount);
if (references.length === 0) {
return NextResponse.json(
{ success: false, error: "未能读取主体画面" },
{ status: 400 },
);
}
const [allModels, agentModel] = await Promise.all([
getEnabledAiModelsWithProvider(false),
getAgentAiModelWithProvider(),
]);
const candidates = allModels.filter(isDescriptionModelCandidate);
const preferredRef = normalizeModelRef(config.defaultMultimodalModel);
const preferredModel = preferredRef
? candidates.find(
(candidate) =>
candidate.model.id === preferredRef ||
candidate.model.modelId === preferredRef,
)
: null;
const orderedCandidates = [
preferredModel,
agentModel && isDescriptionModelCandidate(agentModel) ? agentModel : null,
...candidates,
].filter(
(candidate, index, items): candidate is ModelCandidate =>
Boolean(candidate) &&
items.findIndex((item) => item?.model.id === candidate?.model.id) === index,
);
if (orderedCandidates.length === 0) {
return NextResponse.json(
{ success: false, error: "当前站点没有可用的多模态模型" },
{ status: 500 },
);
}
const category = resolveVideoCanvasSubjectCategory(
parsed.data.category,
subjectConfig,
);
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
modelId: "video-canvas-subject-description",
note: "主体描述智能生成消耗积分",
pointsConfig,
reason: "video_canvas_subject_description",
unitCost: subjectConfig.smartDescription.cost,
userId: session.user.id,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
for (const candidate of orderedCandidates.slice(0, 3)) {
try {
const model = await getLanguageModelById(
candidate.model.id,
getAiModelAgentCodingModelId(candidate.model),
);
if (!model) continue;
const { text } = await generateText({
abortSignal: AbortSignal.timeout(35_000),
maxTokens: subjectConfig.smartDescription.maxTokens,
messages: [
{
role: "user",
content: [
{
type: "text",
text: `主体标签:${category}\n参考画面数量:${references.length}\n请根据系统规则生成主体描述。`,
},
...references.map((image) => ({ type: "image" as const, image })),
],
},
],
model,
system: subjectConfig.smartDescription.prompt,
temperature: getCompatibleTextGenerationTemperature(
candidate,
subjectConfig.smartDescription.temperature,
),
} as any);
const description = normalizeGeneratedSubjectDescription(
text,
subjectConfig.descriptionMaxLength,
);
if (!description) continue;
await pointsCharge.consume({ modelId: candidate.model.id }).catch((error) => {
logger.warn("subject description points consumption failed", { error });
});
return NextResponse.json({
success: true,
data: {
cost: pointsCharge.unitCost,
description,
model: candidate.model.displayName,
},
});
} catch (error) {
logger.warn("subject description model failed", {
error,
modelId: candidate.model.id,
});
}
}
return NextResponse.json(
{ success: false, error: "智能描述模型暂时不可用,请稍后重试" },
{ status: 502 },
);
} catch (error) {
logger.error("subject description generation failed", { error });
return NextResponse.json(
{ success: false, error: "主体描述生成失败" },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,569 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { getSystemSetting } from "@/lib/db/queries";
import { mediaAsset } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger";
import {
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
import {
VIDEO_CANVAS_SUBJECT_SOURCE_TYPE,
VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE,
VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE,
normalizeVideoCanvasSubjectText,
resolveVideoCanvasSubjectCategory,
validateVideoCanvasSubjectDraft,
} from "@/lib/video-canvas/subjects";
export const runtime = "nodejs";
const logger = createLogger("video-canvas-subjects");
const createSubjectSchema = z.object({
assetIds: z.array(z.string().uuid()).min(1).max(10),
category: z.string().trim().max(20),
description: z.string().trim().min(1).max(1000),
durationSeconds: z.number().finite().positive().max(3600).nullish(),
mediaMode: z.enum(["images", "video"]),
name: z.string().trim().min(1).max(100),
voiceId: z.string().trim().min(1).max(160).default("none"),
});
const updateSubjectSchema = createSubjectSchema.extend({
subjectId: z.string().uuid(),
});
async function getSubjectLibraryConfig() {
const setting = await getSystemSetting({
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
}).catch(() => null);
return setting?.value
? normalizeVideoCanvasFunctionConfig(setting.value).subjectLibrary
: defaultVideoCanvasFunctionConfig.subjectLibrary;
}
function readMetadataAssetIds(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
const assetIds = (value as Record<string, unknown>).assetIds;
return Array.isArray(assetIds)
? assetIds.map((item) => String(item || "")).filter(Boolean)
: [];
}
export async function GET(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const config = await getSubjectLibraryConfig();
const requestedCategory = new URL(request.url).searchParams.get("category");
const category = requestedCategory
? resolveVideoCanvasSubjectCategory(requestedCategory, config)
: "";
const subjects = await db
.select({
createdAt: mediaAsset.createdAt,
description: mediaAsset.summary,
displayName: mediaAsset.displayName,
durationMs: mediaAsset.durationMs,
id: mediaAsset.id,
metadata: mediaAsset.metadata,
originalUrl: mediaAsset.originalUrl,
previewUrl: mediaAsset.previewUrl,
thumbnailUrl: mediaAsset.thumbnailUrl,
})
.from(mediaAsset)
.where(
and(
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.mediaType, "subject"),
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_SOURCE_TYPE),
eq(mediaAsset.status, "ready"),
),
)
.orderBy(desc(mediaAsset.updatedAt))
.limit(100);
const filteredSubjects = category
? subjects.filter((subject) => {
const metadata = subject.metadata;
return (
metadata &&
typeof metadata === "object" &&
!Array.isArray(metadata) &&
(metadata as Record<string, unknown>).category === category
);
})
: subjects;
return NextResponse.json({ success: true, subjects: filteredSubjects });
} catch (error) {
logger.error("subject list failed", { error });
return NextResponse.json(
{ success: false, error: "主体加载失败" },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const parsed = createSubjectSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
{ status: 400 },
);
}
const config = await getSubjectLibraryConfig();
const category = resolveVideoCanvasSubjectCategory(parsed.data.category, config);
const validationError = validateVideoCanvasSubjectDraft(
{
assetCount: parsed.data.assetIds.length,
category,
description: parsed.data.description,
durationSeconds: parsed.data.durationSeconds,
mediaMode: parsed.data.mediaMode,
name: parsed.data.name,
voiceId: parsed.data.voiceId,
},
config,
);
if (validationError) {
return NextResponse.json(
{ success: false, error: validationError },
{ status: config.enabled ? 400 : 403 },
);
}
const uploadedAssets = await db
.select({
id: mediaAsset.id,
mediaType: mediaAsset.mediaType,
mimeType: mediaAsset.mimeType,
originalUrl: mediaAsset.originalUrl,
previewUrl: mediaAsset.previewUrl,
thumbnailUrl: mediaAsset.thumbnailUrl,
})
.from(mediaAsset)
.where(
and(
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.status, "ready"),
inArray(mediaAsset.id, parsed.data.assetIds),
),
);
const assetById = new Map(uploadedAssets.map((asset) => [asset.id, asset]));
const orderedAssets = parsed.data.assetIds.flatMap((id) => {
const asset = assetById.get(id);
return asset ? [asset] : [];
});
if (orderedAssets.length !== parsed.data.assetIds.length) {
return NextResponse.json(
{ success: false, error: "主体素材不存在或已失效" },
{ status: 400 },
);
}
const expectedMediaType = parsed.data.mediaMode === "images" ? "image" : "video";
if (orderedAssets.some((asset) => asset.mediaType !== expectedMediaType)) {
return NextResponse.json(
{ success: false, error: "主体素材类型不一致" },
{ status: 400 },
);
}
let customVoiceAssetId: string | null = null;
if (parsed.data.voiceId.startsWith("custom:")) {
customVoiceAssetId = parsed.data.voiceId.slice("custom:".length);
if (!z.string().uuid().safeParse(customVoiceAssetId).success) {
return NextResponse.json(
{ success: false, error: "自定义音色无效" },
{ status: 400 },
);
}
const [voiceAsset] = await db
.select({ id: mediaAsset.id })
.from(mediaAsset)
.where(
and(
eq(mediaAsset.id, customVoiceAssetId),
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE),
eq(mediaAsset.status, "ready"),
),
)
.limit(1);
if (!voiceAsset) {
return NextResponse.json(
{ success: false, error: "自定义音色不存在或已失效" },
{ status: 400 },
);
}
}
const primaryAsset = orderedAssets[0];
const description = normalizeVideoCanvasSubjectText(
parsed.data.description,
config.descriptionMaxLength,
);
const name = normalizeVideoCanvasSubjectText(
parsed.data.name,
config.nameMaxLength,
);
const voice = config.voices.find((item) => item.id === parsed.data.voiceId);
const durationMs =
parsed.data.mediaMode === "video" && parsed.data.durationSeconds
? Math.round(parsed.data.durationSeconds * 1000)
: null;
const subjectId = randomUUID();
const [subject] = await db
.insert(mediaAsset)
.values({
displayName: name,
durationMs,
id: subjectId,
mediaType: "subject",
metadata: {
assetIds: parsed.data.assetIds,
category,
customVoiceAssetId,
durationSeconds: parsed.data.durationSeconds || null,
mediaMode: parsed.data.mediaMode,
mediaUrls: orderedAssets.map((asset) => asset.originalUrl),
voiceBadge: voice?.badge || (customVoiceAssetId ? "自定义" : ""),
voiceId: parsed.data.voiceId,
voiceName: voice?.name || (customVoiceAssetId ? "自定义音色" : "无配音"),
},
mimeType: "application/x-video-canvas-subject",
normalizedUrl: `video-canvas-subject://${subjectId}`,
originalUrl: primaryAsset.originalUrl,
previewUrl: primaryAsset.previewUrl || primaryAsset.originalUrl,
sourceTable: "video_canvas_subject",
sourceType: VIDEO_CANVAS_SUBJECT_SOURCE_TYPE,
status: "ready",
summary: { description },
thumbnailUrl:
primaryAsset.thumbnailUrl || primaryAsset.previewUrl || primaryAsset.originalUrl,
userId: session.user.id,
})
.returning();
return NextResponse.json({ success: true, subject });
} catch (error) {
logger.error("subject creation failed", { error });
return NextResponse.json(
{ success: false, error: "主体创建失败" },
{ status: 500 },
);
}
}
export async function PATCH(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const parsed = updateSubjectSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
{ status: 400 },
);
}
const [existingSubject] = await db
.select({ id: mediaAsset.id, metadata: mediaAsset.metadata })
.from(mediaAsset)
.where(
and(
eq(mediaAsset.id, parsed.data.subjectId),
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_SOURCE_TYPE),
),
)
.limit(1);
if (!existingSubject) {
return NextResponse.json(
{ success: false, error: "主体不存在" },
{ status: 404 },
);
}
const config = await getSubjectLibraryConfig();
const category = resolveVideoCanvasSubjectCategory(parsed.data.category, config);
const validationError = validateVideoCanvasSubjectDraft(
{
assetCount: parsed.data.assetIds.length,
category,
description: parsed.data.description,
durationSeconds: parsed.data.durationSeconds,
mediaMode: parsed.data.mediaMode,
name: parsed.data.name,
voiceId: parsed.data.voiceId,
},
config,
);
if (validationError) {
return NextResponse.json(
{ success: false, error: validationError },
{ status: config.enabled ? 400 : 403 },
);
}
const ownedAssets = await db
.select({
id: mediaAsset.id,
mediaType: mediaAsset.mediaType,
originalUrl: mediaAsset.originalUrl,
previewUrl: mediaAsset.previewUrl,
thumbnailUrl: mediaAsset.thumbnailUrl,
})
.from(mediaAsset)
.where(
and(
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.status, "ready"),
inArray(mediaAsset.id, parsed.data.assetIds),
),
);
const assetById = new Map(ownedAssets.map((asset) => [asset.id, asset]));
const orderedAssets = parsed.data.assetIds.flatMap((id) => {
const asset = assetById.get(id);
return asset ? [asset] : [];
});
const expectedMediaType = parsed.data.mediaMode === "images" ? "image" : "video";
if (
orderedAssets.length !== parsed.data.assetIds.length ||
orderedAssets.some((asset) => asset.mediaType !== expectedMediaType)
) {
return NextResponse.json(
{ success: false, error: "主体素材不存在、已失效或类型不一致" },
{ status: 400 },
);
}
let customVoiceAssetId: string | null = null;
if (parsed.data.voiceId.startsWith("custom:")) {
customVoiceAssetId = parsed.data.voiceId.slice("custom:".length);
if (!z.string().uuid().safeParse(customVoiceAssetId).success) {
return NextResponse.json(
{ success: false, error: "自定义音色无效" },
{ status: 400 },
);
}
const [voiceAsset] = await db
.select({ id: mediaAsset.id })
.from(mediaAsset)
.where(
and(
eq(mediaAsset.id, customVoiceAssetId),
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE),
eq(mediaAsset.status, "ready"),
),
)
.limit(1);
if (!voiceAsset) {
return NextResponse.json(
{ success: false, error: "自定义音色不存在或已失效" },
{ status: 400 },
);
}
}
const primaryAsset = orderedAssets[0];
const voice = config.voices.find((item) => item.id === parsed.data.voiceId);
const previousMetadata =
existingSubject.metadata &&
typeof existingSubject.metadata === "object" &&
!Array.isArray(existingSubject.metadata)
? (existingSubject.metadata as Record<string, unknown>)
: {};
const previousAssetIds = readMetadataAssetIds(existingSubject.metadata);
const previousCustomVoiceId = String(previousMetadata.customVoiceAssetId || "").trim();
const [subject] = await db
.update(mediaAsset)
.set({
displayName: normalizeVideoCanvasSubjectText(
parsed.data.name,
config.nameMaxLength,
),
durationMs:
parsed.data.mediaMode === "video" && parsed.data.durationSeconds
? Math.round(parsed.data.durationSeconds * 1000)
: null,
metadata: {
assetIds: parsed.data.assetIds,
category,
customVoiceAssetId,
durationSeconds: parsed.data.durationSeconds || null,
mediaMode: parsed.data.mediaMode,
mediaUrls: orderedAssets.map((asset) => asset.originalUrl),
voiceBadge: voice?.badge || (customVoiceAssetId ? "自定义" : ""),
voiceId: parsed.data.voiceId,
voiceName: voice?.name || (customVoiceAssetId ? "自定义音色" : "无配音"),
},
originalUrl: primaryAsset.originalUrl,
previewUrl: primaryAsset.previewUrl || primaryAsset.originalUrl,
summary: {
description: normalizeVideoCanvasSubjectText(
parsed.data.description,
config.descriptionMaxLength,
),
},
thumbnailUrl:
primaryAsset.thumbnailUrl || primaryAsset.previewUrl || primaryAsset.originalUrl,
updatedAt: new Date(),
})
.where(eq(mediaAsset.id, existingSubject.id))
.returning();
const removedAssetIds = previousAssetIds.filter(
(assetId) => !parsed.data.assetIds.includes(assetId),
);
if (removedAssetIds.length) {
await db.delete(mediaAsset).where(
and(
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE),
inArray(mediaAsset.id, removedAssetIds),
),
);
}
if (
previousCustomVoiceId &&
previousCustomVoiceId !== customVoiceAssetId &&
z.string().uuid().safeParse(previousCustomVoiceId).success
) {
await db.delete(mediaAsset).where(
and(
eq(mediaAsset.id, previousCustomVoiceId),
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE),
),
);
}
return NextResponse.json({ success: true, subject });
} catch (error) {
logger.error("subject update failed", { error });
return NextResponse.json(
{ success: false, error: "主体保存失败" },
{ status: 500 },
);
}
}
export async function DELETE(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = (await request.json().catch(() => null)) as {
subjectId?: string;
uploadAssetIds?: unknown;
voiceAssetIds?: unknown;
} | null;
const uploadAssetIds = Array.isArray(body?.uploadAssetIds)
? body.uploadAssetIds.map((item) => String(item || "")).filter(Boolean).slice(0, 10)
: [];
const voiceAssetIds = Array.isArray(body?.voiceAssetIds)
? body.voiceAssetIds.map((item) => String(item || "")).filter(Boolean).slice(0, 10)
: [];
const subjectId = String(body?.subjectId || "").trim();
if (subjectId) {
const [subject] = await db
.select({ id: mediaAsset.id, metadata: mediaAsset.metadata })
.from(mediaAsset)
.where(
and(
eq(mediaAsset.id, subjectId),
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_SOURCE_TYPE),
),
)
.limit(1);
if (!subject) {
return NextResponse.json(
{ success: false, error: "主体不存在" },
{ status: 404 },
);
}
const referencedAssetIds = readMetadataAssetIds(subject.metadata);
const subjectMetadata =
subject.metadata &&
typeof subject.metadata === "object" &&
!Array.isArray(subject.metadata)
? (subject.metadata as Record<string, unknown>)
: {};
const customVoiceAssetId = String(
subjectMetadata.customVoiceAssetId || "",
).trim();
await db.delete(mediaAsset).where(eq(mediaAsset.id, subject.id));
if (referencedAssetIds.length > 0) {
await db
.delete(mediaAsset)
.where(
and(
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE),
inArray(mediaAsset.id, referencedAssetIds),
),
);
}
if (z.string().uuid().safeParse(customVoiceAssetId).success) {
await db
.delete(mediaAsset)
.where(
and(
eq(mediaAsset.id, customVoiceAssetId),
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.sourceType, VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE),
),
);
}
return NextResponse.json({ success: true, removed: 1 });
}
const cleanupGroups = [
{ ids: uploadAssetIds, sourceType: VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE },
{ ids: voiceAssetIds, sourceType: VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE },
];
let removed = 0;
for (const group of cleanupGroups) {
if (group.ids.length === 0) continue;
const deleted = await db
.delete(mediaAsset)
.where(
and(
eq(mediaAsset.userId, session.user.id),
eq(mediaAsset.sourceType, group.sourceType),
inArray(mediaAsset.id, group.ids),
),
)
.returning({ id: mediaAsset.id });
removed += deleted.length;
}
return NextResponse.json({ success: true, removed });
} catch (error) {
logger.error("subject deletion failed", { error });
return NextResponse.json(
{ success: false, error: "主体删除失败" },
{ status: 500 },
);
}
}

View File

@ -42,7 +42,10 @@ export function CanvasPageClientRouter({
const id = Array.isArray(routeId) ? routeId[0] || initialId : typeof routeId === "string" ? routeId : initialId;
const mode = searchParams.get("mode") || initialMode;
const requestedWorkspaceType =
searchParams.get("workspaceType") || initialWorkspaceType;
searchParams.get("workspaceType") ||
initialWorkspaceType ||
initialProject?.workspaceType ||
"";
const requestedAgentMode = mode === "agent";
const isVideoMode = !requestedAgentMode && (mode === "video" || requestedWorkspaceType === "video");
const legacyParams = useMemo(() => Promise.resolve({ id }), [id]);

View File

@ -4037,7 +4037,15 @@ export function AgentGenerationWorkspaceDarkV2({
? "agent-anchor-materials"
: "agent-anchor-generate";
const { messages, setMessages, sendMessage, status, stop } = useChat<ChatMessage>({
const {
messages,
setMessages,
sendMessage,
status,
stop,
error: chatError,
clearError,
} = useChat<ChatMessage>({
id: chatId,
messages: historyMessages,
experimental_throttle: 100,
@ -5236,6 +5244,56 @@ export function AgentGenerationWorkspaceDarkV2({
}
}, [pendingGeneration, pendingGenerationProgressCount]);
useEffect(() => {
if (!pendingGeneration || !chatError) {
return;
}
generationRequestSnapshotRef.current = {
hiddenPrompt: null,
replacementExecutionCardIds: null,
replacementRerunReferenceImages: null,
};
setPendingGeneration(null);
toast.error(chatError.message || "生成请求失败,请稍后重试");
clearError();
}, [chatError, clearError, pendingGeneration]);
useEffect(() => {
if (!pendingGeneration) {
return;
}
const elapsedMs = Date.now() - pendingGeneration.timestamp;
const remainingMs = AGENT_PENDING_GENERATION_EXPIRY_MS - elapsedMs;
const clearStalePendingGeneration = () => {
generationRequestSnapshotRef.current = {
hiddenPrompt: null,
replacementExecutionCardIds: null,
replacementRerunReferenceImages: null,
};
setPendingGeneration((current) => {
if (
!current ||
current.chatId !== pendingGeneration.chatId ||
current.timestamp !== pendingGeneration.timestamp
) {
return current;
}
return null;
});
toast.warning("生成请求超过 30 分钟未回传,已结束处理中状态,可重新生成未回传结果。");
};
if (remainingMs <= 0) {
clearStalePendingGeneration();
return;
}
const timerId = window.setTimeout(clearStalePendingGeneration, remainingMs);
return () => window.clearTimeout(timerId);
}, [pendingGeneration]);
useEffect(() => {
if (!pendingGeneration || transportIsGenerating) {
return;
@ -6059,7 +6117,15 @@ export function AgentGenerationWorkspaceDarkV2({
toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`);
}
try {
sendMessage({ role: "user", parts });
void Promise.resolve(sendMessage({ role: "user", parts })).catch((error) => {
generationRequestSnapshotRef.current = {
hiddenPrompt: null,
replacementExecutionCardIds: null,
replacementRerunReferenceImages: null,
};
setPendingGeneration(null);
toast.error(error instanceof Error ? error.message : "发送生成请求失败");
});
if (selectedAgentPreset?.type !== "image") {
setInput("");
}

View File

@ -1,6 +1,7 @@
"use client";
import { useEffect, type RefObject } from "react";
import { isAgentModeScopedChatId } from "@/lib/chat/messages-route-access";
export type AgentStyleSection =
| "brand"
@ -140,6 +141,9 @@ export function useAgentStylePerformanceMonitor({
chatId,
projectId,
}: UseAgentStylePerformanceMonitorParams) {
const persistedChatId =
chatId && !isAgentModeScopedChatId(chatId) ? chatId : null;
useEffect(() => {
if (
!getBooleanEnvFlag(process.env.NEXT_PUBLIC_AGENT_STYLE_PERF_MONITOR, true) ||
@ -200,7 +204,7 @@ export function useAgentStylePerformanceMonitor({
browser,
path: window.location.pathname,
projectId: projectId || null,
chatId: chatId || null,
chatId: persistedChatId,
timestamp: new Date().toISOString(),
});
};
@ -237,5 +241,5 @@ export function useAgentStylePerformanceMonitor({
root.removeEventListener("keydown", markAction, true);
root.removeEventListener("scroll", markAction, true);
};
}, [chatId, projectId, rootRef]);
}, [persistedChatId, projectId, rootRef]);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,947 @@
"use client";
import {
Check,
ChevronDown,
Download,
Eye,
EyeOff,
Gauge,
Link2,
Loader2,
PanelsTopLeft,
Maximize2,
Minus,
Play,
Plus,
Redo2,
Scissors,
TextCursorInput,
Trash2,
Undo2,
Video,
X,
ZoomIn,
ZoomOut,
} from "lucide-react";
import {
type DragEvent,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
import {
createVideoCanvasCompositionClip,
getVideoCanvasCompositionClipDuration,
getVideoCanvasCompositionClipStart,
getVideoCanvasCompositionDuration,
splitVideoCanvasCompositionClip,
type VideoCanvasStoryboardCompositionProject,
type VideoCanvasStoryboardCompositionTransition,
} from "@/lib/video-canvas/storyboard-composition";
import type {
VideoCanvasStoryboardCompositionItem,
VideoCanvasStoryboardItem,
} from "@/lib/video-canvas/storyboard-overview";
type CompositionEditorProps = {
composition: VideoCanvasStoryboardCompositionItem;
compositionItems: VideoCanvasStoryboardCompositionItem[];
exporting: boolean;
onChange: (project: VideoCanvasStoryboardCompositionProject) => void;
onClose: () => void;
onExport: (destination: "canvas" | "local") => void;
onSwitchComposition: (compositionId: string) => void;
project: VideoCanvasStoryboardCompositionProject;
videoItems: VideoCanvasStoryboardItem[];
};
const TRANSITION_OPTIONS: Array<{
label: string;
value: VideoCanvasStoryboardCompositionTransition;
}> = [
{ label: "淡入淡出", value: "fade" },
{ label: "黑场过渡", value: "fade-black" },
{ label: "白场过渡", value: "fade-white" },
];
const SPEED_OPTIONS = [0.5, 1, 1.5, 2];
const formatTime = (seconds: number) => {
const safeSeconds = Math.max(0, Math.floor(seconds));
const minutes = Math.floor(safeSeconds / 60);
const remainder = safeSeconds % 60;
return `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`;
};
export function VideoCanvasStoryboardCompositionEditor({
composition,
compositionItems,
exporting,
onChange,
onClose,
onExport,
onSwitchComposition,
project,
videoItems,
}: CompositionEditorProps) {
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
const compositionMenuRef = useRef<HTMLDivElement | null>(null);
const [selectedClipId, setSelectedClipId] = useState<string | null>(null);
const [selectedSubtitleId, setSelectedSubtitleId] = useState<string | null>(null);
const [playheadSeconds, setPlayheadSeconds] = useState(0);
const [undoStack, setUndoStack] = useState<VideoCanvasStoryboardCompositionProject[]>([]);
const [redoStack, setRedoStack] = useState<VideoCanvasStoryboardCompositionProject[]>([]);
const [subtitlePanelOpen, setSubtitlePanelOpen] = useState(false);
const [subtitleTab, setSubtitleTab] = useState<"subtitle" | "text">("subtitle");
const [transitionPanelOpen, setTransitionPanelOpen] = useState(false);
const [speedMenuOpen, setSpeedMenuOpen] = useState(false);
const [exportMenuOpen, setExportMenuOpen] = useState(false);
const [compositionMenuOpen, setCompositionMenuOpen] = useState(false);
const [previewCollapsed, setPreviewCollapsed] = useState(false);
const [previewFullscreen, setPreviewFullscreen] = useState(false);
const [snapEnabled, setSnapEnabled] = useState(true);
const [subtitleTrackVisible, setSubtitleTrackVisible] = useState(true);
const [timelineZoom, setTimelineZoom] = useState(20);
const totalDuration = getVideoCanvasCompositionDuration(project);
const timelineDuration = Math.max(totalDuration, 10);
const selectedClip = project.clips.find((clip) => clip.id === selectedClipId) || null;
const selectedSubtitle =
project.subtitles.find((subtitle) => subtitle.id === selectedSubtitleId) || null;
const previewClip = selectedClip || project.clips[0] || null;
useEffect(() => {
setSelectedClipId(null);
setSelectedSubtitleId(null);
setPlayheadSeconds(0);
setUndoStack([]);
setRedoStack([]);
setSubtitlePanelOpen(false);
setTransitionPanelOpen(false);
setSpeedMenuOpen(false);
setExportMenuOpen(false);
setCompositionMenuOpen(false);
setPreviewCollapsed(false);
}, [composition.id]);
useEffect(() => {
if (selectedClipId && !selectedClip) setSelectedClipId(null);
}, [selectedClip, selectedClipId]);
useEffect(() => {
if (selectedSubtitleId && !selectedSubtitle) setSelectedSubtitleId(null);
}, [selectedSubtitle, selectedSubtitleId]);
useEffect(() => {
setPlayheadSeconds((current) => Math.min(current, totalDuration));
}, [totalDuration]);
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
if (previewFullscreen) {
setPreviewFullscreen(false);
return;
}
if (exportMenuOpen || speedMenuOpen) {
setExportMenuOpen(false);
setSpeedMenuOpen(false);
}
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [exportMenuOpen, previewFullscreen, speedMenuOpen]);
useEffect(() => {
if (!compositionMenuOpen) return;
const closeOnOutsidePointer = (event: PointerEvent) => {
if (
event.target instanceof Node &&
!compositionMenuRef.current?.contains(event.target)
) {
setCompositionMenuOpen(false);
}
};
document.addEventListener("pointerdown", closeOnOutsidePointer, true);
return () =>
document.removeEventListener("pointerdown", closeOnOutsidePointer, true);
}, [compositionMenuOpen]);
const applyProject = useCallback(
(nextProject: VideoCanvasStoryboardCompositionProject) => {
if (nextProject === project) return;
setUndoStack((current) => [...current.slice(-49), structuredClone(project)]);
setRedoStack([]);
onChange(nextProject);
},
[onChange, project],
);
const undo = useCallback(() => {
const previous = undoStack.at(-1);
if (!previous) return;
setUndoStack((current) => current.slice(0, -1));
setRedoStack((current) => [...current.slice(-49), structuredClone(project)]);
onChange(previous);
}, [onChange, project, undoStack]);
const redo = useCallback(() => {
const next = redoStack.at(-1);
if (!next) return;
setRedoStack((current) => current.slice(0, -1));
setUndoStack((current) => [...current.slice(-49), structuredClone(project)]);
onChange(next);
}, [onChange, project, redoStack]);
const addVideoItem = useCallback(
(item: VideoCanvasStoryboardItem) => {
if (!item.mediaUrl) return;
const clip = createVideoCanvasCompositionClip({
durationSeconds: item.durationSeconds || 5,
existingIds: project.clips.map((candidate) => candidate.id),
mediaUrl: item.mediaUrl,
nodeId: item.id,
title: item.title,
});
applyProject({ ...project, clips: [...project.clips, clip] });
setSelectedClipId(clip.id);
setPlayheadSeconds(totalDuration);
},
[applyProject, project, totalDuration],
);
const updateSelectedClip = useCallback(
(update: (clip: NonNullable<typeof selectedClip>) => NonNullable<typeof selectedClip>) => {
if (!selectedClip) return;
applyProject({
...project,
clips: project.clips.map((clip) =>
clip.id === selectedClip.id ? update(clip) : clip,
),
});
},
[applyProject, project, selectedClip],
);
const removeSelectedClip = useCallback(() => {
if (!selectedClip) return;
applyProject({
...project,
clips: project.clips.filter((clip) => clip.id !== selectedClip.id),
});
setSelectedClipId(null);
}, [applyProject, project, selectedClip]);
const trimSelectedClip = useCallback(
(side: "left" | "right") => {
if (!selectedClip) return;
const clipStart = getVideoCanvasCompositionClipStart(project, selectedClip.id);
if (clipStart === null) return;
const sourcePoint =
selectedClip.inPointSeconds +
Math.max(0, playheadSeconds - clipStart) * selectedClip.speed;
if (side === "left") {
if (sourcePoint >= selectedClip.outPointSeconds - 0.1) return;
updateSelectedClip((clip) => ({ ...clip, inPointSeconds: sourcePoint }));
} else {
if (sourcePoint <= selectedClip.inPointSeconds + 0.1) return;
updateSelectedClip((clip) => ({ ...clip, outPointSeconds: sourcePoint }));
}
},
[playheadSeconds, project, selectedClip, updateSelectedClip],
);
const splitSelectedClip = useCallback(() => {
if (!selectedClip) return;
const next = splitVideoCanvasCompositionClip(
project,
selectedClip.id,
playheadSeconds,
);
if (next !== project) applyProject(next);
}, [applyProject, playheadSeconds, project, selectedClip]);
const addSubtitle = useCallback(() => {
const id = `subtitle-${Date.now()}`;
const startSeconds = Math.min(playheadSeconds, totalDuration);
const subtitle = {
endSeconds: Math.min(Math.max(totalDuration, 2), startSeconds + 2),
id,
startSeconds,
text: "新建字幕",
};
applyProject({ ...project, subtitles: [...project.subtitles, subtitle] });
setSelectedSubtitleId(id);
setSubtitleTab("text");
}, [applyProject, playheadSeconds, project, totalDuration]);
const seekTimeline = (event: ReactPointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
if (!rect.width) return;
let next = ((event.clientX - rect.left) / rect.width) * timelineDuration;
if (snapEnabled) next = Math.round(next * 2) / 2;
setPlayheadSeconds(Math.min(totalDuration, Math.max(0, next)));
};
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const nodeId = event.dataTransfer.getData("application/x-video-canvas-node");
const item = videoItems.find((candidate) => candidate.id === nodeId);
if (item) addVideoItem(item);
};
const togglePlayback = async () => {
const video = previewVideoRef.current;
if (!video) return;
if (video.paused) await video.play().catch(() => undefined);
else video.pause();
};
const renderPreview = (fullscreen = false) => (
<div
className={cn(
"relative flex min-h-0 items-center justify-center overflow-hidden bg-[#1b1b1b]",
fullscreen && "fixed inset-0 z-[300] bg-black",
)}
>
{previewClip ? (
<video
className={cn(
"max-h-full max-w-full object-contain",
fullscreen ? "h-full w-full" : "h-full",
)}
key={`${composition.id}-${previewClip.id}`}
onTimeUpdate={(event) => {
const clipStart = getVideoCanvasCompositionClipStart(project, previewClip.id) || 0;
const relative = Math.max(
0,
event.currentTarget.currentTime - previewClip.inPointSeconds,
);
setPlayheadSeconds(
Math.min(totalDuration, clipStart + relative / previewClip.speed),
);
if (event.currentTarget.currentTime >= previewClip.outPointSeconds) {
event.currentTarget.pause();
}
}}
playsInline
preload="metadata"
ref={previewVideoRef}
src={previewClip.mediaUrl}
/>
) : (
<Video className="h-7 w-7 text-white/10" />
)}
{fullscreen ? (
<button
aria-label="退出全屏预览"
className="absolute right-5 top-5 flex size-9 items-center justify-center rounded-[6px] bg-black/60 text-white/72 hover:bg-black/80 hover:text-white"
onClick={() => setPreviewFullscreen(false)}
type="button"
>
<X className="h-5 w-5" />
</button>
) : null}
</div>
);
return (
<div className="grid min-h-0 min-w-[760px] flex-1 grid-cols-[minmax(248px,0.5fr)_minmax(520px,1fr)] gap-3 pb-4">
<section className="relative flex min-h-0 flex-col overflow-hidden rounded-[8px] bg-[#242424]">
<header className="flex h-[52px] shrink-0 items-center justify-between px-4">
<div className="flex items-center gap-2 text-[14px] font-medium text-white/78">
<span>视频</span>
<ChevronDown className="h-3 w-3 text-white/42" />
</div>
<button className="flex items-center gap-1 text-[12px] text-white/42" type="button">
全部
<ChevronDown className="h-3 w-3" />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-20">
{videoItems.length ? (
<div className="space-y-3">
{videoItems.map((item) => (
<article
className="group relative border-b border-white/7 pb-3"
draggable={Boolean(item.mediaUrl)}
key={item.id}
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(
"application/x-video-canvas-node",
item.id,
);
}}
>
<div className="text-[12px] text-white/48">{item.title}</div>
<div className="mt-2 flex gap-2">
<div className="relative aspect-[2/3] w-[106px] overflow-hidden rounded-[7px] bg-[#191919]">
{item.mediaUrl ? (
<video
className="h-full w-full object-cover"
muted
playsInline
preload="metadata"
src={item.mediaUrl}
/>
) : (
<div className="flex h-full items-center justify-center">
<Video className="h-5 w-5 text-white/15" />
</div>
)}
<button
aria-label={`添加 ${item.title} 到时间线`}
className="absolute bottom-2 right-2 flex size-7 items-center justify-center rounded-full bg-black/65 text-white opacity-0 transition-opacity hover:bg-black/85 focus-visible:opacity-100 group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-25"
disabled={!item.mediaUrl}
onClick={() => addVideoItem(item)}
title="添加到时间线"
type="button"
>
<Plus className="h-4 w-4" />
</button>
</div>
<div className="min-w-0 flex-1 pt-1 text-[11px] text-white/32">
<div>{item.modelName || "视频素材"}</div>
{item.durationSeconds ? (
<div className="mt-2">{item.durationSeconds}秒</div>
) : null}
<div className="mt-8 truncate">{item.subtitle || "参考素材"}</div>
</div>
</div>
</article>
))}
</div>
) : (
<div className="flex h-36 items-center justify-center text-[12px] text-white/28">
暂无视频素材
</div>
)}
</div>
<div className="absolute bottom-5 right-4" ref={compositionMenuRef}>
<button
aria-expanded={compositionMenuOpen}
aria-haspopup="menu"
className="flex size-[50px] flex-col items-center justify-center gap-0.5 rounded-full bg-[#2b2b2b] text-[11px] text-white/72 shadow-[0_10px_30px_rgba(0,0,0,0.36)] hover:bg-[#343434] hover:text-white"
onClick={() => setCompositionMenuOpen(true)}
type="button"
>
<Video className="h-4 w-4" />
<span>剪辑</span>
</button>
{compositionMenuOpen ? (
<div
aria-label="剪辑"
className="absolute bottom-[58px] right-0 w-[208px] overflow-hidden rounded-[8px] border border-white/8 bg-[#242424] py-1 shadow-[0_16px_40px_rgba(0,0,0,0.46)]"
role="menu"
>
{compositionItems.map((item) => (
<button
className="flex h-9 w-full items-center gap-2 px-3 text-left text-[13px] text-white/76 hover:bg-white/7 hover:text-white"
key={item.id}
onClick={() => {
setCompositionMenuOpen(false);
onSwitchComposition(item.id);
}}
type="button"
>
<Video className="h-4 w-4 text-white/42" />
<span className="min-w-0 flex-1 truncate">{item.title}</span>
{item.id === composition.id ? <Check className="h-4 w-4" /> : null}
</button>
))}
</div>
) : null}
</div>
</section>
<section className="flex min-h-0 flex-col overflow-hidden rounded-[8px] bg-[#242424]">
{!previewCollapsed ? (
<>
<header className="flex h-[48px] shrink-0 items-center justify-between px-3">
<span className="text-[14px] font-medium text-white/78">视频合成</span>
<div className="flex items-center gap-2">
<div className="relative">
<button
aria-expanded={exportMenuOpen}
className="flex h-8 items-center gap-2 rounded-[8px] bg-white px-3 text-[13px] font-medium text-black hover:bg-white/88"
disabled={exporting}
onClick={() => setExportMenuOpen((current) => !current)}
type="button"
>
{exporting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : null}
导出
{!exporting ? <ChevronDown className="h-3.5 w-3.5" /> : null}
</button>
{exportMenuOpen ? (
<div
aria-label="导出"
className="absolute right-0 top-10 z-40 w-36 rounded-[8px] border border-white/8 bg-[#202020] p-1 shadow-[0_12px_30px_rgba(0,0,0,0.42)]"
role="menu"
>
<div className="flex h-8 items-center px-3 text-[12px] text-white/34">
导出位置
</div>
<button
className="flex h-9 w-full items-center gap-2 rounded-[6px] px-3 text-left text-[12px] text-white/68 hover:bg-white/7 disabled:cursor-not-allowed disabled:opacity-30"
disabled={!project.clips.length || exporting}
onClick={() => {
setExportMenuOpen(false);
onExport("local");
}}
type="button"
>
<Download className="h-4 w-4" />
导出到本地
</button>
<button
className="flex h-9 w-full items-center gap-2 rounded-[6px] px-3 text-left text-[12px] text-white/68 hover:bg-white/7 disabled:cursor-not-allowed disabled:opacity-30"
disabled={!project.clips.length || exporting}
onClick={() => {
setExportMenuOpen(false);
onExport("canvas");
}}
type="button"
>
<PanelsTopLeft className="h-4 w-4" />
导出到画布
</button>
</div>
) : null}
</div>
<button
aria-label="退出剪辑"
className="flex size-8 items-center justify-center rounded-[6px] text-white/50 hover:bg-white/8 hover:text-white"
onClick={onClose}
type="button"
>
<X className="h-5 w-5" />
</button>
</div>
</header>
<div
className={cn(
"grid h-[230px] shrink-0 gap-2 px-2 pb-2",
transitionPanelOpen
? "grid-cols-[minmax(300px,1.2fr)_minmax(220px,0.9fr)_296px]"
: subtitlePanelOpen
? "grid-cols-[minmax(0,1fr)_296px]"
: "grid-cols-1",
)}
>
{transitionPanelOpen ? (
<aside className="overflow-hidden rounded-[7px] border border-white/7 bg-[#202020]">
<div className="flex h-10 items-center justify-between border-b border-white/7 px-3 text-[13px] text-white/76">
<span>转场库</span>
<button
aria-label="关闭转场库"
className="text-white/42 hover:text-white"
onClick={() => setTransitionPanelOpen(false)}
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="p-3">
<div className="mb-3 text-[12px] text-white/34">基础转场</div>
<div className="grid grid-cols-3 gap-2">
{TRANSITION_OPTIONS.map((option, index) => (
<button
aria-label={`${option.label} ${option.label}`}
className={cn(
"text-left text-[11px] text-white/54 hover:text-white",
!selectedClip && "cursor-not-allowed opacity-35",
)}
disabled={!selectedClip}
key={option.value}
onClick={() =>
updateSelectedClip((clip) => ({
...clip,
transition: option.value,
}))
}
type="button"
>
<span
className={cn(
"mb-1.5 block aspect-[1.45] rounded-[6px] border border-white/7",
index === 0 && "bg-[linear-gradient(90deg,#665d51,#202020)]",
index === 1 && "bg-[linear-gradient(90deg,#342d28,#050505)]",
index === 2 && "bg-[linear-gradient(90deg,#5c554d,#dedede)]",
selectedClip?.transition === option.value &&
"border-white/70 ring-1 ring-white/40",
)}
/>
<span className="block truncate">{option.label}</span>
</button>
))}
</div>
</div>
</aside>
) : null}
{renderPreview()}
{transitionPanelOpen ? (
<aside className="overflow-hidden rounded-[7px] border border-white/7 bg-[#202020]">
<div className="flex h-10 items-center justify-between border-b border-white/7 px-3 text-[13px] text-white/76">
<span>转场</span>
<button
aria-label="关闭转场"
className="text-white/42 hover:text-white"
onClick={() => setTransitionPanelOpen(false)}
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="p-3 text-[12px]">
<div className="text-white/32">名称</div>
<div className="mt-1 text-white/68">
{TRANSITION_OPTIONS.find((item) => item.value === selectedClip?.transition)?.label || "未选择转场"}
</div>
<div className="mt-4 text-white/32">时长</div>
<div className="mt-1 text-white/68">{selectedClip?.transition ? "0.5 秒" : "-"}</div>
<button
className="mt-5 flex h-8 w-full items-center justify-center gap-2 rounded-[6px] bg-white/6 text-white/56 hover:bg-white/9 disabled:opacity-30"
disabled={!selectedClip?.transition}
onClick={() => updateSelectedClip((clip) => ({ ...clip, transition: null }))}
type="button"
>
<Trash2 className="h-3.5 w-3.5" />
删除
</button>
</div>
</aside>
) : subtitlePanelOpen ? (
<aside className="overflow-hidden rounded-[7px] border border-white/7 bg-[#202020]">
<div className="flex h-10 items-center gap-4 border-b border-white/7 px-3 text-[13px]">
<button
className={subtitleTab === "subtitle" ? "text-white" : "text-white/40"}
onClick={() => setSubtitleTab("subtitle")}
type="button"
>
字幕
</button>
<button
className={subtitleTab === "text" ? "text-white" : "text-white/40"}
onClick={() => setSubtitleTab("text")}
type="button"
>
文本
</button>
</div>
{subtitleTab === "subtitle" ? (
<div className="p-3">
<input
aria-label="搜索字幕文本"
className="h-8 w-full rounded-[6px] bg-white/8 px-3 text-[12px] text-white outline-none placeholder:text-white/30"
placeholder="搜索字幕文本"
/>
<button
className="mt-2 flex h-8 w-full items-center justify-center gap-1 rounded-[6px] bg-white/9 text-[12px] text-white/78 hover:bg-white/12"
onClick={addSubtitle}
type="button"
>
<Plus className="h-4 w-4" />
新建字幕
</button>
<div className="mt-3 space-y-1">
{project.subtitles.length ? project.subtitles.map((subtitle) => (
<button
className={cn(
"w-full truncate rounded-[5px] px-2 py-1.5 text-left text-[12px] text-white/58 hover:bg-white/6",
subtitle.id === selectedSubtitleId && "bg-white/8 text-white/86",
)}
key={subtitle.id}
onClick={() => {
setSelectedSubtitleId(subtitle.id);
setSubtitleTab("text");
}}
type="button"
>
{subtitle.text}
</button>
)) : (
<div className="py-3 text-[12px] text-white/28">暂无字幕</div>
)}
</div>
</div>
) : selectedSubtitle ? (
<div className="p-3">
<textarea
aria-label="字幕文本"
className="h-24 w-full resize-none rounded-[6px] bg-white/7 p-3 text-[12px] leading-5 text-white outline-none"
onChange={(event) =>
applyProject({
...project,
subtitles: project.subtitles.map((subtitle) =>
subtitle.id === selectedSubtitle.id
? { ...subtitle, text: event.target.value }
: subtitle,
),
})
}
value={selectedSubtitle.text}
/>
</div>
) : (
<div className="p-3 text-[12px] text-white/34">请先选择一条字幕</div>
)}
</aside>
) : null}
</div>
</>
) : null}
<div className="flex h-12 shrink-0 items-center gap-1 border-y border-white/7 px-3">
<EditorIconButton disabled={!undoStack.length} label="撤销" onClick={undo}>
<Undo2 className="h-4 w-4" />
</EditorIconButton>
<EditorIconButton disabled={!redoStack.length} label="重做" onClick={redo}>
<Redo2 className="h-4 w-4" />
</EditorIconButton>
<EditorIconButton disabled={!selectedClip} label="分割" onClick={splitSelectedClip}>
<Scissors className="h-4 w-4" />
</EditorIconButton>
<EditorIconButton disabled={!selectedClip} label="向左裁剪" onClick={() => trimSelectedClip("left")}>
<span className="text-[16px]">⌈</span>
</EditorIconButton>
<EditorIconButton disabled={!selectedClip} label="向右裁剪" onClick={() => trimSelectedClip("right")}>
<span className="text-[16px]">⌉</span>
</EditorIconButton>
<EditorIconButton
active={subtitlePanelOpen}
label="字幕轨道"
onClick={() => {
setSubtitlePanelOpen((current) => !current);
setTransitionPanelOpen(false);
}}
>
<TextCursorInput className="h-4 w-4" />
</EditorIconButton>
<EditorIconButton
active={transitionPanelOpen}
label="转场"
onClick={() => {
setTransitionPanelOpen((current) => !current);
setSubtitlePanelOpen(false);
}}
>
<Video className="h-4 w-4" />
</EditorIconButton>
<div className="ml-auto flex items-center gap-3 text-[12px] tabular-nums text-white/58">
<span>{formatTime(playheadSeconds)}</span>
<button
aria-label={previewVideoRef.current?.paused === false ? "暂停" : "播放"}
className="flex size-8 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/16"
onClick={() => void togglePlayback()}
type="button"
>
<Play className="h-3.5 w-3.5" />
</button>
<span>{formatTime(totalDuration)}</span>
<EditorIconButton label="全屏预览" onClick={() => setPreviewFullscreen(true)}>
<Maximize2 className="h-4 w-4" />
</EditorIconButton>
</div>
<div className="relative ml-auto flex items-center gap-1">
<EditorIconButton
disabled={!selectedClip}
label="变速"
onClick={() => setSpeedMenuOpen((current) => !current)}
>
<Gauge className="h-4 w-4" />
</EditorIconButton>
{speedMenuOpen ? (
<div
aria-label="变速"
className="absolute bottom-10 right-0 z-40 w-28 rounded-[8px] border border-white/8 bg-[#202020] p-1 shadow-xl"
role="menu"
>
{SPEED_OPTIONS.map((speed) => (
<button
className="flex h-8 w-full items-center justify-between rounded-[5px] px-2 text-[12px] text-white/68 hover:bg-white/7"
key={speed}
onClick={() => {
updateSelectedClip((clip) => ({ ...clip, speed }));
setSpeedMenuOpen(false);
}}
type="button"
>
{speed}x
{selectedClip?.speed === speed ? <Check className="h-3.5 w-3.5" /> : null}
</button>
))}
</div>
) : null}
<EditorIconButton active={snapEnabled} label="吸附" onClick={() => setSnapEnabled((current) => !current)}>
<Link2 className="h-4 w-4" />
</EditorIconButton>
<ZoomOut className="h-4 w-4 text-white/48" />
<input
aria-label="时间线缩放"
className="h-1 w-20 accent-white"
max={100}
min={0}
onChange={(event) => setTimelineZoom(Number(event.target.value))}
type="range"
value={timelineZoom}
/>
<ZoomIn className="h-4 w-4 text-white/48" />
<EditorIconButton
label={previewCollapsed ? "展开预览(占满时间轴上方)" : "收起预览"}
onClick={() => setPreviewCollapsed((current) => !current)}
>
{previewCollapsed ? <Plus className="h-4 w-4" /> : <Minus className="h-4 w-4" />}
</EditorIconButton>
{previewCollapsed ? (
<EditorIconButton label="退出剪辑" onClick={onClose}>
<X className="h-4 w-4" />
</EditorIconButton>
) : null}
</div>
</div>
<div
className="relative min-h-[180px] flex-1 overflow-auto bg-[#1c1c1c]"
onDragOver={(event) => event.preventDefault()}
onDrop={handleDrop}
>
<div
aria-label="时间线"
className="relative min-h-full"
onPointerDown={seekTimeline}
style={{ minWidth: `${Math.max(100, timelineZoom + 80)}%` }}
>
<div className="absolute left-[88px] right-4 top-8 h-px bg-white/7" />
<div className="absolute left-0 top-[50px] flex h-14 w-[72px] items-center justify-center text-[20px] text-white/50">
<Video className="h-5 w-5" />
</div>
<div className="absolute left-[88px] right-4 top-[50px] flex h-14 gap-px bg-white/[0.025]">
{project.clips.map((clip) => (
<button
aria-label={`${clip.title} 时间线片段`}
className={cn(
"relative min-w-[52px] overflow-hidden border border-white/10 bg-[#303030] text-left",
clip.id === selectedClipId && "border-[#7aa7ff] ring-1 ring-[#7aa7ff]",
)}
key={clip.id}
onClick={(event) => {
event.stopPropagation();
setSelectedClipId(clip.id);
setPlayheadSeconds(getVideoCanvasCompositionClipStart(project, clip.id) || 0);
}}
style={{
width: `${Math.max(4, (getVideoCanvasCompositionClipDuration(clip) / timelineDuration) * 100)}%`,
}}
type="button"
>
<video
className="absolute inset-0 h-full w-full object-cover opacity-55"
muted
playsInline
preload="metadata"
src={clip.mediaUrl}
/>
<span className="absolute inset-x-1 bottom-1 truncate text-[10px] text-white/86">
{clip.title}
</span>
{clip.transition ? (
<span className="absolute left-0 top-0 h-full w-3 bg-white/25" />
) : null}
</button>
))}
</div>
{subtitlePanelOpen ? (
<>
<div className="absolute left-0 top-[118px] flex h-10 w-[72px] items-center justify-between px-3 text-white/50">
<TextCursorInput className="h-5 w-5" />
<button
aria-label={subtitleTrackVisible ? "关闭字幕" : "显示字幕"}
onClick={(event) => {
event.stopPropagation();
setSubtitleTrackVisible((current) => !current);
}}
type="button"
>
{subtitleTrackVisible ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
</div>
<div className="absolute left-[88px] right-4 top-[118px] h-10 bg-white/[0.025]">
{subtitleTrackVisible ? project.subtitles.map((subtitle) => (
<button
className={cn(
"absolute top-1 h-8 truncate border border-white/10 bg-[#505050] px-2 text-left text-[10px] text-white/80",
subtitle.id === selectedSubtitleId && "border-[#7aa7ff]",
)}
key={subtitle.id}
onClick={(event) => {
event.stopPropagation();
setSelectedSubtitleId(subtitle.id);
setSubtitleTab("text");
}}
style={{
left: `${(subtitle.startSeconds / timelineDuration) * 100}%`,
width: `${Math.max(3, ((subtitle.endSeconds - subtitle.startSeconds) / timelineDuration) * 100)}%`,
}}
type="button"
>
{subtitle.text}
</button>
)) : null}
</div>
</>
) : null}
<div
className="pointer-events-none absolute bottom-0 top-0 z-20 w-px bg-white/90"
style={{
left: `calc(88px + (100% - 104px) * ${timelineDuration ? playheadSeconds / timelineDuration : 0})`,
}}
/>
</div>
</div>
</section>
{previewFullscreen ? renderPreview(true) : null}
</div>
);
}
function EditorIconButton({
active = false,
children,
disabled = false,
label,
onClick,
}: {
active?: boolean;
children: React.ReactNode;
disabled?: boolean;
label: string;
onClick: () => void;
}) {
return (
<button
aria-label={label}
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-[6px] text-white/46 hover:bg-white/7 hover:text-white disabled:cursor-not-allowed disabled:opacity-20",
active && "bg-white/9 text-white/88",
)}
disabled={disabled}
onClick={onClick}
title={label}
type="button"
>
{children}
</button>
);
}

View File

@ -32,6 +32,7 @@ const GENERIC_CANVAS_PROMPT_SET = new Set([
]);
export interface PickedObjectAttachment extends Attachment {
composerTagId?: string;
pickedObject?: {
objectName: string;
objectPosition: string;
@ -39,6 +40,10 @@ export interface PickedObjectAttachment extends Attachment {
clickY: number;
boundingBox: { x: number; y: number; width: number; height: number };
};
storyboardReference?: {
nodeId: string;
title: string;
};
}
export function toRawCanvasChatId(value: string): string {

View File

@ -696,7 +696,9 @@ function DownloadableMarkdownTable({
<table
ref={tableRef}
className={cn(
"min-w-full table-auto border-collapse text-sm",
"w-max min-w-full table-auto border-collapse text-sm leading-6",
"[&_tr]:border-b [&_tr]:border-border/70 [&_tr:last-child]:border-b-0",
"[&_tbody_tr:nth-child(even)]:bg-muted/15 [&_tbody_tr:hover]:bg-muted/30",
"[&_td]:min-w-[10rem] [&_td]:max-w-[24rem] [&_td]:align-top [&_td]:whitespace-normal [&_td]:break-words [&_td]:[overflow-wrap:anywhere] [&_td]:[word-break:break-word]",
"[&_td:first-child]:min-w-[12rem] [&_td:first-child]:max-w-[18rem] [&_th:first-child]:min-w-[12rem] [&_th:first-child]:max-w-[18rem]",
"[&_th]:min-w-[10rem] [&_th]:max-w-[22rem] [&_th]:whitespace-normal [&_th]:break-words [&_th]:[overflow-wrap:anywhere] [&_th]:[word-break:break-word]",
@ -723,7 +725,8 @@ function MarkdownTableCell({
return (
<td
className={cn(
"min-w-[10rem] max-w-[24rem] align-top !whitespace-normal break-words [overflow-wrap:anywhere] [word-break:break-word]",
"min-w-[10rem] max-w-[24rem] border-r border-border/60 px-4 py-2.5 text-left align-top leading-6 last:border-r-0",
"!whitespace-normal break-words [overflow-wrap:anywhere] [word-break:break-word]",
className
)}
{...props}
@ -741,7 +744,8 @@ function MarkdownTableHeaderCell({
return (
<th
className={cn(
"min-w-[10rem] max-w-[22rem] !whitespace-normal break-words [overflow-wrap:anywhere] [word-break:break-word]",
"min-w-[10rem] max-w-[22rem] border-r border-border/70 bg-muted/70 px-4 py-2.5 text-left align-middle font-semibold leading-5 last:border-r-0",
"!whitespace-normal break-words [overflow-wrap:anywhere] [word-break:break-word]",
className
)}
{...props}

View File

@ -1754,6 +1754,7 @@ export function LovartHome({
agentEnabled={siteSettingsData?.agentEnabled !== false}
getItem={getLovartSidebarItem}
isEnabled={isLovartSidebarItemEnabled}
isAuthenticated={isAuthenticated}
isProjectsOnlyView={isProjectsOnlyView}
onAllProjects={openAllProjectsInPage}
onGallery={() => router.push("/gallery")}
@ -1764,6 +1765,7 @@ export function LovartHome({
onImageEditor={handleOpenImageEditor}
onNavLink={openNavLink}
onNewCanvas={openNewCanvas}
onRequireLogin={openLoginDialog}
onRoute={openNavLink}
onTutorial={handleOpenTutorial}
productPlanningEnabled={siteSettingsData?.productPlanningEnabled !== false}

View File

@ -12,6 +12,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { LovartHomeSidebarItemKey } from "@/lib/home/lovart-sidebar-config";
import { runAuthenticatedSidebarAction } from "@/lib/home/sidebar-auth";
import type { NavLink, TutorialVideo } from "./types";
type SidebarItem = {
@ -23,6 +24,7 @@ type LovartSidebarProps = {
agentEnabled: boolean;
getItem: (key: LovartHomeSidebarItemKey) => SidebarItem;
isEnabled: (key: LovartHomeSidebarItemKey) => boolean;
isAuthenticated: boolean;
isProjectsOnlyView: boolean;
onAllProjects: () => void;
onGallery: () => void;
@ -30,6 +32,7 @@ type LovartSidebarProps = {
onImageEditor: () => void;
onNavLink: (url: string) => void;
onNewCanvas: () => void;
onRequireLogin: () => void;
onRoute: (url: string) => void;
onTutorial: (video: TutorialVideo) => void;
productPlanningEnabled: boolean;
@ -58,6 +61,7 @@ export function LovartSidebar({
agentEnabled,
getItem,
isEnabled,
isAuthenticated,
isProjectsOnlyView,
onAllProjects,
onGallery,
@ -65,6 +69,7 @@ export function LovartSidebar({
onImageEditor,
onNavLink,
onNewCanvas,
onRequireLogin,
onRoute,
onTutorial,
productPlanningEnabled,
@ -93,6 +98,13 @@ export function LovartSidebar({
]
.filter(Boolean)
.join(" ");
const protectAction = (action: () => void) => () => {
runAuthenticatedSidebarAction({
action,
isAuthenticated,
onRequireLogin,
});
};
const tooltipButton = (
key: LovartHomeSidebarItemKey,
@ -105,7 +117,7 @@ export function LovartSidebar({
<button
type="button"
className={sidebarButtonClass(options?.active)}
onClick={onClick}
onClick={protectAction(onClick)}
title={getItem(key).label}
>
{renderIcon(key, {
@ -129,6 +141,7 @@ export function LovartSidebar({
<button
type="button"
className={sidebarButtonClass()}
onClick={protectAction(() => undefined)}
title={getItem(key).label}
>
{renderIcon(key, { sizeClassName: "h-[18px] w-[18px]" })}
@ -148,7 +161,7 @@ export function LovartSidebar({
key={link.id}
type="button"
className="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-left text-[13px] text-black/80 transition hover:bg-black/[0.04] dark:text-white/80 dark:hover:bg-white/8"
onClick={() => onNavLink(link.url)}
onClick={protectAction(() => onNavLink(link.url))}
>
<span className="line-clamp-1">{link.name}</span>
<ArrowRight className="h-3.5 w-3.5 text-black/30 dark:text-white/35" />
@ -175,7 +188,7 @@ export function LovartSidebar({
<button
type="button"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-opacity hover:opacity-80"
onClick={onHome}
onClick={protectAction(onHome)}
title={siteName}
>
{siteLogo ? (
@ -200,7 +213,7 @@ export function LovartSidebar({
<TooltipTrigger asChild>
<button
type="button"
onClick={onNewCanvas}
onClick={protectAction(onNewCanvas)}
className={sidebarButtonClass()}
title={getItem("newProject").label}
>
@ -223,6 +236,7 @@ export function LovartSidebar({
<button
type="button"
className={sidebarButtonClass()}
onClick={protectAction(() => undefined)}
title={getItem("tutorial").label}
>
{renderIcon("tutorial", { sizeClassName: "h-[18px] w-[18px]" })}
@ -243,7 +257,7 @@ export function LovartSidebar({
key={video.id}
type="button"
className="flex w-full items-center gap-2.5 rounded-2xl p-2.5 text-left transition hover:bg-black/[0.04] dark:hover:bg-white/8"
onClick={() => onTutorial(video)}
onClick={protectAction(() => onTutorial(video))}
>
<div className="h-10 w-16 flex-shrink-0 overflow-hidden rounded-xl bg-black/[0.04] dark:bg-white/8">
{video.coverUrl ? (

View File

@ -70,7 +70,7 @@ type CameraMove =
// 视频参数接口
export interface VideoGenerationParams {
mode: VideoGenerationMode;
resolution: "480p" | "720p" | "1080p";
resolution: "480p" | "720p" | "1080p" | "4k";
ratio: "16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9" | "adaptive";
duration: number;
generateAudio?: boolean;

View File

@ -0,0 +1,60 @@
# StoryAI 3D Director Desk 集成审计
## 结论
可以直接复用,而且本项目已经完成复用,不再使用 CSS 模拟导演台视口。上游是 MIT 许可的真实 React Three Fiber / Three.js 编辑器,并提供宿主通信桥;当前版本已通过同源 iframe、`postMessage`、画布节点数据和后台配置接入,不需要从零重写 3D 场景、机位和角色编辑内核。
## 固定版本
- 仓库:https://github.com/jiguang132/storyai-3d-director-desk
- 提交:`8c8bd361790be4d37158a7430365e65546e358fe`
- 上游状态:2026-07-14 通过 `git ls-remote` 复核,`HEAD` 与 `main` 仍指向上述提交,无版本漂移;上游当前没有 release 或 tag。
- 许可证:MIT;内置 UE Mannequin 另附 Sketchfab Standard 许可说明
- 技术栈:React 18、Vite 6、TypeScript、Three.js、React Three Fiber、Drei、Zustand
- 上游构建:`npm run build` 通过,生产 JS 约 1.37 MB,gzip 约 382 KB
- 上游测试:304/312 通过;8 项失败集中在模型库缺失和视觉常量/样式断言漂移
## 已接入
- 构建产物固定在 `/public/vendor/storyai-director`,Vite 使用相对基址 `./`,可从 `/vendor/storyai-director/` 子目录加载带哈希的 JS/CSS;目录内保留上游 `LICENSE`、`SOURCE-NOTICE.md` 和模型素材的独立许可说明。
- 视频画布通过同源 iframe 打开真实导演台,以导演节点 ID 作为场景隔离键。
- 全景图入口已对齐竞品的 `本地上传 / 历史记录 / AI生成`。iframe 把请求发给宿主,宿主创建外层图片节点和图片到导演节点的有向边;取消链接时同步移除对应画布边。
- 导演台发送的真实 PNG 截图通过现有图片上传服务归档,再创建为导演节点的下游图片节点,不再使用固定演示图。
- `/settings/video-canvas` 新增导演台启停、同源嵌入地址、帮助文档地址和四/十二方位截图显示开关,运行时从 `video_canvas_function_config` 读取。
- 宿主只在收到 `storyai:director-desk-ready` 后显示 iframe;每次打开生成唯一 `frameSession` 避免静态入口复用旧哈希缓存,首次 10 秒超时会自动换地址重载一次,再失败时显示手动“重新加载”。
- 角色姿势预设由 `directorDesk.posePresets` 运行时下发,StoryAI store 不再要求 UI 直接读取固定常量;后台以 JSON 维护 20 套预设和骨骼角度。
- 机位视角预设由 `directorDesk.cameraPresets` 运行时下发;后台可维护动态当前视角及固定位置、注视点和 FOV,StoryAI 根据配置生成摄像机快照。
- `directorDesk.panorama` 运行时配置控制 PNG 上传格式、AI 费用显示、预处理延时、历史数量、输出比例和尺寸;`director-panorama-ai` 功能项控制后端模型与系统提示词。
- 开发环境在 `USE_REMOTE_STORAGE_IN_DEV!=true` 时将 `canvas-upload` 写入 `public/uploads/local`,避免复制的生产 S3 端点在本机超时;生产存储、权限校验和媒体资产登记逻辑不变。
## Chrome 回归结果
- 本地已从空画布创建导演台节点并打开 iframe;场景树、蓝色 UE Mannequin、网格、坐标轴、变换工具和右侧属性面板均为真实 WebGL/DOM 状态,不存在登录重定向或空白画布。
- `导演视角 -> 机位视角` 会自动选择 `机位1`、启用活动 PerspectiveCamera,并把右侧切为摄像机属性。选择 `9:16` 后取景框、遮罩和导出裁切同步生效。
- `当前视角截图 -> 发送到画布` 已完整跑通:iframe 生成真实 PNG,宿主上传到对象存储,创建下游图片节点与有向边,关闭导演台后画布能看到截图结果。
- 上传后的 CDN 首次加载竞态已由媒体节点有限次数指数退避重试修复;回归图片尺寸为 `640 × 1138`。
- 竞品相同链路已实测:点击截图时先上传并显示 `正在上传截图...`,生成机位内递增 shot 卡片;点击卡片发送按钮后才创建画布图片节点和边。
- 竞品变换入口实测为单按钮菜单 `移动 V / 旋转 R / 缩放 S`。内置 StoryAI 已改为相同结构并支持快捷键,角色属性页会回到移动模式。
- 竞品没有四/十二方位截图入口;内置扩展能力由后台开关控制并默认隐藏。Chrome 已验证 `batchCapture=0` 不渲染按钮、`batchCapture=1` 渲染两个按钮。
- 本轮 Chrome 还定位并修复了静态包根路径错误造成的纯黑 iframe;父画布内现已验证真实 WebGL 画面非空,iframe ready 后类名为 `opacity-100`。
- 最终静态哈希替换后又复现一次旧入口缓存;加入每次挂载唯一 `frameSession` 后,Chrome 实测关闭再打开可直接加载最新哈希,iframe 首次即为 `opacity-100`,不再依赖手动重载。
- 竞品 20 套姿势的 25 个可见关节角度已全部通过 Chrome 逐项采集,每次应用后都用 `Cmd+Z` 验证原状态恢复。本地运行时配置的 20 个入口和 `T型` 全部关键角度已浏览器回归一致。
- 竞品全景图已实测:PNG 上传自动创建外层图片节点与入边,水平旋转和半径修改均可一次 undo;AI 面板在约 3 秒处理后启用费用 `22` 的生成按钮。
- 本地同一 PNG 已完整回归:生成 `codex-panorama-source` 外层节点和 1 条边,导演台显示 `已连接全景图 / 取消链接`,历史条目显示 `使用中`,AI 上传预处理后 `生成 22` 可用。未点击生成,避免未确认的外部模型费用。
- 竞品场景设置已逐项实测:四个开关默认为 `角色标签 on / 网格吸附 off / 高斯地面吸附 on / 地面 on`;场景缩放滑杆使用倍率 `0.1..10`、文本显示 `10%..1000%`。本地已补齐相同默认值、旧工程迁移、缩放编辑语义和两列开关布局。
- 竞品场景树已逐项实测为 `道具 -> 机位 -> 角色` 扁平顺序;搜索、无结果文案、选中保留、显隐/锁定动态动作和单步撤销均已在本地 Chrome 对照通过。StoryAI 去除了可见分组标题,但保留群众展开、多选和成员预览扩展能力。
- 构图参考线已按竞品完成:具体画幅内右上角使用小型 `构图参考线` 开关,开启时只绘制三分线;`Cmd+Z` 不影响该临时状态,关闭并重开导演台恢复关闭。Chrome 已验证 `Value: 0 -> 1 -> Cmd+Z 仍为 1 -> 重开后 0`,旧快照中的开启值也不会再恢复。
- 顶栏 `帮助 / 关闭` 已按竞品完成:帮助地址由宿主配置下发,iframe 具备受限的新标签弹窗权限;Chrome 已验证新标签最终导航到 `LibTV使用指南 - 飞书云文档` 的准确飞书 URL。关闭仍只卸载全屏 iframe,保留外层导演台节点和再次打开入口。
- 画幅比例菜单已按竞品完成:可见选项固定为 `Auto / 21:9 / 16:9 / 4:3 / 1:1 / 3:4 / 9:16`,选择后保持菜单展开,比例变化不进入 undo,重复点击入口才收起。Chrome 已逐步验证 `Auto -> 21:9 -> Cmd+Z 仍为 21:9 -> 9:16 -> 关闭菜单`,取景框、遮罩和选中态同步正确。
- 视口全屏已按竞品完成:进入后仅折叠左右侧栏,顶栏、3D 视口、画幅框和工具条保留,单按 `Escape` 不退出,再点同一入口恢复。折叠态现为不进入 undo 和项目快照的会话 UI;Chrome 已验证全屏态关闭并重开后侧栏恢复,而 `9:16` 画幅和工程状态保留。
- 机位视角预设已按竞品完成:`添加机位` 展示相同顺序的 15 项双列菜单;14 个固定预设使用逐项采集的位置、注视点和 FOV,`当前视角` 使用实时视口快照。Chrome 已验证 `正面特写` 和动态当前视角的创建、属性联动与单步撤销,菜单与全景图浮层互斥。
- 摄像机属性面板已按竞品完成:原生机位/跟随/注视下拉框、名称延迟提交、机位视角快捷键、位置/偏移、旋转、手动坐标与手动旋转双状态、对象注视、第一/第三人称跟随、`15..90` 整数 FOV 和空截图区均已对齐。默认位置/注视/旋转为 `[0,2.2,10] / [0,1.2,0] / [5.71,180,0]`,旧机位与截图名称在加载时迁移为 `机位1 / 机位1-shot-01`。
## 当前边界
- 工程 JSON 已通过 `storyai:director-desk-project-updated` 写回导演节点的 `directorProject` 并随画布数据库持久化,跨浏览器和环境复制恢复已验证;`localStorage` 只保留独立运行兜底,不再是宿主模式的唯一数据源。
- 上游模型库引用的外置中文 FBX 目录未随仓库或服务器提供,因此竞品对齐版本不展示这些失效资源。后续若开放模型库,必须接入自有后台素材并逐项确认模型、贴图和缩略图分发许可。
- 角色姿势、机位、群众默认值、全景图和高斯配置均已改为宿主运行时下发。角色基础网格仍使用随包 UE Mannequin,其 Sketchfab Standard 许可需要与静态包一起保留。
- Gaussian Splat 已支持 PLY/SPLAT/SPZ/KSPLAT 上传、解析、渲染、射线命中和落地补偿,不再是占位开关。
- 当前固定版本与上游 HEAD 相同,但本地补丁较大。升级时必须从固定提交重新应用 `patches/storyai-3d-director-desk-local.patch`,完整运行 StoryAI 测试、TypeScript、生产构建和父画布 Chrome 回归,不能直接替换哈希静态包。
- 未执行的只有会真实消耗积分的 AI 生成完整任务;上传、预处理、按钮状态、费用显示和宿主消息链路已经验证。

View File

@ -32,8 +32,8 @@
| 文生视频 | 视频生成模型 text2video | `/api/canvas/generate/video` |
| 首帧图生视频 | 视频生成模型 image2video | `/api/canvas/generate/video` |
| 首尾帧生视频 | 视频生成模型 frames2video | `/api/canvas/generate/video`,最多传 4 张图 |
| 音频生视频 | audio2video,官方会结合音频节拍/情绪和参考图 | 当前先用提示词+参考图走 `/api/canvas/generate/video`,音频节拍解析后端后续补 |
| 工具箱工作流模板 | 预设画布工作流,点击后在当前画布展开节点 | 前端已按官方工作流思路展开,后续模板内容可配置化扩充 |
| 音频生视频 | audio2video,音频节点直接作为视频模型的音频参考 | `/api/canvas/generate/video` 使用 `audio-to-video`;Seedance 2.0 下发 `referenceAudios`,Wan 2.5 下发 `audio_url` |
| 工具箱工作流模板 | 预设画布工作流,点击后在当前视口中心展开节点 | 后台配置驱动的 2 类 30 项模板目录;帮助、封面、提示词、节点布局、连线和可见性均可配置并持久化 |
## 配置入口
@ -49,6 +49,8 @@
- 每个功能的模型覆盖
- 每个功能的扣点
- 每个功能的预设提示词/规则
- 3D 导演台新项目默认画幅、机位预设、姿势预设、群众参数、高斯上传参数和全景图参数
- 视频音频分离启停、算法标识、人声/背景增益和无声视频帧率
## 当前已接入的前端触发点
@ -61,8 +63,848 @@
## 后续后端缺口
- 真正的 audio2video 音频节拍/情绪解析输入
- 在服务器模型配置中启用带密钥的 Seedance 2.0 或 Wan 2.5,完成一次真实付费音频视频任务
- 接入 Demucs/MDX 等专业人声与伴奏源分离服务;当前中心/侧声道算法是可工作的本地后备,不冒充云端模型质量
- 视频编辑到视频、视频参考生成分镜脚本的专用接口
- 擦除/局部重绘的蒙版编辑接口
- 官方扣点估算等价接口
- 官方 67 个工具规格的完整后台模型路由表
## 逐项竞品实测:音频生视频(2026-07-13)
- 状态:交互、请求模式、模型能力筛选、供应商音频字段和音频资源工具已完成;后续不要重复音频历史、右连接点、视频节点参数面板、无参考图主流程以及截取/变速/下载。只需在服务器启用音频视频模型后补一次真实生成结果。
- 竞品从 `历史记录 -> 音频历史(6)` 使用最新条目后创建 5 秒音频资源节点。资源节点工具栏只有 `截取 / 变速 / 下载`;右侧 `引用该节点生成` 菜单中 `Video` 可用,文本、图片、导演台等不支持的目标保持禁用。
- 竞品点击 `Video` 后只创建一条音频到视频的有向边,不要求图片参考。视频节点显示 `参考 / 标记 / 特效 / 角色库 / 运镜`、模式 `音频参考`、模型短标签 `2.0`、参数 `16:9 · 720P · 5s · 1个 ·`,预计成本 `135`。测试账号余额仅 20,未点击竞品付费提交,避免消耗用户积分。
- 竞品空白 TTS 节点也显示 `音频生视频` 尝试项,但点击不会创建节点;必须先有真实音频资源。本地快捷动作已采用同一门槛。
- 本地已移除旧的强制参考图和伪首帧模式。starter、快捷动作和预设 Composer 都只创建 `音频 -> 视频`;视频节点按功能键进入独立音频参考面板,显示 `2.0 / 音频参考 / 16:9 · 720P · 5s · 1个 ·`,不会再同时叠加通用“文生视频”面板。
- API 对音频 URL 单独做可访问性过滤,`audio-to-video` 至少要求 1 个可访问音频;模型选择只接受声明该模式的 Krea 视频模型。Seedance 2.0/2.0 Fast 使用最多 3 个 `referenceAudios`,Wan 2.5 使用首个 `audio_url`,不支持音频的模型不会静默退化。
- 本地 Chrome 按相同步骤提交后,节点明确返回 `没有启用支持音频参考的 Seedance 2.0 或 Wan 2.5 视频模型`。这验证请求已到达新的音频能力分支;复制到本地的配置库当前只有不支持音频的 `Agnes Video v2.0`,因此没有向外部模型发起错误任务。临时音频和视频节点随后删除,数据库节点数恢复 `3`。
- 音频资源工具补测(2026-07-13):竞品选中工具栏固定为 `截取 / 变速 / 下载`。截取默认选择源音频前 1/3,确认后保留原节点并生成 `_截取` 独立节点;变速范围 `0.1x - 4.0x`,步进 `0.1x`,文本框支持回车应用,`2.00x` 预览把约 4 秒时长显示为 2 秒,确认后生成 `_2x` 独立节点。原节点始终保持选中。
- 本地已按相同步骤完成浏览器回归:截取结果 `1.306667s`,二倍速结果 `1.96s`,均编码为 WAV、上传为独立资产并在刷新后持久化。下载按钮补齐 `data-auth-download-trigger=true`,远程音频通过受认证代理转为 Blob 下载,避免跨域 MP3 劫持当前画布;处理结果按真实 `.wav` 扩展名下载。代理仅接受公网 HTTPS 音频,禁止私网地址、重定向、非音频响应和超过 100MB 的资源。
- 本轮 9 项音频定向自动化、helper 全集 `180/180`、TypeScript、`git diff --check` 与生产构建通过;测试节点删除后数据库节点数恢复 `3`。
## 逐项竞品实测:视频剪辑(2026-07-13)
- 状态:默认选区、拖拽、整数秒吸附、选区循环开关、关闭、确认、独立结果节点、真实媒体处理、上传、持久化和播放均完成;后续不要重复视频剪辑主流程。
- 竞品从 `视频历史(10)` 使用首条资源后创建并选中 `视频39`。选中工具栏顺序为 `剪辑 / 裁剪 / 高清 / 解析 / 智能去字幕 / 音频分离`,卡片内另有 `截取首帧 / 截取尾帧 / 截取当前帧`。
- 点击 `剪辑` 后工具栏切换为 959px 宽、73px 高的浮动剪辑条:左侧关闭和键盘入口,中间 56px 高视频缩略帧轨道,右侧为整数秒吸附、选区循环和确认。5 秒源默认选择前 1/3,竞品显示 `1.68 s`;吸附按钮的 aria 在 `开启整数秒吸附 / 关闭整数秒吸附` 间切换,循环按钮在 `关闭选区循环播放 / 开启选区循环播放` 间切换。
- 竞品确认后保留 `视频39` 原节点,在同一 Y 坐标右侧约 722px 新建并选中独立 `剪辑` 节点,不创建连线。结果卡显示 `1280 × 720` 和 `0:01`,说明视频时间轴按整秒向下取整而不是四舍五入。
- 本地已把旧的 `{ start: 0.18, end: 0.52 }` 占位选区改为前 1/3,补齐整段拖动、双手柄、整数秒吸附、循环开关和关闭入口;缩略帧由受保护的视频代理解码生成,不再把 MP4 URL 当图片而显示破图。
- 确认剪辑会通过 `MediaRecorder + captureStream` 按真实选区录制 WebM,经 `canvas-video-process` 上传并创建右侧独立 `剪辑` 节点。浏览器实测结果约 0.59MB,上传路径为 `/uploads/local/*.webm`,可正常播放(`readyState=4`、无媒体错误),刷新后仍持久化并显示 `0:01`。
- 新增 `/api/canvas/video-source`,与音频代理共享公网 HTTPS、私网阻断、禁止重定向、媒体 MIME 校验、15 秒超时和 100MB 上限;本地代理冒烟返回 `200 video/mp4 10940879`。测试结束后双方临时节点均删除,本地数据库恢复 `3` 个原始节点。
- 本轮 helper 全集 `184/184`、TypeScript、`git diff --check` 和生产构建均通过。
## 逐项竞品实测:视频裁剪(2026-07-13)
- 状态:默认选区、整框移动、八方向自由缩放、动态像素尺寸、关闭、生成、真实媒体裁剪、音轨保留、独立结果节点、播放和刷新持久化均完成;后续不要重复视频裁剪主流程。
- 竞品选中 `视频39` 后点击 `裁剪`,源节点 622×350 的媒体区上默认出现 `left/top 10%`、`width/height 80%` 的裁剪框;8 个手柄 aria 依次为 `resize-nw / n / ne / e / se / s / sw / w`,框体本身可移动,说明算法是归一化自由矩形而非固定画幅比例。
- 1280×720 源的默认选区实时显示 `1024 x 576`。节点下方浮动条实测为 244×49px,只有 `退出裁剪`、动态尺寸和 `生成裁剪` 三部分,不提供额外比例菜单。
- 竞品确认后保留 `视频39`,在同一 Y 坐标右侧 742px 新建并选中 `视频39_裁剪`;结果标记 `1024 × 576`、时长仍为 5 秒,不创建源到结果的连线。
- 本地原实现只有不可交互的手柄装饰,确认后用定时器复制原视频 URL,并错误创建连线。本轮已改为真实归一化选区状态:整框移动限制在源画面内,8 个手柄分别修改对应边,最小宽高 5%,输出像素按 `round(source × crop size)` 实时计算。
- 真实处理使用受保护的视频代理解码源视频,将选区逐帧绘制到目标尺寸 Canvas,组合 Canvas 视频轨与源音频轨后用 MediaRecorder 输出 WebM,再经 `canvas-video-process` 上传。Chrome 实测结果路径 `/uploads/local/*.webm`,媒体解码尺寸精确为 `1024×576`,`readyState=4`、无错误且可播放;刷新后仍持久化并显示 5 秒。
- 本地结果标题、输出尺寸、同 Y 右侧独立放置、自动选中和无新增连线均与竞品一致。针对默认框、移动边界、八向自由缩放和最小尺寸新增 3 项测试;测试结束后双方临时节点删除,本地数据库恢复 `3`。
- 本轮 helper 全集 `187/187`、TypeScript、`git diff --check` 和生产构建均通过。
## 逐项竞品实测:视频高清(2026-07-13)
- 状态:配置节点创建、源节点连线、模型选择、分辨率、补帧模式、帧率、慢放、费用显示、本地真实输出、播放和刷新持久化均完成;竞品最终 Topaz 画质仍因测试账号积分不足未付费提交。后续不要重复高清参数面板和本地默认 1080P 主流程。
- 竞品选中 `视频39` 点击 `高清` 后,不是在源节点上打开小菜单,而是立即在同一 Y 坐标右侧创建 350×350 的 `高清(1080P)` 配置节点并建立源到配置节点的连线;节点被自动选中,下方出现宽 420px 的 `视频高清` 参数面板。
- 默认参数为模型 `Topazlabs`、分辨率 `1080P`、补帧模式 `不补帧`、慢放 `1x`,费用 `24`。分辨率改为 `2K / 4K` 时标题同步变成 `高清(2K)/高清(4K)`,费用分别为 `42 / 90`。
- `Topazlabs` 模型菜单当前只有同名一个选项。补帧模式为 `高质量补帧 / 不补帧`;高质量补帧会增加帧率字段,选项为 `标准(24fps) / 标准(30fps) / 流畅(60fps) / 超流畅(90fps) / 极致流畅(120fps)`。慢放选项为 `1x / 2x / 3x / 5x`。
- 竞品 4K 高质量补帧 1x 费用为 `138`,改成 2x 后为 `270`,帧率从 30 改成 60 不改变费用。由此可反推费用公式为 `setup + (resolutionBase + interpolationExtra - setup) × slowMultiplier`;默认 setup 为 6,三档基础费用为 24/42/90,补帧附加费用为 12/24/48。
- 竞品账号余额 20,默认任务也需 24,因此未点击最终生成,避免消耗用户积分。竞品最终 Topaz 超分画质和任务响应结构尚未冒充为已验证。
- 本地已改为点击高清即创建并选中右侧配置节点,面板结构、选项、标题联动和费用与竞品一致。默认 1080P 通过受保护视频代理、Canvas 高质量重采样和 MediaRecorder 真实输出 WebM,浏览器实测 `/uploads/local/*.webm` 为精确 `1920×1080`、`readyState=4`、无媒体错误;配置节点原位转为生成结果并在刷新后恢复。
- 本地高质量补帧使用目标帧率 Canvas 捕获,慢放通过播放速率和输出时长联动,并保留源音轨;它是可工作的本地后备实现,不宣称等同 Topaz AI 超分算法。模型显示名、固定费用、三档尺寸/费用、补帧附加费用、帧率和慢放档位均加入 `/settings/video-canvas` 的“视频高清”设置卡,不再硬编码在前端交互组件。
- 新增 4 项高清参数与费用自动化;双方测试节点清理后,本地数据库恢复 `3`。全量辅助测试 `191/191`、TypeScript、`git diff --check` 与生产构建均通过。
## 逐项竞品实测:视频解析(2026-07-13)
- 状态:扣点、加载态、独立结果节点、结构化镜头表、关键帧、重复解析、无连线、全屏展开、`ESC` 关闭、真实模型调用、失败处理、时间轴校正和刷新持久化均完成;后续不要重复视频解析主流程。
- 竞品选中 5 秒视频后点击 `解析`,余额立即从 `20` 变为 `18`,说明固定费用为 `2` 点;按钮同步变成 disabled 的 `解析中`。任务期间保留已有故事节点,不预先插入空白或骨架故事节点。
- 竞品每次成功都会新建并选中一个独立 `800×400` 的 `视频故事` 节点,不更新旧节点,也不创建源视频到故事节点的连线。第一份故事默认与源视频同 X、放在源节点下方约 94px;已有故事占位时会另寻空位。
- 故事表固定 16 列:`镜号 / 开始时间 / 结束时间 / 时长 / 画面描述 / 叙事内容 / 景别 / 摄影机角度 / 摄影机运动 / 焦距与景深 / 光线 / 背景音乐 / 人声/音效 / 图像生成提示词 / 视频运动提示词 / 关键帧`。中文字段负责叙事、摄影和声音分析,图像与运动提示词主要使用英文,关键帧来自源视频实际画面。
- 同一 5 秒源视频的历史结果为一镜 `0→5`,本轮再次解析得到两镜 `0→2.5 / 2.5→5`,文案、景别和提示词也发生变化;由此确认后端是视觉大模型的非确定性结构化分析,不是前端固定切段或文案模板。复杂度判断可改变镜头数,但所有镜头必须连续覆盖真实视频总时长。
- 节点标题栏右侧 18px 对角展开按钮会打开全视口故事表:顶栏高 56px,左侧标题 `视频故事`,右侧 `关闭 (ESC)`;表格横向滚动,行高约 160px。点击关闭或实际按 `ESC` 都回到画布,节点数据和选择不丢失。
- 本地首次实测暴露两个旧问题:默认分析模型返回 `Invalid Authentication`,前端又把错误伪装成一镜占位故事。本轮改为最多 3 个健康文本/多模态模型顺序回退,排除生图和视频模型;失败只恢复源视频选择并显示错误,不再创建假结果。真实回归由 `qwen3-vl-plus` 成功返回中文分析、英文生成提示词和 JPEG 关键帧。
- 本地服务端使用 `ffprobe` 读取真实时长,并按时间顺序通过 `ffmpeg` 抽取 4 帧、缩放到最长边 960 后送入视觉模型。提示词、模型覆盖、启停和固定费用都读取 `video_canvas_function_config`;后台 `/settings/video-canvas` 的“视频解析”功能卡可直接调整模型、2 点费用和分析规则。
- 为防模型把 5 秒素材误写成 15 秒,提示词显式写入真实总时长;服务端再次验证时间段的有序、连续、无重叠和不越界。模型时间轴不合法时按镜头数均分真实时长,合法时保留模型切点并重新计算时长。
- 本地专用账号 `codex-canvas-e2e@local.test` 已恢复为普通 Free 用户,浏览器实测同样 `20→18` 即时扣点,账本记录 `video-analysis / qwen3-vl-plus / -2`。结果节点位于源视频正下方并保持当前缩放自动居中,尺寸 `800×400`、无连线;全屏视觉、实际 `ESC`、刷新后 16 列数据与关键帧持久化均通过。
- 双方本轮新增的故事节点均已清理,竞品保留原有故事节点,本地临时源视频也已删除,数据库恢复 `3` 个原始节点。新增 4 项解析参数、落位和时间轴自动化;helper 全集 `196/196`、TypeScript、`git diff --check` 与生产构建均通过。
## 逐项竞品实测:智能去字幕(2026-07-13)
- 状态:入口文案、智能模式、框选模式、创建/移动/四角缩放、多区域、撤销/重做/重置、费用与禁用态、真实逐帧处理、音轨保留、上传、独立结果节点和播放均完成;后续不要重复智能去字幕主流程。竞品新任务的最终云端结果仍受测试账号云存储额度限制,不宣称本地修复画质等同竞品云模型。
- 竞品选中 5 秒 `视频39` 后,`智能去字幕` 菜单固定显示说明 `AI一键去除视频字幕,仅支持中英文字幕`,子项为 `智能擦除 / 框选擦除`。智能模式把标准工具栏替换为底部浮动条:`关闭去字幕生成器 / 智能擦除 / 0 / 向上箭头提交`。
- 竞品智能提交不会先创建空节点;本次账号弹出全屏会员超市并明确提示 `云存储空间不足,升级以获得更多存储空间`,余额保持 `18`、画布无新节点。说明提交前存在账户云存储配额校验,而不是前端固定延时。竞品画布中保留的历史 `视频一键去字幕-*` 结果证明该能力此前可成功,但本轮没有把历史结果冒充新任务结果。
- 竞品框选模式初始费用为 `-`,撤销、重做、重置和提交均禁用。首次拖拽后显示青色半透明矩形与 4 个方形角点,费用变为 `0`,撤销、重置和提交启用;可继续框选多个区域。实测右下角可自由缩放,拖动矩形内部可整体移动,二者都进入同一撤销栈,撤销后重做启用。
- 本地删除了旧的 `42s` 定时器和复制源 URL 的伪结果。智能模式在配置的下半屏扫描区抽取视频样本帧,通过灰度水平/垂直边缘密度、5 行平滑、均值与标准差自适应阈值合并最多 3 个连续文字带;框选模式严格使用用户归一化选区。两种模式都逐帧把邻近无字条带映射到目标区并按可配置半径模糊融合。
- 真实处理保持源像素尺寸和源音轨,使用 Canvas 视频轨与源音频轨组合成 MediaStream,经 MediaRecorder 输出 WebM,再通过 `canvas-video-process` 上传。Chrome 智能模式实测生成 `/uploads/local/1783899877445-t3i4sm.webm`,框选模式生成 `/uploads/local/1783900062727-3n1gq8.webm`;两者均为独立 URL、`720×1280`、画布时长 5 秒且可播放。框选结果播放画面可见选区被真实替换和模糊,不再与源视频像素内容相同。
- 本地智能浮动条提交图标改为与竞品一致的单向上箭头。框选区域支持创建、整体移动和 `nw/ne/se/sw` 四角缩放,角点改为方形;移动或缩放只在指针松开时写入一次历史,因此每次撤销对应一次完整手势,不会逐像素回退。浏览器实测创建、右下角放大、整体上移、撤销和重做状态全部通过。
- `video_canvas_function_config` 新增 `video-subtitle-erase` 功能定义和 `videoSubtitleErase` 处理配置;后台 `/settings/video-canvas` 可调整启停、模型标识、智能扫描起止、边缘阈值、最小文字带、扩边、模糊半径和输出帧率。模型名、处理规则和费用不再写死在前端节点创建逻辑。
- 新增 3 项字幕检测、参数钳制和选区移动/缩放自动化,并把字幕测试加入 helper 全集。双方临时视频与结果节点已删除,竞品恢复原画布,本地数据库恢复 `3` 个原始节点,积分保持 `18`;helper 全集 `200/200`、TypeScript、`git diff --check` 与生产构建均通过。
## 逐项竞品实测:视频音频分离(2026-07-13)
- 状态:入口层级、有无音轨禁用态、音视频分离、人声分离、结果节点命名/尺寸/落位、真实媒体处理、上传与播放均已完成;后续不要重复本组主流程。专业人声与伴奏模型质量仍属于后端缺口。
- 竞品一级菜单固定为 `人声分离 / 音视频分离`,悬停人声后展开 `仅保留人声 / 仅保留背景音`。选中两个经服务端 `ffprobe` 确认为只有 H.264 视频流的历史素材时,两项都是真正的 disabled,但菜单仍可打开;换用含 `H.264 + AAC 双声道` 的素材后,两项同时启用。
- 点击竞品 `音视频分离` 会立即提示 `已创建无声视频与音频节点并开始上传`,不扣积分;完成后同时生成 `视频41_音轨` 音频节点和 `视频41_无声` 视频节点。音频节点为 `350×148`,无声视频为 `350×350`,同一 Y 坐标放在源节点右侧,已有节点占位时会继续避让而不是重叠。
- 点击 `仅保留人声` 后没有即时结果或扣点,云端处理超过 30 秒后生成 `视频43_人声 / 视频43_无声`。竞品工程已有历史结果 `分镜视频-#1_背景音 / 分镜视频-#1_音轨 / 分镜视频-#1_人声 / *_无声`,说明三个分支都输出目标音频加一份无声视频,背景分支后缀固定为 `_背景音`。
- 本地旧实现只是等待 `6.4s`,音频节点复用演示 MP3,无声节点继续引用原视频 URL,并把两个结果放到相同坐标。本轮已全部移除:源文件通过受保护代理读取,音轨检测使用 ISO-BMFF `hdlr=soun` 或 WebM EBML `TrackType=2` 的结构化解析,不按文件名、扩展名或前端假状态猜测。
- 本地真实处理用 `HTMLVideoElement + Web Audio + Canvas + MediaRecorder` 同时录制独立 Opus 音频和仅含画面的 VP9 WebM。`音视频分离`直接导出原解码音轨;本地人声后备取双声道中心信号 `(L+R)/2`,背景音后备取侧信号 `(L-R)/2` 并反相到右声道。增益、算法标识和输出帧率均来自 `video_canvas_function_config.videoAudioSeparation`,管理员可在 `/settings/video-canvas` 调整。
- Chrome 以同一 AAC 素材回归:`音视频分离`约 9 秒完成并生成两个不同的 `/uploads/local/*.webm` 地址,音轨实际播放时间从 `0` 推进到 `1.09s`;`仅保留人声`生成 `_人声 / _无声`,人声音频实际播放推进到 `0.86s`。服务器 `ffprobe` 对无声结果只读到 `vp9|video`,没有音频流;再次选中该 `_无声` 节点时两项分离操作均禁用。
- 双方测试节点和本地上传临时文件均已删除,竞品与本地缩放恢复 `100%`,余额都保持 `18`,本地数据库恢复 `3` 个原始节点。新增 MP4/WebM 音轨结构解析、中心/侧声道算法、后缀和配置钳制测试;helper 全集 `206/206`、TypeScript、`git diff --check` 与生产构建均通过。
## 逐项竞品实测:视频帧截取(2026-07-13)
- 状态:快速截图、首帧、尾帧、当前帧、时间轴定位、真实解码、PNG 上传、节点命名/尺寸/落位、重复截取、连线和选择保持均已完成;后续不要重复本组主流程。
- 竞品视频卡片底部是 `播放 / 当前时间 / 可点击时间轴 / 总时长 / 50 / 截图图标`。鼠标悬停截图图标会在其上方显示 `截取首帧 / 截取尾帧 / 截取当前帧`;直接点击图标等价于截取当前帧,不只是打开菜单。时间轴中点实测把 5 秒视频从 `0:00` 定位到显示值 `0:02`。
- 竞品直接截图结果固定标题 `截图`、图片替代文本 `视频截图`;菜单首尾帧结果固定为 `首帧 / 视频首帧` 和 `尾帧 / 视频尾帧`,不会拼接源视频标题。三个分支都显示真实源像素 `1280 × 720`,不扣积分、没有成功 toast。
- 每次截取都新建独立图片节点并建立源视频到图片的连线,但源视频继续保持选中。结果节点与源节点同宽同高、同一 Y,X 方向固定为 `source.width + 100`;本次源和结果都是 `622×350`,屏幕 X 相差 `722px`。重复截图、首帧和尾帧会在同一坐标重叠,竞品不做避让或纵向堆叠。
- 竞品在 `0:00` 快速截图和显式首帧得到同一个上传 PNG 地址;尾帧得到不同地址。时间轴定位到 `0:02` 后再次快速截图,生成第二个 `截图` 节点和第三个不同地址,证明后端/浏览器按实际帧时间解码后上传,不是复用视频海报、OSS `t_0` 缩略图或前端固定预览。
- 本地旧实现把视频预览地址或 MP4 URL直接写进图片节点,导致图片加载失败;标题为 `源视频-首帧`,节点缩成 `420×236`,重复结果纵向错开,选中结果节点并弹出 `已截取*` toast。上述占位逻辑已全部移除。
- 本地现在通过受保护的视频代理读取源 Blob,`HTMLVideoElement` 解码后按 `0 / min(current,duration-0.05) / duration-0.05` 定位首帧、当前帧和尾帧,再按原始 `videoWidth×videoHeight` 绘制 Canvas、无损编码 PNG 并调用现有图片资产服务上传。结果记录真实时间、像素、存储路径、源节点和类型,随画布数据库持久化。
- Chrome 使用本地 `720×1280` 视频实测得到四个独立 PNG:`0:00` 快速截图与首帧 SHA-256 完全相同,尾帧和时间轴 `0:02` 截图分别为不同哈希;逐张实图确认不是黑帧或破图。四个节点均为 `560×315`、同 Y、右移 `660px`、各有一条连线,源视频保持选中且余额保持 `18`。
- 本地测试节点已经删除,数据库恢复 `3` 个原始节点和 `2` 条原始边,四个测试 PNG 已从本地存储清理。竞品恢复登录后,四个帧结果和测试源也已全部删除,原画布内容保留;本组无需再次执行。新增 2 项时间点和结果文案测试;helper 全集 `208/208`、TypeScript、`git diff --check` 与生产构建均通过。
## 逐项竞品实测:视频下载与全屏预览(2026-07-13)
- 状态:工具栏入口顺序、首次下载规则、去水印开关、保存后继续下载、后续直接下载、文件名、下载代理、黑色全视口预览、播放/进度/倍速/音量/播放器全屏、`ESC` 与关闭按钮均已完成;后续不要重复本组主流程。
- 竞品选中视频后,标准工具栏顺序固定为 `剪辑 / 裁剪 / 高清 / 解析 / 智能去字幕 / 音频分离 / 下载 / 预览`。首次点击下载弹出 `AI 生成内容水印管理规则`,默认 `去 AI 水印` 关闭,唯一命令是 `保存设置`;保存会关闭弹窗并继续原下载,第二次点击直接下载,不再重复弹窗。
- 竞品下载地址把源路径从 `/sd-gen-save-img/...` 改写为 `/wm/...`,并追加 `attname=视频37.mp4`,说明未开启去水印时由 CDN 水印出口即时返回同一媒体并通过查询参数控制文件名,而不是前端重新编码视频。本轮 Chrome 扩展拦截了该下载地址,但请求 URL、命名和弹窗状态机均已完整取得;下载本身不扣积分。
- 本地工具栏加入同序的下载和预览图标。视频下载通过受保护的 `/api/canvas/video-source` 代理拉取 Blob,以节点标题生成 `视频37.mp4` 并触发浏览器下载;首次保存规则后立即续传,第二次实测不弹窗且代理返回 `200`。音频仍走独立音频代理,图片下载行为不受影响。
- 实测发现竞品 CDN 对真实 MP4 错报 `Content-Type: text/plain`。本地代理现在仅在 URL 后缀属于允许的视频/音频扩展时接受 `text/plain` 或 `application/octet-stream`,并纠正为标准 MIME;普通 `.txt`、非 HTTPS、私网地址、错误媒体类型和超过 100 MiB 的响应仍被拒绝,避免为了兼容 CDN 放宽 SSRF 与类型边界。
- 竞品预览是应用内 fixed 黑色覆盖层,不会进入 `document.fullscreenElement`;`1512×861` 视口下视频区域为 `x=0 / y=56 / 1512×805`、`object-fit: contain`。底部播放器包含 `play / 0:00 / 0:05 / 1x / 音量 100 / 进度 / 播放器全屏`,右上角为 `关闭 (ESC)`;实际 `ESC` 和关闭按钮都返回画布并保持源视频选中。
- 本地预览尺寸与竞品测量值逐项一致,应用覆盖层同样不是浏览器原生全屏;播放实测从 `0` 推进到 `1.28s`,按钮切为 `pause`,结束时间为真实 `5.06195s`。`ESC` 与显式关闭均已回归,退出后工具栏仍显示,另将覆盖层提升到最高应用层级,避免开发悬浮控件抢占预览交互。
- 水印规则标题、欢迎语、申请说明、责任说明、链接文案、链接地址和确认说明已从前端硬编码移入 `video_canvas_function_config.videoDownload`;支持 `{{siteName}}` 自动替换本站品牌,管理员可在 `/settings/video-canvas` 的“视频下载与水印规则”卡片修改。浏览器只保存用户选择和首次确认状态,不再写死竞品品牌。
- 双方临时 `视频37` 均已删除;竞品保留原有三个分镜视频节点,本地数据库恢复 `3` 个原始节点和 `2` 条原始边。新增文件名、扩展名、配置归一化、品牌替换与 CDN 错误 MIME 兼容测试;helper 全集 `214/214`、TypeScript、`git diff --check`、Ultracite 与生产构建均通过。
## 逐项竞品实测:视频提示词工具(2026-07-10)
- 竞品“提示词优化”:在视频节点输入 `一个女孩在雨夜奔跑` 后点击参数栏首个图标,按钮立即 disabled 并显示旋转加载图标;请求结束后不覆盖原输入,而是打开 fixed 遮罩和 `640 × 400` 结果面板。`1512 × 805` 视口下外壳为 `x=436 y=202.5`,包含 `优化后的提示词 / 复制 / 关闭 / 重新生成 / 填充到输入框内`。
- 竞品优化结构:首个结果按“主体1/主体2定义 -> 主体关系和核心动作 -> 镜头1/2/3 -> 画风、色调、光影”扩写;点击 `重新生成` 后仍保留面板,但生成另一套主体细节和 4 镜头方案,说明后端是非确定性文本模型,不是前端模板拼接。
- 竞品确认规则:结果面板打开时原输入保持不变;只有点击 `填充到输入框内` 才替换节点提示词并关闭面板。填充后段落换行压成单段文本,无成功 toast,不新增节点、连线或视频任务。竞品测试结束前已恢复为 `以当前图为首帧生成视频。`。
- 竞品“翻译提示词”:自动检测语言方向,中文样本先得到 `A girl running on a rainy night`,英文再次点击恢复中文;相同中文再次翻译得到 `A girl runs in the rainy night.`,说明翻译同样是生成式调用。翻译直接替换输入框,不打开结果面板,无成功 toast。
- 本地优化对齐:新增 `/api/video-canvas/prompt-tools`,按钮使用独立 spinner;同句测试打开居中 `640 × 400` 模态,本地 `1200 × 672` 视口下为 `x=280 y=136`。重新生成期间保留旧结果并禁用操作,返回后结果变化;填充后 809 字结果立即写回可见 `contenteditable`,压成单段,模态关闭且节点数不变。
- 本地翻译对齐:中文得到 `A girl running in the rain at night`,再次点击得到 `一个女孩在夜晚雨中奔跑`;两次都是按钮级 spinner、直接替换、无成功 toast。
- 后端算法与配置:`video_canvas_function_config` 新增 `video-prompt-optimize` 和 `video-prompt-translate`,管理员可编辑启停、模型覆盖与系统提示词。优化规则在后端要求主体定义、主体关系、连续镜头、画风光影四层结构;翻译规则要求自动识别中英文并只返回反向目标语言译文。两项默认模型均配置并保存为已验证可用的 `qwen3-vl-plus`,数据库读取结果一致。
- 模型容错:首次真实请求暴露默认 Kimi 上游鉴权失败,提示词 API 现支持最多 3 个健康文本模型顺序回退;实测回退到 `qwen3-vl-plus` 后 `POST /api/video-canvas/prompt-tools 200`。后台明确指定 Qwen 后,翻译请求约 10 秒完成,不再先调用失效模型。
- 复制反馈边界已于 2026-07-12 完成:竞品点击 `复制优化后的提示词` 后结果面板保持打开,按钮只保留焦点,不显示 toast、成功文案或图标切换;本地原有 `已复制优化后的提示词` toast 已移除,剪贴板失败也静默处理,交互与竞品一致。后续不要重复本项。
- 超长输入边界已于 2026-07-12 完成:使用带 `START|...|END` 首尾标记的 `6,010` 字符样本,竞品和本地编辑器都完整保留首尾与全部字符,优化按钮保持可用;两边提交后均正常返回结果面板,没有输入过长、截断或校验提示。由此确认竞品不存在常见的 `2,000/5,000` 字前端裁剪,本地 API 的 `50,000` 字限制只作为防滥用安全上限,不影响实际体验。测试结束后两边均恢复 `以当前图为首帧生成视频。`,后续不要重复该长度样本。
- 多个 `@素材引用` 与结构化标记边界已于 2026-07-12 完成,后续不要重复本项。竞品在无参考连接时输入 `@` 显示 `暂无可引用资产,请连入后操作`;连接一张或多张图片后,菜单按连线顺序显示 `图片 1 (@1)`、`图片 2 (@2)`。引用写入不可编辑、可拖拽的 mention 芯片,关键属性为 `data-mention-type="mixed"`、`data-mention-index`、`data-mention-node-id`、`data-mention-media-type="image"`;画面标记写入带 `data-magic-id` / `data-magic-index` 的不可编辑芯片。
- 竞品结构化优化规则:结果面板打开时,两个引用芯片和画面标记仍保留在原输入框;优化结果会吸收引用图片和标记对象的语义,但不保证逐字输出芯片标签。点击 `填充到输入框内` 后,引用芯片和标记芯片全部移除,只保留扁平化后的优化文本;参考缩略图和节点连线不移除。
- 竞品结构化翻译规则:翻译直接替换芯片之间的自然语言,多个引用芯片和画面标记均保持原顺序和原位置,不扁平化、不删除。本地已按同一步骤完成 Chrome 回归:输入 `图片 1`、`图片 2` 和 `人物面部` 后翻译为英文,三个结构芯片仍原位保留;优化结果面板打开时也保留三个芯片,填充后芯片清空而 6 个参考缩略图仍在。
- 本地提示词工具现在使用稳定的结构协议传递上下文:请求前把引用和标记注入 `[[REF:id]]` / `[[MARK:id]]`,并把引用图片序号、节点 ID、媒体类型及标记标签作为结构化数据发送给后台;后台提示词要求模型原样保留协议标记。翻译响应解析标记并返回 mention 偏移和保留的标记 ID,优化填充则有意扁平化。提示词正文、模型及开关继续来自 `video_canvas_function_config`,没有把竞品文案或模型硬编码进前端。
- 标记链路同步修复:服务端在缩放后的图片上按点击坐标真实绘制红色圆圈与十字线,以 PNG data URL 送入视觉模型,避免模型无法访问本地储存 URL;模型和提示词读取后台 `element-mark` 配置,并支持 `{{positionOptions}}` 占位符。修复前 Qwen 返回 `InvalidParameter: The provided URL does not appear to be valid`,修复后同一图片点击稳定返回 `人物面部`。结构协议定向测试 `3/3`、全量 helper 测试 `159/159`、TypeScript、Ultracite 与 `git diff --check` 均通过。
## 逐项竞品实测:视频参考节点(2026-07-10,2026-07-13 补充)
- 状态:Seedance 2.0 与 Seedance1.5 Pro 的进入、连续选择、重复选择取消、模型上限、退出、返回节点、附件预览、附件移除、连线和模式切换均已完成;双方测试节点已清理。本小功能后续不要重复测试主流程,只在出现回归时复测。未执行竞品付费视频生成,最终生成请求和成片质量仍属于独立边界。
- 共同入口:点击视频节点的 `参考` 不打开素材弹窗,而是进入全画布拾取模式;目标视频节点出现蓝色描边,视口上方工具条严格为 `从画布选择参考 / 返回节点 / 退出`。`退出` 只结束拾取,`返回节点` 把视口带回目标节点。已选图片显示 `取消选择`,再次点击会取消;被其他节点或编辑器遮挡的图片不会穿透命中。
- Seedance 2.0 VIP:选择后保持拾取模式,模式为 `全能参考`;按点击顺序创建编号附件和一条图片到视频的有向边。精确上限为 9 张,第 10 张会静默忽略,不新增附件、序号、连线或 toast,`参考` 入口在达到上限后仍保留。
- Seedance1.5 Pro:精确上限为 1 张,选择后立即退出拾取,模式从 `文生视频` 切为 `图生视频`,`参考` 入口隐藏。快捷操作由 `参考 / 标记 / 特效 / 角色库 / 运镜` 变为 `标记 / 角色库 / 运镜`,即保留角色库、隐藏特效;模型按钮显示完整名称 `Seedance1.5 Pro`。
- 单个附件占位为 `48 × 48`,图片本体约 `47 × 47`,左上角显示从 1 开始的序号,并与一条来源图片到目标视频的有向边一一对应。点击附件显示约 `160 × 80` 的悬浮预览,右上角提供移除按钮;从附件或图片节点移除都会同步删边,最后一个参考移除后恢复 `文生视频` 和普通快捷入口。
- 竞品刷新后完整恢复参考附件、连线、模式和模型。其前端行为表明后端收到的是按选择顺序排列的结构化参考图片数组和模型模式,而不是把图片引用展开成可见提示词;Seedance 2.0 对应 `reference-images`,Seedance1.5 Pro 对应 `first-frame`。这是根据 UI 状态和处理结果做出的推断,未抓取或绕过竞品私有接口。
- 本地已按同一步骤回归:Seedance1.5 Pro 选 1 张后工具条立即消失、参考入口隐藏、模式为 `图生视频`,快捷操作只剩 `标记 / 角色库 / 运镜`;Seedance 2.0 保持 `全能参考` 并支持连续选择。节点数据同时持久化 `params.mode` 与 `settings.mode`,参考顺序由画布边保存,模型切换时按目标规则裁剪超额边。
- 规则已进入 `video_canvas_function_config.videoReferenceSelection`:默认规则为 `reference-images / 9 张 / 不自动退出`,`Seedance1.5 Pro` 覆盖为 `first-frame / 1 张 / 自动退出 / 达上限隐藏入口`。后台“视频参考素材规则”支持编辑默认规则、模型匹配规则、上限、模式、自动退出和入口隐藏;旧数据库没有该字段时由规范化层补齐默认值。
- 生成链路按相同模型规则保序截取参考图片,API 再读取后台配置复核上限;真正构建模型请求时使用所选模型的 `maxReferenceImages` 能力,不再固定截断为 4 张。Seedance 2.0 能力目录为 9 张,能力较低的实际后端会在付费提交前明确报错而不是静默丢图。
- 参考边切换、模型规则解析、9 张静默上限和配置规范化均有定向测试;本地刷新测试曾在约 500ms 的自动保存窗口内提前重载,因此临时节点未落库并自然回到干净基线,不把该次操作误记为刷新持久化实测。
## 逐项竞品实测:3D 导演台机位与截图闭环(2026-07-10)
- 竞品导演台是全屏真实 3D 编辑器。顶栏为 `3D导演台 / 导演视角 / 机位视角 / 帮助 / 关闭`;左侧是可搜索的场景树和逐项显隐/锁定,中央为 WebGL 视口、坐标轴和悬浮工具条,右侧根据当前选择切换 `3D场景 / 摄像机 / 角色 / 道具` 属性面板。
- 竞品当前项目的导演视角保留 `9:16` 竖幅框,场景缩放为 `300%`,天空色 `#060608`,全景球半径 `60`。切换 `机位视角` 会自动选择 `机位1` 并将右侧切到摄像机属性,而不是只改变顶栏按钮状态。
- 竞品 `机位1` 实测参数为 FOV `50`、位置 `[0, 2.2, 10]`、不跟随、旋转 `[5.71, 180, 0]`、手动注视 `[0, 1.2, 0]`。中央视口同步使用该机位构图,竖幅框仍保留,角色在画面中的尺寸随机位参数变化。
- 本地 StoryAI 机位模式已验证会选中活动机位、切换摄像机属性面板并使用活动 PerspectiveCamera 渲染。初始画幅现由后台 `directorDesk.defaultAspectRatio` 下发,默认 `9:16`;用户在导演台内切换后写入节点的 `directorProject.viewportAspectRatio`,项目值优先于后台默认并随数据库、跨浏览器和环境复制恢复。机位坐标仍来自工程或可配置机位模板,没有把竞品当前工程坐标硬编码为全局默认。
- 竞品点击底部 `截图` 后按钮立即 disabled,文案和通知变为 `正在上传截图...`;约 4 秒后生成 `机位1-shot-01` 卡片,卡片提供 `删除 / 发送到画布 / 全屏查看`。这说明截图在生成卡片时已经上传和持久化,不是等发送到画布时才上传。
- 竞品点击 `发送机位1-shot-01到画布` 后创建导演台到图片节点的有向边,并显示 `截图已添加到画布`;截图卡片仍保留,可再次使用。节点标题沿用 `导演台 12 机位1-shot-01`,推断后端以机位为作用域维护递增 shot 序号。
- 状态:截图上传、总览、重复发送、删除撤销、远端延迟清理和数据库持久化已于 2026-07-12 完成,后续不要重复测试本组主流程。
- 状态:截图预览对话框已于 2026-07-12 完成逐项对比和本地回归,后续不要重复测试打开、关闭与图片适配流程。竞品不是带缩放/下载工具栏的黑色全屏灯箱,而是透明全屏交互层中的深色预览框:宽度 `min(760px, 100vw - 64px)`、最大高度 `82vh`、圆角 `16px`、标题栏 `44px`、内容边距 `16px`,图片 `object-contain`、最大高度 `calc(82vh - 76px)`、圆角 `12px`,右上角只有 `28px` 的 `关闭截图预览` 按钮。点击图片保持打开,点击框外、关闭按钮或按 `Esc` 关闭;没有滚轮缩放、拖拽、放大/缩小或下载入口。
- 本地已移除原先多出的四按钮全屏查看器并复刻上述结构。Chrome 在 `1512×861` 视口实测:交互层为 `1512×861 @ (0,0)`,预览框宽 `760px`、最大高 `706.02px`,标题栏高 `44px`、关闭按钮 `28×28px`;横幅截图按原始比例收缩为 `726×379.8px`,三种关闭入口和图片点击阻止关闭均通过。组件测试 `20/20`、TypeScript 与生产构建通过。
- 竞品 `摄像机截图` 总览只显示有截图的机位,按机位顺序分组;标题为 `机位1截图`,卡片显示 `机位1-截图01`,操作辅助名称为 `删除机位1-截图01 / 发送机位1-截图01到画布 / 全屏查看机位1-截图01`。底部固定为 `全部清空 / 发送到画布`;清空前弹出 `确认清空所有截图?`,操作为 `取消 / 确认`。
- 竞品同一截图可以重复发送,每次都创建新的同标题图片节点;关闭并重开导演台后截图仍在。删除立即生效但保留机位,`Cmd+Z` 可恢复;删除后重新截图复用当前数组长度加一的序号,清空的机位组从总览隐藏。
- 本地宿主桥现于截图创建时立即发送 `storyai:director-desk-capture-created`,宿主显示 `正在上传截图...`,通过现有存储服务归档 PNG 后回传 `storyai:director-desk-capture-persisted`。工程快照只保存可移植 URL、`assetId` 和 `storagePathname`,不把 data URL 写进数据库。
- 慢上传回归曾暴露会话竞态:过滤掉临时 data URL 的项目快照会触发宿主重新发送完整 session,提前抹掉待确认截图。宿主现只在 iframe 首次加载/ready 时注入项目,运行中项目改为单向增量同步;真实上传回退耗时约 4 秒时截图卡片仍稳定保留。
- 删除截图会先从工程移除并向宿主发送删除事件,物理文件延迟 30 秒删除;期间项目 undo 恢复同一截图会取消删除计时器。远端删除接口同时校验登录、画布所有权,并支持本地文件、Vercel Blob、S3 和 MediaAsset 清理。
- 本地 Chrome 完整回归:创建 `机位2-shot-01` 时上传成功并出现 1 张真实图片;总览精确显示 `机位2截图 / 机位2-截图01`、三项操作和两个底部按钮;清空确认的三段文案全部唯一存在。连续点击同一卡片发送两次后外层画布出现两个 `机位2-shot-01` 节点。等待自动保存并完整刷新页面后,`导演台 1`、两个图片节点、机位和截图卡片均从数据库恢复;删除后卡片数 `1 -> 0`,一次 `Cmd+Z` 恢复为 `1`。
- 本地首次回写还暴露 CDN 刚完成上传时 `<img>` 首次加载失败后不再重试,节点显示纯白。媒体图片节点现加入最多 4 次指数退避和缓存破除重试;浏览器回归后 `naturalWidth=640`、`naturalHeight=1138`,画布正确显示人物竖幅截图。
## 逐项竞品实测:3D 导演台全景图(2026-07-10)
- 竞品底部全景图入口是三项菜单:`本地上传 / 历史记录 / AI生成`,不是单个文件选择按钮。本地上传实测只接受 PNG;JPG 在 Chrome 原生选择器中不可提交。
- 竞品上传 PNG 后立即显示 `正在上传全景图...`,约 3.5 秒后在外层画布创建 `codex-panorama-source` 图片节点,并创建“图片节点 -> 导演节点”有向边;导演台不会关闭。因此全景上传的业务所有者是外层画布/存储服务,iframe 只负责选文件和显示场景。
- 竞品连接后的场景面板显示 `已连接全景图`、哈希化文件名、`取消链接`,以及天空色 `#060608`、水平旋转 `0°`、球形半径 `60`。实测水平旋转 `0 -> 45` 和半径 `60 -> 80` 均即时生效,各自一次 `Cmd+Z` 恢复原值。
- 竞品 `AI生成` 面板为 `上传图片 / 退出不会中断生成,可在历史记录中查看结果 / 生成 / 22`。PNG 选中后先显示预览,约 3 秒预处理后生成按钮才可用。未点击生成,因为该动作会消耗 22 积分,需在操作前单独确认。
- 本地 StoryAI 已改为同款三入口菜单。iframe 通过宿主桥提交上传、历史使用和 AI 请求;宿主上传后创建外层图片节点,替换该导演节点现有图片入边,再把全景 URL 回传 iframe。
- 本地 Chrome 使用同一 PNG 实测通过:外层生成 `codex-panorama-source` 节点,`.react-flow__edge` 数量为 `1`;右侧显示 `已连接全景图 / 取消链接`;历史记录中该条显示 `使用中`;AI 面板上传后约 3 秒将 `生成 22` 从 disabled 切换为可用。
- 全景业务值均从 `video_canvas_function_config` 下发:文件格式、AI 费用显示、预处理毫秒、历史上限、输出比例/尺寸,以及 `director-panorama-ai` 的模型和系统提示词。开发环境存储默认落到 `public/uploads/local`,生产环境仍使用数据库 S3/备份存储/Vercel Blob 链路。
- 全景历史与取消链接边界已完成(2026-07-12):竞品全景菜单的 `历史记录` 复用全局 `历史资产` 弹窗,而不是全景专用小列表;弹窗包含 `图片历史(8) / 视频历史(10) / 音频历史(6)`、`2026-07-09` 日期分组、时间排序、批量操作,以及每项的 `查看 / 使用 / 下载`。点击第一张图片的 `使用` 后弹窗关闭,右侧全景立即切换到该资产。
- 竞品历史图片使用后画布边数为 `4`;点击 `取消链接` 后变为 `3`,源图片节点仍存在,说明历史使用新增/切换的是图片到导演台的输入边,取消只删除当前输入边,不删除资产。取消后的空状态仍保留小标题 `已连接全景图`,下方显示虚线投放框 `请将图片节点连接到导演台左侧输入口`。
- 本地已将 StoryAI 小型历史弹窗改为宿主桥请求,集成模式直接复用现有 `HistoryAssetsPanel`,独立运行仍保留原弹窗兜底;补齐音频历史的 6 条默认记录。Chrome 同步实测为 `8 / 10 / 6`、同日期分组,图片 `使用` 后弹窗关闭并连接全景;取消后边数 `3 -> 2`,源截图节点保留,空状态标题、说明和虚线框与竞品一致。本小功能已完成,后续不要重复历史面板、使用图片或取消链接主流程;仅付费 AI 生成完整任务仍等待积分消耗确认。
## 逐项竞品实测:3D 导演台变换与截图入口(2026-07-10)
- 竞品底部变换入口不是三个并排按钮,而是一个跟随当前模式变化的触发器;实测菜单固定为 `移动 V / 旋转 R / 缩放 S`。进入角色“属性”页后模式会回到 `移动 (V)`。角色属性顺序为名称、位置 XYZ、旋转 XYZ、缩放 XYZ、统一缩放和颜色;当前测试角色数值为位置 `[0.69, 0, 0]`、旋转 `[0, 0, 0]`、缩放 `[1.03, 1.03, 1.03]`、统一缩放 `1.0`、颜色 `#34C759`。
- 本地原先是 `移动 / 旋转 / 缩放` 三个独立按钮,角色属性页也不会重置模式。本轮已改为单触发器和同款三项菜单,补齐未带修饰键的 `V/R/S` 快捷键;输入框、文本域、选择框和可编辑区域聚焦时不会抢键。角色“属性”标签会把变换模式切回移动。
- 竞品在导演视角点击唯一截图按钮时,会先把当前自由视角物化为新摄像机,再选中该机位并立即上传截图。本轮实测生成 `机位5` 和 `机位5-shot-01`;新机位参数为位置 `[0, 3.19, 19.9]`、旋转 `[5.71, 180, 0]`、注视 `[0, 1.2, 0]`、FOV `50`。因此后端作用域应是“机位 ID + 递增 shot 序号”,导演视角截图只是先创建机位后复用同一截图流程。
- 本地 `当前视角截图` 的核心算法已一致:导演视角先调用 `addCameraShot(getViewportCameraSnapshot())`,切到机位视角,再对新机位截图。本轮浏览器实测从导演视角生成 `机位02` 与 `机位02-截图01`。
- 竞品当前工具栏没有四方位或十二方位按钮。StoryAI 扩展能力实测为:四方位在同一半径和极角上按 `2π × i/4` 旋转方位角,十二方位按 `2π × i/12`,批量结束后恢复原机位;本轮分别生成 4 张与 12 张,累计截图序号到 `机位02-截图17`。
- 为保持竞品默认体验,同时保留扩展能力,`video_canvas_function_config.directorDesk.showBatchCaptureControls` 已做成后台开关并默认 `false`。宿主向 iframe 下发 `batchCapture=0/1`;Chrome 实测默认两个批量按钮均不存在,参数置 `1` 后各出现一次。
- iframe 黑屏根因已通过 Chrome 定位:上游 Vite 曾把 JS/CSS 写为站点根路径 `/assets/...`,嵌入目录因此只加载空的 `#root`。上游构建基址已改为相对路径 `./`,宿主同时增加 ready 消息门控、首次 10 秒自动重载和可见“重新加载”兜底。静态包替换后浏览器还可能复用旧入口并请求已删除的哈希资源,宿主现为每次打开生成唯一 `frameSession` 查询参数;Chrome 关闭后重开实测首次即为 `opacity-100`,无需进入 reload 重试。地址同时包含 `batchCapture=0`,WebGL 角色、网格、场景树和属性面板均正常显示。
- 变换 gizmo 精确拖动边界已完成(2026-07-12):旧模型阶段点击竞品 `重置视角` 后,角色 C 的正视图高度曾约为 130px;沿红色 X 轴手柄向左拖动 40px,位置 X 从 `0.69` 变为 `0.21`,一次 `Cmd+Z` 精确恢复为 `0.69`,说明一次连续 pointer drag 只生成一个 undo 事务。当前服务器模型的像素尺寸已在下方“角色姿势”一节重新校准,不再把旧 130px 当作现行模型基线。
- 本地未重置的自由视角中,同路径 40px 初测为 `0 -> -0.1598675`,一次撤销恢复 `0`。现已在原生坐标控件下补齐同款 `重置视角`,并按当前场景缩放倍率同步缩放默认镜头位置和注视点;同路径复测为 `0 -> -0.4796025`,与竞品 `-0.48` 的差值约 `0.0004`,一次撤销恢复 `0`。后续模型尺寸校准只调整 GLB 资产内部倍率,并把镜头位置和注视点共同上移,镜头距离、俯角及该拖动灵敏度保持不变。
- 快捷键输入边界已完成(2026-07-12):竞品保持 `移动 (V)` 时聚焦场景搜索框输入 `rvs`,三个字符完整写入,变换模式没有切换;清空并移出输入框后可正常用 `R` 切到旋转、`V` 恢复移动。本地按相同步骤得到 `value="rvs"` 且始终为 `移动 (V)`,失焦后 `R/V` 正常切换。
- 本地快捷键监听除输入框、文本域、下拉框和 contenteditable 外,现额外忽略标准组合输入事件 `event.isComposing`,并兼容旧浏览器/输入法的 `keyCode === 229`。新增“文本编辑中忽略”和“IME 组合态忽略”定向测试 `2/2` 通过。系统候选窗不通过自动输入强行模拟,避免改变整机输入源;组合事件本身由浏览器标准字段测试覆盖。
- 本组全部完成,后续不要重复做 X 轴 40px 灵敏度、重置视角、拖动 undo、文本输入或 IME 快捷键测试。截图上传/删除的数据库持久化已在“机位与截图闭环”一节完成。
## 逐项竞品实测:3D 导演台场景设置(2026-07-10)
- 竞品场景开关顺序和默认值为:`角色标签 on / 网格吸附 off / 高斯地面吸附 on / 地面 on`。四项均为即时更新;关闭地面后,透明度和高度控件在面板重排完成后消失,重新开启后恢复原数值。
- 竞品地面默认透明度 `0.40`、高度 `0.0`。实测可输入透明度 `0.65` 和高度 `1.2`,滑杆与文本值同步;测试结束已手动恢复默认值。
- 竞品场景缩放显示为百分比,但编辑语义是倍率:默认输入显示 `300%`、滑杆值为 `3`;全选后键入 `2.5` 得到 `250%`。键入 `250` 会钳制为倍率 `10`、显示 `1000%`,由此确认范围上限为 `1000%`。测试结束已恢复 `300%`。
- 本地 StoryAI 新增持久化字段 `snapToGaussianGround`,默认开启;旧 localStorage 工程迁移时自动补齐为 `true`。开关按竞品顺序插入,四项使用窄侧栏稳定的两列布局,Chrome 已验证 `on -> off -> on` 和默认恢复。
- 本地场景缩放现使用与竞品相同的双表示:滑杆保存倍率 `0.1..10`,文本框显示百分比;聚焦编辑时输入 `2.5`,提交后显示 `250%`,默认 `3` 在滑杆中处于 30% 位置而不是顶到最右端。
- 该轮测试时 StoryAI 还没有 Gaussian Splat 加载器,因此只先落了真实开关。此限制已在下方“高斯泼溅”专项中解除:现有真实解析、渲染、射线命中和角色落地补偿,不再是占位状态。
## 逐项竞品实测:3D 导演台高斯泼溅(2026-07-12)
- 状态:入口、格式、单文件语义、本地解析/上传/渲染/错误反馈和高斯地面射线已完成;竞品成功导入后的对象初始参数仍待一次人工文件选择确认,后续不要重复检查入口格式。
- 竞品 `添加角色 -> 高斯泼溅` 会直接打开独立文件选择器,不是模型库。文件选择器为单文件,精确接受 `.ply,.splat,.spz,.ksplat,application/octet-stream`;普通模型入口仍只接受 GLB/GLTF/OBJ/FBX/STL/DAE,两条导入链路彼此独立。
- Chrome 扩展安全层禁止向竞品和本地页面自动注入任意磁盘文件,`fileChooser.setFiles` 均返回禁止,因此没有伪造“竞品成功导入”结果,也没有改动竞品工程。真实样本选取这一步需要人在原生选择器中确认。
- 本地采用 `@sparkjsdev/spark@2.1.0`,与竞品暴露的四种格式集合一致。导入前先用 `PackedSplats` 真实解析并校验点数,随后以 `purpose=director-gaussian` 上传原文件;失败显示明确错误且不写入资产或对象,成功后创建 `sourceType=gaussian` 资产和可变换道具对象。
- 主项目上传接口按后台 `directorDesk.gaussian.maxFileSizeMb` 校验大小,只允许四种扩展,跳过图片解码/HEIC/Sharp 优化。匿名本地测试只在 `localhost / 127.0.0.1 / ::1` 且未启用远程存储时放行;公网请求仍要求会话。
- 真实样本 `legobrick.splat` 为 3,297,920 字节,SHA-256 为 `d5131a664a12a8764da70552c85f567d276313110f63f1efd48424845917899e`。本地接口实传返回 `200`,文件原尺寸落到 `public/uploads/local/*.splat`,响应包含同源 URL 和 pathname。
- 首次浏览器截图暴露 Spark 只创建 `SplatMesh` 但未挂载 `SparkRenderer`,对象树存在而画布无点云。修复后每个含高斯资产的场景挂载一个 SparkRenderer;加载完成按包围盒统一缩放到 2.5 场景单位、水平居中并贴地,避免不同训练坐标系的文件落在镜头外。
- 桌面 Chrome 用数据库恢复等价的宿主工程快照加载上述落盘 URL,场景树显示 `legobrick`,加载提示消失,992×736 视口截图为 40,114 字节并出现明确点云像素;主 WebGL 画布为 3024×1470,坐标控件画布为 160×160。390×844 复测下点云和底部工具栏完整可见,小屏会隐藏左右属性栏以保证画布宽度。
- Spark SplatMesh 开启 `raycastable`,射线透明度阈值由后台 `minRaycastOpacity` 下发;角色完成移动时若“高斯地面吸附”开启,会从角色 X/Z 上方向下取最近高斯交点并回写 Y。该行为是几何射线算法,不需要模型调用或提示词。
- 定向自动化共 `49/49` 通过,覆盖竞品格式契约、单文件入口、解析前扩展名/大小校验、上传目的、成功入场景、失败不污染项目和宿主配置钳制;StoryAI TypeScript 与生产构建通过。
## 逐项竞品实测:3D 导演台场景树(2026-07-10)
- 竞品场景树为无分组标题的扁平列表,当前工程顺序是 `道具1..7 -> 机位1..4 -> 角色B/C`。点击 `角色B` 后保持行选中并打开角色属性面板;清空或改变搜索词不会取消该选择。
- 竞品搜索使用对象名称子串过滤。输入 `角色` 后只保留 `角色B/角色C`;输入 `不存在` 后列表区域仅显示 `没有匹配的场景对象`,没有空状态图标;测试结束已清空搜索词。
- 竞品可见对象的行尾操作为 `隐藏`,点击后原行和右侧属性面板都保留,按钮改为 `显示`;一次 `Cmd+Z` 恢复为可见。未锁定对象的操作为 `锁定`,点击后改为 `解锁`,右侧位置/旋转/缩放输入仍可编辑;一次 `Cmd+Z` 恢复未锁定。
- 本地原实现把对象拆成 `角色 / 群众 / 几何体 / 我的模型 / 摄像机` 可见分组,并使用带图标的居中空状态 `未搜索到内容`。现已改为与竞品一致的扁平排序:道具和模型、机位、角色和群众;群众展开、多选及成员预览能力仍保留。
- 本地 Chrome 回归已验证默认列表为 `机位01 -> 角色01`,不存在角色/摄像机分组标题;搜索 `不存在` 显示 `没有匹配的场景对象`;角色操作依次为 `隐藏 -> 显示 -> Cmd+Z -> 隐藏` 和 `锁定 -> 解锁 -> Cmd+Z -> 锁定`,锁定期间属性输入保持 enabled。
- 锁定对象的视口边界已完成(2026-07-12):竞品选中角色 C 后锁定,变换 gizmo 立即消失,但列表选中态、脚底选择环和属性面板保留;角色坐标保持 `0.69 / 0 / 0`。随后先选角色 B、再点击视口中的锁定角色 C,竞品不会选中 C,而是清空所有对象选择并回到 `3D场景` 面板。
- 竞品从列表重新选中锁定角色 C 后,在原 gizmo 水平手柄区域拖动,角色坐标仍为 `0.69 / 0 / 0`;拖动落到场景命中层并清空选择。测试结束已将角色 C 恢复为未锁定,坐标未改变。
- 本地旧行为会从视口选中锁定角色,现已统一处理角色、道具、导入模型和摄像机:锁定对象仍可从场景树选中并编辑属性,但不挂载 TransformControls;视口点击锁定对象会清空选择。导演视角 Chrome 对照验证为 `先选机位1 -> 点击锁定角色01 -> 3D场景`,原 gizmo 区域拖动前后位置均为 `0 / 0 / 0`。新增角色/摄像机锁定命中定向测试 `3/3` 通过,TypeScript 和生产构建通过;测试后角色01已恢复未锁定。
- 本小功能已全部完成,后续不要重复搜索、显隐、锁定撤销、锁定对象视口点选和 gizmo 拖动测试。
## 逐项竞品实测:3D 导演台角色姿势(2026-07-10)
- 竞品角色“姿势”页实测有 20 套预设:`站立 / T型 / 行走 / 跑步 / 坐姿 / 蹲下 / 单膝跪 / 双膝跪 / 叉腰 / 倚靠 / 鞠躬 / 思考 / 格斗 / 踢球 / 投掷 / 推进 / 招手 / 伸手 / 抱臂 / 看手机`。可见调节项共 25 个,分为身体 3、躯干 3、头部 3、左右肩各 3、左右肘各 1、左右髋各 3、左右膝各 1。
- 本轮在 Chrome 中逐个点击全部 20 套预设,读取每套 25 个角度值,并在每次读取后立即 `Cmd+Z`;每次都将 25 个值与点击前快照比较,全部完整恢复,说明竞品把一次预设应用作为单个 undo 事务。
- 竞品预设不是左右对称的简单模板。例如 `站立` 包含躯干前倾 `2`、头部点头 `-10`、左右肩前举 `-5/-5`、外展 `12/14`、双肘 `15/15`;`T型` 的左右肩 `前举/外展/扭转` 分别是 `16/60/40` 与 `22/54/41`,双肘为 `0/0`。这种微小不对称是姿势自然感的重要来源。
- 本地旧 `T型` 是左右肩外展 `-70/70`、前举 `15/15`、双肘 `10/10`,与竞品骨骼坐标明显不同。现已将完整采集结果写入 [director-pose-presets.ts](../lib/video-canvas/director-pose-presets.ts),作为 `video_canvas_function_config.directorDesk.posePresets` 的服务端默认值。
- 姿势数据没有写死在 iframe UI。宿主通过 `storyai:director-desk-session` 下发 `posePresets`,StoryAI 运行时 store 更新预设列表,单角色和群众姿势都从运行时配置取值;undo、切换导演节点和恢复场景快照时会保留该运行时配置。后台 3D 导演台区域新增“角色姿势预设 JSON”编辑器,应用时解析、去重、限制数量并把角度钳制到 `-360..360`。
- 本地 Chrome 回归:20 个配置化入口全部唯一存在;点击 `T型` 后读取到左肩 `16/60/40`、右肩 `22/54/41`、双肘 `0/0`、身体/躯干/头部/髋/膝为 `0`,与竞品采集值逐项一致;一次 `Cmd+Z` 恢复测试前的肩部零值。
- `蹲下` 隐藏骨骼边界已完成(2026-07-12):竞品角色 C 在 `9:16`、场景缩放 `300%`、点击 `重置视角` 后,轮廓为双腿分离的前倾蹲姿,双手落在髋/膝附近;本地旧实现会把双腿折向同侧,并把双手反折到头部。对 GLB 的父子骨位移和正负旋转逐轴验证后,已将左右肩、左右髋的“外展”改为同一正值表示向身体外侧,将肘部弯曲映射为局部 `+Z`、膝部弯曲保留局部 `-Z`,并让大腿的组合前抬/外展/扭转按 `ZYX` 应用,避免前抬接近 90 度时两腿在屏幕中心塌缩。
- UE4 素体仍在每次预设应用后按变形网格最低点自动回正到地面,因此 `蹲下` 不需要伪造界面未暴露的根节点高度;骨骼映射与姿势应用定向测试 `19/19` 通过,Chrome 复测确认双腿分离、双手回到身体两侧且双脚保持落地。本小功能已完成,后续不要重复采集 20 套可见角度,也不要重复 `蹲下` 的肩/髋/肘/膝轴向和落地测试。
- 模型物理尺寸 / 重置视角基线已完成(2026-07-12):原始 UE4 GLB 的站立边界为约 `0.96 × 1.82` 世界单位,直接以倍率 `1` 渲染会明显小于竞品。根据同一 Chrome 窗口、`9:16`、场景缩放 `300%` 的清洁轮廓,资产层现采用 `[1.92, 2.24, 1.92]` 非等比基准缩放;青少年、儿童和 Q 版仍乘原有 `0.88 / 0.72 / 0.56` 相对倍率,角色属性中的位置、旋转、缩放仍保持 `0 / 0 / 1`。
- 同一重置视角下最终像素复测:竞品/本地站立分别约 `146×354 / 153×355px`,蹲下分别约 `172×298 / 163×298px`;两种姿势高度差为 `1px / 0px`,横向轮廓差约 5%。为适配该 GLB 的关节响应,髋部前抬和膝部弯曲按可见角度的 `2/3` 映射,蹲下/站立高度比由约 `0.73` 调整到 `0.84`;导演镜头位置和注视点同时上移 `0.25` 个基础单位,保持距离和俯角不变并让站立脚底与竞品相差约 2px。蹲下中心仍高约 17px,来自两套网格腿部深度不同;没有用脚底穿地的根节点下沉伪造匹配。
- 本小功能已完成,后续不要重复站立/蹲下的模型尺寸、画幅排除和重置视角像素测量。
- `单膝跪` 隐藏骨骼与构图补偿已完成(2026-07-12):本轮没有重采 20 套可见角度,只在角色已选中、`9:16`、场景缩放 `300%` 下各应用一次竞品/本地单膝跪。竞品为一腿前伸、一腿后跪的近景;本地直接使用可见角时会把小腿折进景深,在屏幕上近似站立。对真实 GLB 髋、膝、踝骨点做相机投影搜索后,宿主预设保留竞品可见值,同时下发隐藏的 `left/rightHip.rigPitch=70/15`、`left/rightKnee.rigBend=40/-90`、`left/rightFoot.pitch=20/60`。StoryAI 运行时优先使用 rig 校准值,没有时继续使用可见值和默认 `2/3` 下肢倍率,因此 iframe 没有按预设标签硬编码逻辑,后台姿势 JSON 可继续调整。
- 竞品选中角色后重置视角会按当前姿势中心调整垂直构图。单膝跪新增配置化 `body.cameraTargetOffsetY=0.56`,重置时把镜头位置和注视点同量平移,距离、俯角和角色尺寸不变。最终绿色/蓝色主体轮廓约为 `161×322 / 162×325px`,左上位置只差 `1px`,脚底只差 `4px`;相关姿势、骨骼和重置算法定向测试 `29/29` 通过。本小功能已完成,后续不要重复单膝跪的可见角度、隐藏 rig 角、脚腕或选中角色构图测试。
- `双膝跪` 隐藏落地与尺寸补偿已完成(2026-07-12):竞品保持直立躯干并让双膝成为最低支撑,本地只使用可见膝角 `108/104` 时仍接近站立。宿主预设保留全部竞品可见值,新增隐藏 `left/rightKnee.rigBend=126/126` 与 `left/rightFoot.pitch=-20/-20`;UE4 自动落地随后按变形网格最低点把膝部对齐地面。跪姿模型仍比竞品大约 20%,因此新增通用配置字段 `body.modelScale`,默认 `1`、双膝跪取 `0.83`;缩放围绕已落地的模型原点执行,角色属性面板缩放仍显示 `1`,其他姿势不受影响。
- 选中角色、`9:16`、场景缩放 `300%`、重置视角下最终主体轮廓:竞品约 `116×209px`,本地约 `116×210px`;左上位置差约 `4px/1px`,膝部最低点差约 `2px`。姿势缩放、骨骼和落地定向测试 `27/27` 通过。本小功能已完成,后续不要重复双膝跪的可见角度、隐藏膝/脚腕、姿势缩放或轮廓测量。
- 未选中整场景 `重置视角` 已完成(2026-07-12):竞品关闭角色属性面板、重新打开导演台并在无选择状态点击 `重置视角` 后,会退到同时容纳角色和相机对象的远景;选中角色时则继续使用按姿势校准的近景。两条路径相互独立,清空选择不会沿用上一个角色的近景构图。
- 本地无选择路径现按全部可见对象估算世界包围范围:角色使用当前落地身高和姿势缩放,相机及其他对象使用各自类型边界,再以包围盒中心为注视点、半对角线为半径,结合 `50°` 视场角和 `1.3` 留白系数计算镜头距离;选中角色仍走原有姿势垂直补偿,不受整场景算法影响。默认项目在场景缩放 `300%` 时计算镜头位置约为 `[0, 13.921008, 90.257528]`。Chrome 按“关闭属性面板 -> 重开导演台 -> 确认无选择 -> 重置视角”复测,角色和相机线框均完整进入远景并居中;网格算法与两条重置路径定向测试 `10/10` 通过。本小功能已完成,后续不要重复无选择整场景重置或选中角色近景回归。
## 逐项竞品实测:3D 导演台添加角色菜单(2026-07-12)
- 竞品人物图标点击后不是角色库弹窗,也没有搜索或分类页,而是一个约 `148×496px` 的竖向菜单。顺序固定为:`本地上传`、`高斯泼溅`、分隔线、`男性素体`、`女性素体`、`宽厚素体`、`健壮素体`、`纤细素体`、`少年素体`、`儿童素体`、`二头身`、`添加空对象`、`群众 (3x3)`、`几何模型`;群众和几何模型带右侧子菜单箭头,其余条目点击即执行。
- 本地原先把 `导入本地模型` 和 `模型库` 放成了人物图标右侧的独立工具栏按钮,层级和按钮数量均与竞品不一致。默认工具栏现只保留人物入口,菜单补齐上述 13 项、图标、分隔线和顺序,最终 Chrome 实测尺寸为 `148×500px`。`本地上传` 复用统一的 GLB/GLTF/OBJ/FBX/STL/DAE 导入器,不复制上传逻辑;7 种成人/青少年素体加二头身继续复用统一的 UE4 模型与体型配置。
- 竞品 `添加空对象` 的创建结果已完整实测:首次名称为 `空对象1`,创建后自动选中,场景树位于机位之后、普通角色之前;该行只有主选择按钮,没有 `隐藏 / 锁定`。右侧标题为 `空对象`,只包含名称和位置 XYZ,默认位置为 `[0, 1.2, 0]`,没有旋转、缩放、统一缩放或颜色控件。
- 竞品 `男性素体` 的创建结果也已完整实测:新对象追加到现有角色之后并自动选中;名称不是查重生成,而是按创建前的角色总数计算字母序号。当前已有 `角色B / 角色C` 两个角色时,新增第三个仍得到 `角色C`,证明允许重名。默认位置和旋转均为 `[0, 0, 0]`,轴向缩放显示 `[1.03, 1.03, 1.03]`,统一缩放为 `1.0`,第三个角色颜色为 `#34C759`。
- 本地已按同一规则校正:空对象使用独立对象类型与精简属性面板,旧本地快照中的空对象自动迁移为 Y `1.2`;角色名改为基于数量的 `A..Z / AA..` 字母序号,颜色按数量依次使用蓝、红、绿调色板,新增素体落在原点。轴向缩放以 `1.03` 语义显示,统一缩放仍保存并显示为 `1`,不改变此前已校准的模型实际尺寸。
- Chrome 最终双端回归已通过:本地旧快照迁移后树顺序为 `机位1 / 机位2 / 空对象1 / 角色A / 角色B`,空对象右侧只有名称和位置且 Y 为 `1.2`;随后只新增一次男性素体,得到并选中 `角色C`,位置/旋转全零、轴向缩放 `1.03`、统一缩放 `1`、颜色 `#34C759`。竞品受控标签页同时保持 `空对象1 / 角色B / 角色C / 角色C` 及完全相同的新角色属性。相关定向测试 `8/8` 通过,生产构建通过。
- 本地和服务器 `/var/www/ai-chatbot` 均未发现 StoryAI 源码所引用的外置 `模型库/` FBX 目录,线上静态目录也没有可复制的 FBX/OBJ/PLY/SPLAT 源文件;因此没有把缺失资源硬编码进前端。`高斯泼溅` 已在独立专项中接入真实解析与渲染。
- 本节的菜单入口、13 项顺序、菜单尺寸、一次普通素体创建和一次空对象创建均已完成,后续不要重复打开、重数、重新量尺寸或再次创建对象。`高斯泼溅` 与 `群众 (3x3)` 也已分别形成独立完成记录。
## 逐项竞品实测:3D 导演台群众阵列(2026-07-12)
- 状态:已完成,后续不要重复创建默认 3x3 群众、重新测量浮层、重测 0 人禁用或重复检查分组树语义。
- 竞品悬停 `群众 (3x3)` 后显示独立参数浮层,点击该菜单行本身不执行创建。浮层精确为 `240×170px`,标题 `添加群众阵列`,默认 `行数3 / 列数3 / 间距1.2 / 共9人`,底部为 `取消 / 添加`;取消按钮为半透明深色,添加按钮为白底深色字。
- 三个 number 输入分别为 `54×28px`。行列 `min=1 / step=1`,间距 `min=0.1 / step=0.1`,均没有 HTML `max`;行数输入 `0` 时实时显示 `共0人` 并禁用添加。曾输入 `323` 得到 `共969人` 且添加仍可用,测试后已恢复 `3`,没有创建大阵列。
- 默认创建后场景树顶部新增并自动展开 `群众 (3x3)`,名称使用半角括号和一个空格;当前竞品已有两个普通角色,所以九名成员按角色总数命名为 `角色C..角色K`,允许与原有 `角色C` 重名。分组行和每个成员都保留独立显隐/锁定操作。
- 竞品右侧对群众只显示 `分组 / 名称 / 群众 (3x3)`,没有角色属性、姿势、位置、旋转、缩放或颜色。点击展开成员 `角色D` 后成员行进入活动态,分组行不再活动,但右侧仍保持分组面板。选中分组后按 Delete 会一次删除分组及全部九名成员;竞品临时测试分组已删除。
- 阵列排布从参数和截图可确定为以原点为中心的行列网格:列沿 X、行沿 Z,默认相邻距离 1.2;不会为了避开已有角色自动追加 Z 偏移,因此与原角色发生视觉重叠是竞品现有行为。本地已移除旧的 `maxZ + 2×spacing` 自动偏移。
- 本地原实现默认折叠、把群众排在角色之后、使用全角 `群众(3x3)`,并把整个角色属性/姿势面板暴露给分组;分组行也没有整组显隐/锁定。现已全部改为竞品语义,并为旧持久化全角组名增加加载迁移。
- 新分组被选中时 `selectedCrowdId` 独立于成员选择;点击成员只更新活动成员,仍保持分组属性上下文。整组显隐和锁定各作为一次工程 mutation 更新全部成员;整组删除、undo 和数据库工程快照继续复用现有对象数组机制。
- 群众默认行数、列数和间距已加入 `video_canvas_function_config.directorDesk.crowd`,后台 3D 导演台设置提供三个数值字段,宿主会话通过 `crowdConfig` 下发;StoryAI 对配置钳制后再初始化浮层,不在工具栏写死业务默认值。
- 本地 Chrome 同路径回归:浮层尺寸、输入尺寸、默认值、无 max、0 人禁用、白色添加按钮均一致;创建后树顺序为 `群众 -> 机位 -> 空对象 -> 普通角色`,默认展开九名成员并显示独立操作,右侧仅有分组名称。点击 `角色E` 后组行 `aria-selected=false`、成员活动、分组面板保留;整组显隐/锁定显示 pressed,Delete 后九名成员全部消失。本地临时测试分组已删除。
- StoryAI 相关测试 `106/106`、主项目群众配置测试 `2/2`、StoryAI TypeScript/生产构建与主项目 TypeScript 检查均通过。
## 逐项竞品实测:3D 导演台几何模型(2026-07-12)
- 状态:菜单层级、上传格式、六种基础体、创建默认值、命名、选择、属性面板和删除均已完成;后续不要重复创建六种基础体或重新检查上传 accept。
- 竞品悬停 `几何模型` 后显示右侧子菜单,固定顺序为 `上传文件 / 立方体 / 球体 / 圆柱体 / 环状体 / 圆锥 / 棱锥`。`上传文件` 打开单文件选择器,精确 accept 为 `.glb,.gltf,.obj,.fbx,.stl,.dae,model/gltf-binary,model/gltf+json`;它与父菜单的 `本地上传` 共用普通模型语义,高斯文件仍走独立入口。
- 六种基础体逐个创建后都会追加到现有道具之后、机位之前并自动选中。右侧统一显示 `几何模型 / 名称 / 位置 / 旋转 / 缩放 / 统一缩放 / 颜色`;位置和旋转 XYZ 均为 `0`,缩放 XYZ 均为 `1`,统一缩放显示 `1.0`,颜色为 `#d7e7ff`。
- 命名采用类型名加无补零序号。当前竞品工程保留四个已删除立方体的计数,所以首次样本为 `立方体5`,同存第二个为 `立方体6`;删除后重建仍复用 `立方体5`。其他未使用类型依次得到 `球体1 / 圆柱体1 / 环状体1 / 圆锥1 / 棱锥1`。这说明序号按该类型当前可用槽位生成,不是跨类型总数,也不使用 `01` 格式。
- 本地旧实现缺少子菜单 `上传文件`,只接受 FBX/OBJ;首个基础体没有序号,并会按自定义网格放到 `[-1.725, 0, 1.15]`,右侧标题为 `模型`、统一缩放使用 number 输入显示 `1`。现已逐项改为竞品契约,并新增 GLB/GLTF/STL/DAE 的 Three.js 加载器;STL 会补默认浅蓝白材质,所有导入模型继续复用包围盒归一化和贴地逻辑。
- 本地 Chrome 同路径回归:子菜单七项顺序和单文件 accept 与竞品逐字符一致;创建 `立方体1` 后树中自动选中,位置/旋转全零、缩放全一、统一缩放为文本框 `1.0`、颜色 `#d7e7ff`,属性标题为 `几何模型`。本地和竞品临时立方体均已删除。
- 几何模型、上传、属性、摄像机目标和场景渲染定向测试 `125/125` 通过,StoryAI TypeScript 与生产构建通过。
## 逐项竞品实测:3D 导演台 AI 识图导入(2026-07-12)
- 状态:入口、弹窗尺寸、上传/历史、插入/覆盖、人物模式、远程结构化识图、结果导入、撤销和积分前置检查均已完成;后续不要重复打开、重测尺寸、重新选择同一历史图或重复调用识图模型。
- 竞品入口位于底部工具栏截图按钮与全屏按钮之间,名称为 `AI 识图导入`。弹窗在 `1512×861` 视口中为 `640×604px`,包含 `本地上传 / 历史记录` 两页;上传区为 `606×298px`,input 为单文件且 accept=`image/*`,支持点击和拖拽。
- 上传空态文案固定为 `点击上传图片或拖拽本地图片至此上传 / 上传后画布将新建一个图片节点并自动替换当前图源`。选择图片后显示完整预览与 `更换图片`。历史页按日期分组,实测 `2026-07-09` 下为 8 张、四列两行,每张 `138×138px` 并有 `使用` 按钮。
- 场景模式默认选中 `插入当前导演台 / 作为站位参考层插入,不覆盖当前全景、角色和机位`;另一个互斥项为 `覆盖当前导演台 / 作为站位参考层插入,覆盖当前全景、角色和机位`。下方 `仅识别人物 / 只调用本地 SAM,生成动作 Mesh` 是独立开关,可与任一场景模式组合。
- 普通模式底部说明为 `关闭不会中断识图任务,生成站位参考后自动导入导演台`,费用 `48`,按钮 `生成站位参考`;没有图源时禁用,选图后启用。人物模式不显示费用,说明改为 `关闭不会中断 SAM 识别,生成动作 Mesh 后自动导入导演台`,可见按钮文本为 `生成动作 Mesh`。
- 竞品对桌前人物图和全身人物参考图执行本地 SAM 都立即返回 `本地 SAM 未识别到可导入的人物 Mesh`,弹窗不关闭、场景对象不变。说明该分支不走远程积分模型,失败也是本地分割结果,不应伪造远程等待任务。
- 竞品账号余额 20、任务费用 48 时,普通模式提交后弹窗内显示 `算力不足`,并打开全屏会员页,提示 `您的积分不足,请升级会员或充值积分继续创作`;关闭会员页后 AI 弹窗也关闭,场景不变且没有扣积分。因此后端必须先检查余额,只有得到可解析识图结果后才实际扣费。
- 本地现已增加同位置入口和同尺寸/层级弹窗;上传会在外层视频画布创建真实图片资源节点并写入日期历史。远程路径使用后台 `director-ai-import` 多模态功能配置,要求模型只返回 `summary / characters / props / cameras` JSON,服务端钳制对象数量、位置、比例、颜色、FOV 和机位目标,再按插入或覆盖语义导入站位参考层。模型失败或 JSON 无效不扣积分,402 会打开现有会员充值页。
- 人物模式优先调用后台配置的同源 `localSamEndpoint`,未配置时使用浏览器本地前景分割后备并生成透明动作 Mesh 参考层,全程不调用远程模型、不扣积分。该后备只解决可运行性,不宣称与真实 SAM 权重等价;服务器部署真实 SAM 后可直接通过后台地址替换。
- Chrome 在统一 `1512×861` 视口下最终量得本地弹窗 `640×604 @ (436,128.5)`、上传区 `606×298 @ (453,227.5)`,与竞品记录逐像素一致;入口顺序为截图、AI 识图导入、全屏。空态、预览、双页、默认插入、覆盖切换、人物模式费用消失和按钮文本均按同路径验证。
- 宿主集成实测从两条真实截图历史中选择第一张后,远程模型成功返回并导入 `站位参考1 / Character_1 / Camera_Main`,弹窗自动关闭;一次 `Cmd+Z` 完整移除三类结果。人物模式对同一历史图在浏览器本地成功生成透明 `动作 Mesh1`,视觉截图确认参考平面进入 3D 视口且没有空白渲染。
- 本地点数总开关当前关闭,因此余额显示 0 时远程任务仍按免费模式成功执行,无法在本地复现竞品的 20<48 会员弹窗;服务端仍使用统一 `prepareFeaturePointsCharge` 在模型调用前返回 402,并只在 JSON 成功解析后 consume。相对历史地址 `/api/...` 在首轮实测暴露为“图片地址无效”,现已按请求同源安全解析后复测成功。
- Chrome 扩展未开启 `Allow access to file URLs`,因此它拒绝把系统临时目录图片交给原生 file chooser;上传 input、`image/*`、单文件、FileReader 预览、按钮启用和宿主上传消息由真实 `File` 组件测试覆盖。AI 弹窗、宿主桥、插入/覆盖和工程状态定向测试已纳入 StoryAI 套件;StoryAI TypeScript、生产构建及主项目 TypeScript 均通过。
## 逐项竞品实测:3D 导演台构图参考线(2026-07-11)
- 竞品入口是画幅框右上角的小九宫格图标,辅助名称为 `构图参考线`。点击后直接切换,不打开菜单;开启态图标变为青蓝色,关闭态是低对比白色,没有大面积按钮底色。
- 竞品开启后只在当前画幅框内绘制两条竖线和两条横线,位置分别为宽高的 `1/3` 与 `2/3`;框外没有延伸线。像素对照确认线条约为 `rgba(255,255,255,0.3)`,再次点击后画面恢复到开启前状态。
- 竞品 `Cmd+Z` 不撤销参考线开关;关闭导演台并重新打开后参考线恢复关闭。因此它是单次导演台会话的临时 UI 状态,不属于项目工程或数据库持久化数据。
- 本地按钮已改为相同的 `构图参考线` 语义、画幅框右上角位置、透明小图标和青蓝开启态;三分线原有数量、裁切范围、位置与透明度保持不变。
- StoryAI 保存快照时现在固定写入关闭态,读取旧快照时也忽略历史开启值;开关改为不进入 undo、不触发持久化的运行时状态。组件和 store 定向测试共 `44` 项通过,TypeScript 与生产构建通过。
- 本地 Chrome 完整回归:选择 `9:16` 后初始为 `构图参考线 Value: 0`;点击后为 `Value: 1` 并出现 `九宫格辅助线`;`Cmd+Z` 后仍为开启;关闭并重开导演台后新的 iframe 会话恢复 `Value: 0`,且没有辅助线容器。
- 本小功能已完成,后续不要重复开关、撤销和关闭重开主流程。参考线是否写入“当前视角截图”的最终 PNG 可作为截图输出层的独立边界测试。
- 截图输出层边界已完成(2026-07-12):本地在 `16:9`、参考线开启且页面可见 `九宫格辅助线` 时生成 `1824×1026` PNG,实图只有 3D 场景,没有四条三分线。截图实现只裁切 WebGL canvas,DOM 参考线不会参与合成;已补定向回归测试锁定该行为。竞品同样把参考线作为画幅框 DOM 覆盖层,因此最终图片应保持干净。此边界后续不要重复。
## 逐项竞品实测:3D 导演台帮助与关闭(2026-07-11)
- 状态:已完成,后续不要重复测试帮助跳转和关闭返回画布主流程。
- 竞品顶栏右侧为紧凑的 `帮助 / 关闭` 图标按钮。点击 `帮助` 不打开站内面板,而是在新标签页打开飞书文档 `https://resonate.feishu.cn/wiki/Loxfw6XHziYRk0kKzdjcFfp9nhb`;点击 `关闭` 只退出全屏导演台,外层画布中的导演台节点及其 `打开导演台` 入口保留。
- 本地 `directorDesk.helpUrl` 已纳入视频画布后台配置并通过宿主会话下发给 StoryAI;StoryAI 只接受同源路径或 HTTP(S) 地址,点击后以 `noopener,noreferrer` 新标签打开。导演台 iframe 已增加 `allow-popups` 和 `allow-popups-to-escape-sandbox`,否则 Chrome 只会拦截嵌入页的外部跳转。
- 本地 Chrome 完整回归:顶栏显示 `帮助 / 关闭`;点击帮助先创建新标签,随后准确导航到上述飞书 URL,标题为 `LibTV使用指南 - 飞书云文档`;返回本地点击关闭后 iframe 卸载,画布仍显示 `导演台 1 / 打开导演台`,再次打开可正常进入新会话。
## 逐项竞品实测:3D 导演台画幅比例(2026-07-11)
- 状态:已完成,后续不要重复测试比例选项、菜单保持、撤销、入口开关、初始画幅和数据库持久化主流程。
- 竞品菜单固定为 7 项,顺序和标签是 `Auto / 21:9 / 16:9 / 4:3 / 1:1 / 3:4 / 9:16`;使用四列网格排列,第一行为前四项,第二行为后三项。竞品没有 `2:1`,自动比例使用英文 `Auto`。
- 竞品点击 `21:9` 或 `9:16` 后立即更新取景框和框外遮罩,当前比例显示青蓝选中态;选择比例不会收起菜单,按 `Escape` 也不会收起。`Cmd+Z` 不撤销画幅变化,重复点击底部画幅入口才关闭菜单。
- 本地原实现多出 `2:1`、标签为 `自动`、选项顺序不同,并在选择后立即收起菜单。本轮已改为竞品的 7 项、相同顺序和 `Auto` 标签;旧工程若含 `2:1` 仍可按比例值读取,但不再作为新菜单选项展示。
- 竞品持久化补测:在全新 Chrome 标签页重新加载同一工程并打开导演台,不操作比例菜单即可测得取景框 `326.8125×581px`,宽高比精确为 `0.5625`,证明当前工程的 `9:16` 是持久工程状态而非单次弹窗状态。
- 本地初始值与持久化补测:后台新增“新导演台默认画幅”七项选择,默认 `9:16`;临时新建的 `导演台 4` 首次打开即显示 `data-aspect-ratio=9:16`,取景框 `351.5625×625px`。切换为 `16:9` 后取景框变为 `912×513px`,数据库节点的 `directorProject.viewportAspectRatio` 为 `16:9`,关闭重开仍为 `16:9`。测试节点随后删除,数据库节点数恢复 `3`,原工程无污染。StoryAI 状态/桥接测试 `59/59`、主项目配置/工程测试 `8/8`、双方 TypeScript 与生产构建通过。
## 逐项竞品实测:3D 导演台视口全屏(2026-07-11)
- 状态:已完成,后续不要重复测试全屏进入、`Escape`、同按钮退出和关闭重开主流程。
- 竞品底部全屏入口不是关闭整个导演台,也不是把画幅框拉伸变形;进入后保留顶栏、中央 3D 视口、画幅框、坐标控件和悬浮工具条,只隐藏左右场景/属性侧栏并让视口铺满可用宽度。退出后左右侧栏回到原位置,`9:16` 画幅、导演视角和场景参数不变。
- 竞品单按 `Escape` 不退出该状态,必须再次点击同一全屏图标。进入全屏前已经打开的机位预设菜单在进入和退出后均保持打开,说明全屏只是布局层状态,不会重置工具面板或工程数据。
- 竞品在全屏态直接点击顶栏关闭,再次打开同一导演台时左右侧栏恢复展开;因此全屏属于单次导演台会话的临时 UI 状态,不应写入工程快照、undo 或数据库。
- 本地原有布局和入口行为已经与竞品一致,但 `viewportPanelsCollapsed` 会写入 localStorage 并进入 undo。本轮改为运行时直接更新;保存快照固定写入 `false`,读取旧快照也忽略历史 `true`,一次 `Cmd+Z` 不改变全屏状态。
- 本地 Chrome 完整回归:进入后左右侧栏消失且 `9:16` 画幅保持;单按 `Escape` 不退出;再次点击入口恢复侧栏。随后用旧 iframe 在全屏态关闭并由新静态包重开,场景树和属性面板立即恢复,画幅框与导演视角仍保留。状态层定向测试 `4/4`、TypeScript 和生产构建均通过。
## 逐项竞品实测:3D 导演台机位视角预设(2026-07-11)
- 状态:已完成,后续不要重复测试 15 个预设的菜单顺序、创建、撤销和固定参数主流程。
- 竞品点击底部 `添加机位` 后打开标题为 `选择机位视角` 的双列滚动菜单,固定顺序为:`当前视角 / 正面中景 / 正面特写 / 正面全景 / 侧面跟拍 / 侧面近景 / 背面中景 / 俯拍全景 / 45° 俯拍 / 低角度仰拍 / 低角度广角 / 过肩镜头 / 过肩镜头(右) / 鸟瞰 / 荷兰角`。
- 选择任意预设会立即收起菜单、创建并选中新机位、把右侧切到摄像机属性,但仍停留在导演视角;一次 `Cmd+Z` 会完整移除该机位并恢复之前的选择。15 项均按“创建 -> 读取 -> 立即撤销”逐项采集,测试结束后竞品仍为原来的 4 个机位。
- 固定预设的摄像机位置、手动注视点和 FOV 如下;旋转值由位置与注视点的 look-at 几何关系确定,不是额外后端结果。竞品 `荷兰角` 当前显示的 Z 旋转也是 `0`。
| 预设 | 位置 | 注视点 | FOV |
| --- | --- | --- | ---: |
| 当前视角 | 当前视口动态读取;采样时 `[0, 2.2, 10]` | 当前视口动态读取;采样时 `[0, 1.2, 0]` | 采样时 50 |
| 正面中景 | `[0, 1.5, 4]` | `[0, 1.2, 0]` | 50 |
| 正面特写 | `[0, 1.7, 2]` | `[0, 1.6, 0]` | 35 |
| 正面全景 | `[0, 2.5, 8]` | `[0, 1, 0]` | 60 |
| 侧面跟拍 | `[4, 1.5, 0]` | `[0, 1.2, 0]` | 50 |
| 侧面近景 | `[2.5, 1.5, 0]` | `[0, 1.5, 0]` | 40 |
| 背面中景 | `[0, 1.5, -4]` | `[0, 1.2, 0]` | 50 |
| 俯拍全景 | `[0, 8, 3]` | `[0, 0, 0]` | 55 |
| 45° 俯拍 | `[0, 5, 5]` | `[0, 1, 0]` | 50 |
| 低角度仰拍 | `[0, 0.3, 3]` | `[0, 1.8, 0]` | 45 |
| 低角度广角 | `[0, 0.5, 5]` | `[0, 1, 0]` | 70 |
| 过肩镜头 | `[-0.5, 1.7, 2.5]` | `[0.5, 1.6, 0]` | 50 |
| 过肩镜头(右) | `[0.5, 1.7, 2.5]` | `[-0.5, 1.6, 0]` | 50 |
| 鸟瞰 | `[0, 12, 0.5]` | `[0, 0, 0]` | 60 |
| 荷兰角 | `[1, 1.5, 3.5]` | `[0, 1.2, 0]` | 50 |
- 结论:这组功能是前端确定性机位模板,不需要模型调用或提示词。`当前视角` 读取实时视口快照,其余 14 项由位置、注视点和 FOV 直接构造摄像机。
- 本地默认值写入 `directorDesk.cameraPresets`,后台新增“机位视角预设 JSON”编辑器;配置经主应用规范化后通过 iframe 会话下发,StoryAI 不依赖 UI 内硬编码。菜单同样为双列、选择后关闭,并与全景图等浮层互斥。
- 本地 Chrome 完整回归:`正面特写` 创建 `机位2`,右侧读取位置约 `[0, 1.700005, 2]`、注视点 `[0, 1.6, 0]`、FOV `35`;一次 `Cmd+Z` 后只剩 `机位1`。`当前视角` 创建出的坐标来自操作时真实视口而非固定值,再次撤销也完整移除。机位/互斥定向测试 `3/3`、宿主配置测试 `1/1`、TypeScript 和生产构建均通过。
## 逐项竞品实测:3D 导演台摄像机属性面板(2026-07-11)
- 状态:已完成,后续不要重复测试名称提交、机位切换、位置/旋转/注视/FOV 数值规则、跟随视角和空截图区主流程。
- 竞品字段顺序为 `FOV 摘要与机位视角快捷键 -> 名称 -> 切换机位 -> 位置/跟随偏移 -> 跟随目标 -> 跟随视角(条件显示) -> 旋转 -> 注视目标 -> 注视坐标(条件显示) -> FOV -> 相机截图`。空机位的截图区只显示标题,不出现“当前机位截图”按钮或占位文案。
- 名称只在输入框失焦或按 `Enter` 后提交,并同步更新场景树和机位下拉项。默认名称为 `机位1`,截图递增命名为 `机位1-shot-01`;旧本地 `机位01 / 机位01-截图01` 已做加载迁移。
- 位置和注视坐标步长为 `0.1`,输入显示保留两位;`1.234 -> 1.23`,从 `0` 按上箭头得到 `0.1`。旋转步长为 `1`,保留两位但不归一化用户角度,`190.555 -> 190.56`。
- FOV 滑杆固定 `min=15 / max=90 / step=1`,文本显示一位小数;`5 -> 15.0`、`200 -> 90.0`、`50.55 -> 51.0`。顶部摘要同步显示整数角度。
- `注视目标` 顺序为 `手动坐标 / 手动旋转 / 可见目标对象`。手动旋转或对象注视时隐藏注视坐标;编辑任一旋转轴会自动切换到手动旋转。再选手动坐标会恢复独立保存的手动目标,而不是从当前旋转反推并覆盖它。
- 自动旋转为确定性 look-at 几何:`pitch = atan2(position.y - target.y, hypot(target.x - position.x, target.z - position.z))`,`yaw = atan2(target.x - position.x, target.z - position.z)`,roll 保留。基线 `[0,2.2,10] -> [0,1.2,0]` 得到 `[5.71,180,0]`;接近 `-180` 的浮点边界按显示精度归为 `180`,用户手输的 `190.56` 不改写。
- 选择跟随目标时保持相机世界位置并记录“相机位置减角色位置”的偏移,同时新增 `第三人称 / 第一人称`。第一人称把偏移设为 `[0,1.2,0]` 并采用角色旋转;第三人称使用同一偏移并继续朝注视目标 look-at;取消跟随后把当前世界位置实体化,隐藏跟随视角。
- 本地 Chrome 按竞品相同步骤完成实际输入回归:名称提交前后树节点变化、机位视角快捷键、三组 FOV 边界、位置精度和箭头步进、`190.555` 手动旋转、角色注视后编辑旋转、跟随启用/第一人称/第三人称/取消跟随均得到预期状态。视觉截图确认右侧紧凑面板无重叠;相关测试 `55/55`、TypeScript 与生产构建通过。
## 逐项竞品实测:视频特效库(2026-07-13)
- 状态:特效入口、面板尺寸、目录、搜索、收藏、最近使用、应用、替换、删除和刷新持久化已完成;后续不要重复测试本组主流程。付费视频生成没有执行,生成结果质量和服务端模型实际接纳特效提示词仍属于独立边界。
- 竞品在选中视频节点后,于视频生成编辑器显示 `参考 / 标记 / 特效 / 角色库 / 运镜`。特效面板有 `特效广场 / 我的收藏 / 最近使用` 三个标签、搜索框、`推荐` 分类和禁用的 `Seedance 2.0 ·` 模型提示。`1512×861` 视口下展开面板为 `1312×702 @ (100,79.5)`、6 列;紧凑面板为 `810×657 @ (351,102)`、4 列。
- 竞品推荐目录共 49 项,卡片包含真实缩略图、名称、商用标记、作者头像、作者名、使用次数、收藏和更多操作;底部固定为 `已加载全部`。更多操作展示全部适配模型、Seedance 2.0、Kling 3.0 和 Kling O3。当前目录及相关展示字段已完整写入 `video_canvas_function_config.effectPresets`,后台可编辑标题、作者、头像、缩略图、模型、提示词、搜索关键词权重、使用次数、商用和推荐状态,前端不再硬编码目录。
- 竞品搜索不是输入即做本地字符串过滤:输入 `产品扫光` 时仍显示 49 项,按 Enter 或点击搜索才提交;结果也不删除未命中项,而是把完整列表重新排序。实测前十为 `产品扫光 / 微距推镜 / 水下慢镜头 / 赛博化妆师 / 多角度定点 / 悬浮缓入 / 普拉达换装 / 红毯闪光灯 / 穿梭机运镜 / 驯龙高手`。本地新增鉴权接口 `POST /api/video-canvas/effects/search`,从数据库读取配置并按标题、作者、提示词和可配置关键词权重做确定性语义重排;前九和第十完全一致,第九使用目录中同作者同素材记录的标题 `小蜜蜂运镜`,不伪造竞品搜索时出现的别名。
- 应用特效后竞品立即关闭面板,在编辑器中只增加一个 `48×48` 的内联特效项,显示 `特效 / 替换` 和删除按钮;画布不会创建特效节点或连线。再次打开时当前卡片显示 `当前使用`,选择另一项只替换同一个内联项,删除后恢复普通 `特效` 按钮。由此反推后端数据应保存结构化特效描述,在真正生成时才把特效提示词合入视频请求,不应提前创建画布工作流节点。
- 本地现将单个特效保存到目标视频节点的 `draftValues.videoEffect` 和 `params.effects[0]`,字段含 ID、标题、作者、缩略图、模型、商用状态和提示词;生成请求优先读取该结构,并兼容旧特效节点数据。Chrome 实测 `产品扫光` 刷新后仍保留;替换为 `穿云而入` 后数据库始终为 4 节点、2 连线且只更新同一结构;删除并刷新后结构为空、普通特效入口恢复。临时视频删除后本地工程恢复原始 3 节点、2 连线。
- `我的收藏` 使用浏览器持久收藏,收藏后只显示该卡片,取消后恢复 `暂无素材`;`最近使用` 按竞品当前行为保持 `暂无素材`,应用特效不会写入。重复标题按 ID 判断当前项和收藏项,避免串选。搜索清空后立即恢复目录原顺序。
- 竞品测试创建的临时 `视频37` 已删除,收藏状态已恢复,未点击生成按钮。竞品页面刷新前后可见积分从 `4471` 变为 `4231`;测试流程没有提交生成,这一变化只记录为另一个会话或待结算任务刷新后的外部观察,不归因于特效操作。
- 定向自动化覆盖 49 项真实缩略图、`产品扫光` 语义排序,以及单个内联特效的应用、替换、删除和原提示词保持。实现集中在 `lib/video-canvas/video-effects.ts`、`app/api/video-canvas/effects/search/route.ts`、视频画布组件、后台视频画布设置和统一配置层。
## 逐项竞品实测:视频运镜素材库(2026-07-13)
- 状态:入口、面板、23 项目录、搜索、收藏、自定义运镜、结构化插入、组合、重复、退格删除和测试数据清理均已完成双端实测。本小功能已完成,后续不要重复操作竞品或本地主流程。付费视频生成没有执行,生成结果质量和模型对运镜指令的服从度仍属于独立边界。
- 竞品在 `1512×861` 视口中的运镜面板为 `800×604 @ (356,128.5)`,无整屏暗色遮罩,包含 `运镜广场 / 我的收藏 / 我的运镜`、搜索框、关闭按钮和四列卡片。目录严格为 23 项,从 `固定镜头` 到 `手持拍摄`;预览资源依次为 `https://libtv-res.liblib.art/tool/movement/1.webp` 到 `/23.webp`。本地已使用同一顺序与资源,23 张预览均实际加载成功。
- 搜索采用提交式子串匹配:输入时目录不变化,按 Enter 或点击搜索后过滤,清空按钮立即恢复全部目录。本地实测输入 `航拍` 前仍为 23 项,提交后只显示 `高空航拍`,清空后恢复 23 项。
- 选择运镜不会把名称写成普通提示词,而是在编辑器插入 `data-mention-type="camerapreset"` 的不可编辑、可拖拽结构化标签;字段包含顺序索引和空节点 ID。运镜可以按顺序组合、可以重复,单次 Backspace 只删除末尾一个标签。本地 Chrome 实测 `固定镜头 -> 镜头前推 -> 固定镜头` 保留三个标签,修正了浏览器原生退格会整组删除的问题,复测一次退格后只剩前两个。
- 由交互反推,真正提交生成时应按标签顺序解析隐藏提示词,而不是在用户输入框提前展开文案。本地将标签保存到视频节点的 `draftValues.motionMentions` 和 `params.motionMentions`,提交时才由 `composeVideoCanvasMotionPrompt` 依次拼入 `运镜要求「名称」:指令`;重复标签不会去重。目录名称、预览和生成提示词来自 `video_canvas_function_config.motionPresets`,后台“视频运镜素材预设”可编辑,不在前端写死业务提示词。
- 竞品自定义弹窗为 `526×349px`,标题为 `自定义`,字段为可选预览图、最多 6 个字符的名称和运镜提示词;预览 accept 精确为 `image/png,image/jpeg,video/mp4,.png,.jpg,.jpeg,.mp4`。名称或提示词为空时保存无动作,超过 6 个字符时保存禁用;不上传预览也能保存占位卡片。自定义卡片支持收藏、编辑、删除,删除确认文案为 `确定删除「名称」吗?删除后不可恢复。`。本地已对齐尺寸与同一状态机,并使用浏览器本地持久化;自定义标签与官方标签走同一结构和生成解析链路。
- 本地 Chrome 最终回归:空表单点击保存仍保留弹窗;`测试自定义运镜` 显示 `7/6` 并禁用保存,改为 `测试运镜` 后显示 `4/6`,不上传预览也能保存。卡片可收藏并出现在“我的收藏”,应用后插入 `type=camerapreset / index=1 / nodeId="" / draggable=true / contenteditable=false` 的标签;编辑为 `测试横移` 后收藏保留,删除确认文案一致,确认删除后“我的运镜”只剩新建入口且“我的收藏”恢复 `暂无收藏`。
- 目录收藏即时生效,空收藏显示 `暂无收藏`;自定义运镜删除时同步清理收藏引用。竞品临时 `测试运镜` 和收藏已删除;本地测试标签、自定义卡片、收藏和临时 `分镜视频-#1` 均已通过界面删除,数据库确认工程恢复原始 `3 节点 / 2 连线 / 0 运镜标签`。双方均未点击视频生成按钮。
- 定向自动化覆盖 23 项名称与资源顺序、23 项完整持久化、重复标签保留和生成阶段按序解析;实现集中在 `lib/video-canvas/video-motion.ts`、统一视频画布配置、视频画布组件、后台视频画布设置和本文件记录。
## 逐项竞品实测:视频角色库(2026-07-13)
- 状态:公共角色库入口、Seedance 2.0 承诺书边界、23 项目录、分页与页码跳转、四类素材详情、单选、预览、应用、刷新持久化、移除和测试数据清理均已完成。本小功能后续不要重复测试主流程;未执行付费视频生成,模型最终成片中的角色一致性仍属于独立边界。
- 竞品登录状态正常,测试时积分为 `4,039`。Seedance 2.0 点击角色库会先显示 `Seedance2.0模型使用承诺书`,勾选前“同意并使用”禁用;本轮没有替用户接受法律承诺,使用“不同意”和关闭验证退出。Seedance1.5 Pro 不显示承诺书,直接进入公共角色库。本地已接受过承诺书的浏览器状态会直接打开带 `Seedance2.0合规素材库` 标签的面板,符合接受后绕过弹窗的状态机。
- `1512×861` 视口下竞品与本地面板均为 `1280×720 @ (116,70.5)`,5 列、每页 10 项,共 `10 + 10 + 3`。精确目录 ID 顺序为 `003,001,002,004,005,006,007,010,011,012 / 013,014,015,016,018,022,021,008,009,019 / 020,017,023`;角色名称、性别、年龄、时代和角色标签已写入 `video_canvas_function_config.rolePresets`,后台可继续编辑。
- 真实素材来自 `https://libtv-res.liblib.art/tool/character/`。只有 003 的四张文件带 `_1`,即 `003_full_body_1 / face_closeup_1 / expression_grid_1 / character_sheet_1.png`;其余 22 个角色均不带 `_1`。本地三页 23 张目录图和 003 的四张详情图均在 Chrome 中实际加载为 840px 宽,不再使用推导错误的空图。
- 角色详情固定为 `全身正面 / 近景 / 九宫格 / 设定图`。一次最多选择一项,选中后其他三项禁用,底部显示 `已选择 1 项 / 应用`;预览支持关闭、上一张、下一张和 `1 / 4` 页码。本地实测从全身正面进入预览并翻到近景 `2 / 4`,卡片宽约 `239.6px`,与竞品一致。
- 应用后角色不会创建画布节点或连线,而是成为视频编辑器中的单个 `48×48` 内联附件,角标为 `1`;“参考”和“角色库”入口同时隐藏,模式从文生视频切到图生视频,已选模型保持不变。附件点击预览为 `129×169`,目录、缩略图和预览分别请求 840/96/800px 资源;移除后恢复普通入口和文生视频。
- 本地将角色同时保存到目标节点的 `draftValues.videoRole` 与 `params.roles[0]`,字段包含角色 ID、合规资产 ID、名称、标签、素材种类、素材 URL 和角色一致性提示词。生成时把角色图加入首帧参考,并在真正提交前合并 `角色引用「名称」(合规资产 ID)` 与“一致五官、发型、服装、体型,避免换脸、换装、年龄/性别漂移和肢体畸变”等约束;编辑器中不提前展开隐藏提示词。
- Chrome 刷新验证角色附件、`Seedance1.5 Pro` 标签和“图生视频”均恢复;数据库确认 `videoRole.id=params.roles[0].id=role-003`、`materialKind=full`、`mode=settings.mode=first-frame`。本地复制的实际视频后端当前只有 Agnes Video v2.0,因此模型引用保持后台默认 `auto`,显示标签与真实模型路由分层;管理员可通过已有模型表和视频画布函数配置替换真实后端。
- 竞品资源提取临时 `视频37` 和本地临时 `分镜视频-#1` 均已删除。最终数据库恢复原始 `3 节点 / 2 连线 / 0 角色附件`,双方均未点击生成。TypeScript 检查通过,主项目 helper 回归 `225/225` 通过;角色定向测试覆盖目录顺序、真实文件名、结构化保存/移除和生成阶段提示词合并。
## 逐项竞品实测:画布节点连线显隐(2026-07-13)
- 状态:入口、隐藏、恢复、按钮状态、悬停提示和刷新边界均已完成;没有新增、删除或修改节点与连线数据。后续不要重复测试本小功能主流程。
- 竞品底部 Dock 的入口为 `28×28px` 图标按钮,可见状态的无障碍名称是 `隐藏节点连线`。点击后所有 `.react-flow__edge` 元素直接从 DOM 移除,按钮名称变为 `显示节点连线`,没有 toast;再次点击按原数量恢复全部连线。
- 该状态只属于当前页面会话:隐藏后刷新,按钮和连线都恢复默认显示,不写入画布项目。竞品当前项目在测试期间有其他在线会话,连线总数可动态变化,因此验收条件使用 `N -> 0 -> N`,不把某个瞬时总数硬编码为产品规则。
- 本地原实现只把 `edgesHidden` 传给节点连接点,按钮会切换但 SVG 连线仍全部显示。本轮已把相同状态接入 `CloneEdges` 渲染层;Chrome 实测本地 `3 -> 0 -> 3`,刷新后仍为默认显示,与竞品一致。
- 两边按钮在同一视口中的几何位置均为 `28×28 @ (184,759)`。显示与隐藏图标均已逐项对齐为 `14×14 / viewBox 0 0 16 16`,Chrome 核对两种状态的 SVG 路径和变换坐标完全一致。竞品悬停提示为 `89×25.5px`、`#262626` 背景、8px 圆角、12px 白字、`4px 8px` 内边距,位于按钮上方 7px。本地已移除浏览器原生 `title`,改为同规格、500ms 延迟的 DOM tooltip,实测为 `88×25.5px @ (154,726.5)`;1px 文本测量差来自字体栅格取整。
## 逐项竞品实测:画布小地图(2026-07-13)
- 状态:入口、展开、拖拽导航、按钮状态、视觉结构、无障碍结构和刷新边界均已完成双端实测;没有新增、删除或修改节点与连线数据。后续不要重复测试本小功能主流程。
- 两边入口均为底部 Dock 的 `28×28 @ (152,759)` 图标按钮,无障碍名称为 `切换小地图`,没有 `title` 或 `aria-pressed`。关闭时 DOM 中没有 `.react-flow__minimap`;点击后在按钮上方 8px 展开,刷新页面后恢复默认关闭,因此它是纯前端会话状态,不需要数据库字段、模型调用或提示词配置。
- 竞品展开框为 `150×110 @ (152,641)`,外层 `rgba(31,31,31,.9)`、`0.5px solid #363636`、12px 圆角和隐藏溢出;内部小地图位于 `(152.5,641.5)`,节点填充为 `#525252`,视口遮罩为 `rgba(255,255,255,.04)`。本地已逐值对齐,Chrome 最终读取的外框、内部坐标、尺寸、颜色、边框、圆角和遮罩均一致。
- 竞品入口不是原本的四宫格图标,而是 `13×13 / viewBox 0 0 21.7988 21.8008` 的定位图标。本地已复制相同 SVG 路径;激活态背景为 `rgba(255,255,255,.15)`、前景为 `rgb(247,247,247)`。展开后的无障碍树只保留 `img "Mini Map"`,本地多余的 `generic "Mini Map"` 包装已移除。
- 竞品项目节点数量会受其他在线会话影响,实测时小地图包含 106 个节点;本地测试工程为 4 个节点。节点数和图形大小应忠实反映各自当前工程,不能硬编码成竞品瞬时数量。
- 竞品从小地图中心向右上拖拽后,画布平移由 `translate(-4px,236.9px) scale(.1)` 变为 `translate(-528.799px,498.855px) scale(.1)`,缩放值不变;本地按同一步骤由 `translate(-577px,-327px) scale(1)` 变为 `translate(-565px,-212px) scale(1)`,缩放同样不变。绝对位移取决于项目边界与当前缩放,交互规则均为“拖拽导航只平移,不改变 zoom”。
## 逐项竞品实测:画布网格吸附(2026-07-13)
- 状态:入口、开关、自由拖动、吸附拖动、缩放换算、刷新持久化、图标和悬停提示均已完成双端实测;测试节点均恢复原坐标,开关恢复关闭。后续不要重复测试本小功能主流程。
- 两边入口均为底部 Dock 的 `28×28 @ (216,759)` 按钮,无障碍名称始终为 `网格吸附`,没有 `title`、`aria-pressed`、开关文案或稳定的激活背景;状态只能由拖拽落点判断。图标均为 `14×14 / viewBox 0 0 24 24` 的 Lucide 磁铁路径。竞品提示为 `65×25.5 @ (197.5,726.5)`,本地为 `64×25.5 @ (198,726.5)`;两边均为 `#262626`、8px 圆角、12px 字体和 `4px 8px` 内边距,1px 宽度差来自字体栅格取整。
- 竞品当前为 10% 缩放,测试节点原始世界坐标为 `(6504,60)`。相同指针轨迹在关闭吸附时落到 `(6542,96)`,约为自由位移 `(+38,+36)`;开启后落到 `(6544,90)`,位移量化为 `(+40,+30)`。结果保留节点原坐标的个位偏移,证明算法量化的是本次拖动位移,而不是把最终绝对坐标对齐到全局网格:`next = start + round(delta / 10) * 10`。
- 本地旧实现默认开启,并把最终绝对坐标按 8 单位取整;实测 `(756,431)` 经小幅拖动变为 `(760,432)`,关闭后刷新又恢复开启。这三点都与竞品不同。
- 本地现改为默认关闭、世界坐标 10 单位的相对位移吸附,并以浏览器偏好保存开关。Chrome 最终用同一有效世界位移实测:关闭时 `(756,431) -> (794,465)`,即 `(+38,+34)`;开启时 `(756,431) -> (796,461)`,即 `(+40,+30)`;开启后刷新仍得到相同吸附结果。关闭态仍把最终自由坐标取整,避免浮点坐标污染项目数据。
- 该开关是个人操作偏好,不进入项目节点、连线或系统业务配置,也不涉及模型、后端算法或提示词。实现集中在 `lib/canvas/grid-snap.ts` 与视频画布组件;定向测试 `2/2`、主项目 helper 回归 `230/230`、TypeScript 和格式检查均通过。
## 逐项竞品实测:画布缩放控制(2026-07-13)
- 状态:缩放菜单、输入、边界、放大缩小倍率、适合屏幕命令、全局快捷键、菜单常驻、动态按钮宽度和悬停提示均已完成双端实测;双方已恢复到竞品 `10%`、本地 `100%` 且菜单关闭。后续不要重复测试本小功能主流程。
- 两边缩放范围均为 `10%–800%`。竞品不是固定加减 20 个百分点,而是每次放大乘以 `1.2`、每次缩小除以 `1.2`,并按百分比显示:实测 `50% -> 60%`、`50% -> 42% -> 35%`、`100% -> 120% -> 100%`。本地已把 Dock 按钮与 `⌘+ / ⌘-` 快捷键统一到同一倍率和两位小数内部精度。
- 手动输入只保留数字,`12a.5` 会即时变成 `125`;按 Enter 或输入框失焦都会提交,`0` 夹到 `10%`,`999` 夹到 `800%`,空输入恢复当前比例。点击菜单项或画布空白处后菜单继续保持展开,测试结束时再次点击缩放按钮可关闭。本地旧实现允许小数、只在 Enter 提交,并会在点击空白处关闭菜单,现已对齐。
- `⌘+ / ⌘- / ⌘0` 在菜单关闭时仍全局生效;本地 Chrome 实测依次得到 `120% / 100% / 94%`。`⌘0` 与菜单中的“适合屏幕”共用按当前节点边界重新计算视口的命令,百分比随项目内容、节点分布和实时协作视口而变化;竞品当前项目实测为 `10%`,本地三节点工程为 `94%`,不能把任一结果硬编码为固定值。
- 缩放按钮宽度由系统字体文字和左右 4px 内边距自然撑开,竞品与本地在 `10% / 100% / 800%` 下均为 `34.0234 / 42.1328 / 44.4063px`,且没有 `title`、`aria-expanded` 或 `aria-haspopup`。悬停提示均为 `65×25.5px`、`#262626` 背景、`0.5px solid #363636`、8px 圆角和 `4px 8px` 内边距。
- `1512×861` 视口、`100%` 下,竞品菜单为 `186.6094×276.8906 @ (175.7578,474.1016)`;本地为 `186.6016×276.8906 @ (175.7617,474.1094)`。两边每个菜单项均为约 `173.609×36.148px`、`8px 10px`,快捷键拆成 `⌘` 与固定 10px 宽的符号列;输入框高度均为 `20.1484px`,菜单背景、边框、圆角、阴影和 8px 底部间距一致。
- 该功能是纯视口交互,不修改节点、连线、模型配置、数据库业务字段或生成提示词,不需要后台可配置项。算法集中在 `lib/canvas/zoom.ts`,定向自动化覆盖倍率、边界与纯数字输入;本轮定向测试 `3/3`、主项目 helper 回归 `233/233`、TypeScript、格式检查和差异检查均通过。
## 逐项竞品实测:整理画布(2026-07-13)
- 状态:入口、图标、提示、点击整理、快捷键、图布局、视口适配、确认、还原、保留和 `Escape` 边界均已完成;测试工程已恢复原节点坐标与视口。后续不要重复测试本小功能主流程。
- 两边入口均为底部 Dock 的 `28×28 @ (120,759)` 透明按钮,无障碍名称为 `整理画布,Option+Shift+F`,没有 `title` 或 `aria-pressed`,前景色为 `rgb(247,247,247)`。图标已对齐竞品 `libtv:computing` 的完整 `14×14 / viewBox 0 0 16 16` SVG;提示文案为 `整理画布 / ⌥⇧F`,使用系统字体、`#262626` 背景、8px 圆角和向下箭头。
- 竞品不是规则网格,而是确定性的 ELK 图布局。公开前端代码确认使用 `elkjs 0.9.3`、`elk.algorithm=layered`、`elk.direction=RIGHT`、同层节点间距 `100`、层间距 `200`、零 ELK padding;布局只处理根节点,把连线端点提升到根节点并去重,再按连通分量分别布局。
- 每个分量先把内部最小坐标归零,最终坐标按 `round(value / 12) * 12` 吸附;分量间距为 `150`,从原根节点最小 `x/y` 的 12px 吸附坐标开始横向排列,累计宽度超过 `3200` 时换行。小于等于一个根节点或布局没有产生坐标变化时不显示确认框。
- 整理后竞品对全部节点边界调用 React Flow `getViewportForBounds`,缩放范围 `10%–800%`、padding 为 `0.12`,再固定以 `x=48-bounds.x*zoom / y=48-bounds.y*zoom` 设置视口并动画 300ms。本地已复刻 React Flow 对 padding 先换算为像素并向下取整的细节,不使用旧版居中网格视口。
- 竞品确认框文案为 `是否保留此次整理结果?`,按钮为 `还原 / 保留`;约 `168×87 @ (50,664)`,背景 `#262626`、`0.5px solid #363636`、12px 圆角与内边距、16px backdrop blur。`保留` 只清除原坐标备份;`还原` 只恢复被改动节点的原坐标,然后再次适配全画布,不恢复整理前的旧 viewport;`Escape` 等同保留,点击框外不会关闭。
- 快捷键严格为捕获阶段的 `Alt+Shift+F`(Mac 显示 `⌥⇧F`),忽略按键重复、输入控件、Ctrl/Meta 组合、标记模式和待确认状态。竞品与本地均可由该快捷键打开同一确认流程。
- 本地 QA 副本以原工程的 `4 节点 / 3 连线` 实测:整理前视口为 `translate(-332px,-340px) scale(1)`;整理后导演台、两个图片和视频节点依次落到 `(600,624) / (1152,432) / (1152,1152) / (1704,1296)`,视口为 `translate(-273.461px,-183.452px) scale(.535768)`。点击还原后四个节点精确恢复,视口按原边界重新适配为 `translate(-645.569px,-450.214px) scale(1.15595)`;快捷键再次整理后按 `Escape`,确认框关闭且整理坐标保持。
- 实现集中在 `lib/canvas/arrange.ts` 与视频画布组件,`elkjs` 已成为显式依赖。定向测试覆盖精确 ELK 坐标、重复边去重、零/单节点、更新合并和视口公式;本轮主项目 helper 回归 `238/238`、TypeScript 与新文件格式检查均通过。独立 QA 工程 `f2d93450-68a4-48f4-846f-4b183f18b877` 已恢复为原始 `4 节点 / 3 连线 / (-332,-340,1)`,原工程未被自动化写入。
## 逐项竞品实测:资产管理侧栏与个人资产库(2026-07-13)
- 状态:左侧工作区面板的打开/收起、宽度记忆、画布/资产页签、搜索、类型筛选、分组收起、节点定位、重命名、复制,以及全量个人资产库的搜索、分类、上传、新建文件夹和批量操作层均已完成。后续不要重复本节主流程。`可灵主体库 -> 创建主体` 的素材校验、音色、标签和描述属于下一个独立小功能,不计入本节完成范围。
- 竞品底部 `资产管理` 入口在 `1512×805` 视口为 `94×28 @ (22,759)`,打开无遮罩、全高的左侧 Drawer。公开前端 chunk 确认宽度键为 `libtv:workspacePanelDrawer:width`,窗口宽度大于 `1520` 时默认 `280px`,否则 `240px`,最小 `240px`、最大 `380px`、右侧保留 `48px`;拖动指针的 `clientX` 直接夹紧为面板宽度,松开后持久化。竞品当前已记忆 `360px`,本地新会话按同一规则得到 `240px`,ARIA 范围为 `240–380`。
- 面板结构为 `92px` 工作区头部、`50px` 页签带、中部列表和 `53px` 底部。顶部书本按钮会在保留 Drawer 的同时打开独立资产管理弹窗,不是关闭侧栏;本地原先的错误关闭行为已修正。节点行为 `40px`,包含 `32px` 缩略图,更多和定位按钮只在悬停/聚焦时出现;底部同时显示 `收起节点侧栏` 和工程总节点数。
- 画布列表类型顺序精确为 `全部 / 文本 / 图片 / 视频 / 视频合成 / 导演台 / 音频 / 脚本 / 脚本(旧版)`。搜索使用 `搜索节点` 占位和 `搜索画布元素` 辅助名;定位会选中节点并以 `zoom=1` 居中。分组收起只隐藏组内成员,不隐藏根节点;本地当前四个根节点无分组,因此收起按钮与竞品同样禁用。
- 节点更多菜单对普通节点只有 `重命名 / 复制`。重命名输入的辅助名为 `节点名称`、`maxLength=120`,打开后自动聚焦并全选,失焦或 Enter 提交,Escape 取消,没有额外确认/取消图标。复制节点保持 X,Y 增加 `节点实测高度 + 60`,标题追加 ` - 副本`并选中新节点。竞品临时复制节点已删除,名称已恢复,总数回到 `106`。
- 全量弹窗为视口 `90%×80%`:在 `1512×805` 下约 `1360.8×645 @ (75.6,80)`、12px 圆角,顶部全宽 `48px` 标题栏,下方为 `160px` 左导航和内容区。个人库包含长搜索框、`批量操作 / 新建`、`全部 / 其它 / 人物 / 场景 / 物品 / 风格 / 音效` 和文件夹空态。`新建` 菜单为 `新建文件夹 / 上传资产`;文件夹名称必填、最多 20 字,空值时保存禁用。
- 本地个人库使用真实 `MediaAsset` 数据,`GET /api/video-canvas/assets` 按当前用户、ready 状态、名称和媒体类型查询;图片/视频/音频上传复用统一文件 API,并以 `video_canvas_asset_library_upload` 来源写回资产表。文件夹、分类标签和所属文件夹保存在资产记录/元数据中;批量入口支持当前页全选、移动、修改标签和删除,服务端限制单次 50 项且严格按当前用户过滤。
- Chrome 本地回归已验证:顶部书本入口打开弹窗后 Drawer 仍存在;重命名只有一个 `节点名称` 输入,额外确认/取消按钮均为 0;搜索 `导演台`、类型筛选、定位、个人库真实缩略图、新建文件夹对话框和三个批量动作层均按同一路径通过。两次实际文件夹写入测试均已从数据库删除,本地资产数据恢复测试前状态;没有删除或改标签任何真实资产。
- 该功能不调用生成模型,没有可反推的生成提示词;后端核心是用户隔离的资产索引、文件元数据和批量 mutation。宽度算法集中在 `lib/canvas/asset-manager.ts`,接口为 `app/api/video-canvas/assets/route.ts`,上传注册与界面分别位于统一文件路由和视频画布组件。定向宽度测试 `3/3`、主项目 helper 回归 `241/241`、TypeScript 和差异检查均通过。
## 逐项竞品实测:可灵主体库创建主体(2026-07-14)
- 状态:图片/视频素材入口、历史图库、校验、音色、标签、智能描述、九候选视角补全、创建、编辑、保存、退出确认、删除和测试数据清理均已完成。后续不要重复测试本小功能主流程;把已创建主体应用到具体视频模型并生成付费成片属于下一独立边界。
- 竞品入口为 `资产管理 -> 可灵主体库 -> 创建主体`。在 `1512×805` 视口中创建弹窗约 `680×639px`,初始图片和视频入口均约 `304×176px`。图片要求 `2-4张,第一张图必须为清晰正脸的图片`,accept 为 `image/jpeg,image/png,image/webp` 且支持多选;视频要求 `3-8s单一连续镜头的视频`,accept 为 `video/mp4,video/quicktime`,并提示目前只支持人物主体。
- 名称必填、最多 20 字且输入时硬截断;描述必填、最多 100 字。标签菜单固定为 `无标签 / 人物 / 场景 / 道具 / 特效 / 其他`,创建时允许不选,落库后自动归为 `其他`。音色菜单为 `无配音`、六个官方音色和 `添加音色`,添加分为 `本地上传 / 历史生成`;人物限制只作为说明,不会在场景标签下强制禁用菜单。
- 图片和视频入口均先显示 `本地上传 / 历史图库`。图片历史弹窗标题 `选择图片`、上限 `0/4 张`,视频为 `选择视频`、上限 `0/1 张`;来源页签固定为 `LibTV / Lib生成器 / WebUI / ComfyUI / AI应用`,并分别只启用图片或视频媒体开关。本地使用真实用户 `MediaAsset`,历史选择顺序就是主体素材顺序,第一张严格成为主参考图。
- 竞品智能描述费用为 1:选中两张内容并不一致的古装图后,模型仍客观描述两张图中的全部人物和三视图,不假设它们属于同一主体。由此反推提示词应要求“只描述可见内容、身份外观和需要保留的细节,不补造关系”;本地按相同策略调用可用多模态模型,只有成功返回后才扣费,首个模型鉴权失败时会继续尝试后备视觉模型。
- 竞品 `6 智能补全` 只使用第一张图片作为身份主参考,约生成 9 张白底的正面、侧面、背面与转身候选;第二张原图不参与生成。结果页标题为 `AI智能补全其他视角`,要求选择 1-3 张,选择第 4 张会静默保持前三张;确定后素材变为 `第一张主参考图 + 选中的三张候选`,其余原图和未选候选均丢弃。
- 本地九候选后端按正面近景、正面全身、左右 45 度、左右侧面、背面、背面回首和轻微俯视九个方向拆分独立任务,统一约束白底、身份/五官/发型/服装/体型一致且禁止拼图、文字和边框;每个方向失败可重试一次,必须凑齐后台配置数量才返回。任务使用现有模型队列并按候选区分 owner,使同模型可利用全局并发上限;候选数量、最多保留数、费用、模型、画幅、清晰度和基础提示词均可在后台调整。
- 本地 Chrome 实图回归生成了 9 张同一皇帝主体候选,逐张覆盖正面、45 度、左右侧面和背面;选择 `1 / 4 / 7` 后第 4 次点击保持未选,确定后表单精确保留 4 张。当前复制的 `nano-banana-2` 服务在全局并发为 3 时首轮实测耗时约 3.4 分钟,竞品约 30 秒;交互状态机一致,实际耗时仍取决于后台所选模型、供应商和全局并发配置。
- 图片创建在无标签状态下成功,卡片只显示方形主图和名称,悬停显示删除及 `编辑`;编辑表单完整回填并显示标签 `其他`,保存走 PATCH。编辑即使没有可见修改,取消或关闭仍显示 `确定要退出? / 关闭将不会保存任何更改。`。删除弹窗为 `删除主体 / 确认删除吗,删除后将不可恢复 / 取消 / 删除`,成功后卡片消失并提示 `已删除`。
- 视频历史路径使用 5 秒 MP4 验证:图片开关禁用、视频开关启用,选择后通过 3-8 秒校验,表单只保留全宽、静音循环且带播放/进度/当前时间/总时长控件的视频预览,智能描述随之启用;取消并确认退出后没有创建视频主体。
- 主体记录复用 `MediaAsset`,但 `normalizedUrl` 使用独立的 `video-canvas-subject://<uuid>` 标识,避免同一用户的主体记录与主参考素材触发唯一索引冲突;原图、预览、素材 ID 顺序、音色、标签和描述仍以结构化字段保存。编辑不改变该独立标识,删除只清理主体专用上传和音色,不会删除历史图库原资产。
- 后台“主体库”设置已覆盖总开关、历史图库、默认未选标签、图片/视频格式与体积、名称/描述长度、标签、音色、智能描述模型/费用/提示词,以及智能补全模型/费用/候选数/最多选择数/画幅/清晰度/提示词。定向测试 `16/16`、主项目 helper 回归 `246/246`、TypeScript 和相关文件格式检查均通过。
- 竞品临时主体与本地主体均已通过界面删除。本地两张图片、一条 5 秒视频、所有候选图、主体专用素材/音色、相关积分流水均已从数据库清理,测试账号余额恢复为原始 `18`,最终查询为 0 条残留。
## 逐项竞品实测:视频节点引用已创建主体(2026-07-14)
- 状态:模型入口、主体选择、结构化应用、原素材共存、可见提示词边界、刷新持久化、悬停移除、模型恢复、请求映射和测试数据清理均已完成。后续不要重复测试本小功能主流程;没有点击付费生成,模型成片中的主体一致性仍是独立边界。
- 竞品只有切到已实测确认的 `Kling O3` 后才在视频节点工具栏增加独立 `主体`,顺序变为 `参考 / 标记 / 特效 / 主体 / 角色库 / 运镜`。同时模型按钮显示 `Kling O3`,模式保持 `全能参考`,参数摘要从 Seedance 2.0 的 `16:9 · 720P · 5s · 1个` 变为 `16:9 · 5s · 1个`,费用显示 `55`,原参考素材不会因切模型而删除。
- 点击 `主体` 直接打开 `我的主体`,没有角色库的承诺书;空库只显示 `创建主体`。主体卡片提供 `查看 / 使用`,创建入口复用已完成的可灵主体创建流程。本轮为避免重复付费和重复测试创建主流程,只用历史图库两张现有图片创建了临时主体。
- 点击 `使用` 后面板立即关闭,主体以 `48×48` 内联附件出现在原参考素材之前,编号为 `主体1`;工具栏仍保留 `主体` 和 `角色库`,原两个参考素材、全能参考模式、模型、提示词和费用都不变。输入框不会出现主体名称、`@` 引用或展开的一致性提示词,说明主体是节点级结构化附件,不是文本插入或新画布节点。
- 刷新后竞品完整恢复主体图片、`主体1`、Kling O3、全能参考、原参考素材和原提示词。悬停主体缩略图会在右上角出现 `X`,点击后只删除主体附件;模型、模式、提示词和原参考素材仍在。删除主体后切回 Seedance 2.0,工具栏恢复不含主体的普通状态,720P、原费用和原节点内容全部恢复。
- 本地现把主体数组同时保存到视频节点的 `draftValues.videoSubjects` 和 `params.subjects`,字段包含用户主体 ID、名称、描述、分类、图片/视频模式、素材 URL、缩略图和音色。添加会按 ID 去重并遵守后台数量上限;移除只改这两个主体字段,不改提示词、模式、模型、特效、角色或参考连线。Chrome 已按同一步骤验证应用、刷新恢复、移除和模型切回。
- 生成请求携带结构化 `subjects`,后端不信任浏览器传来的名称、描述或 URL,而是按主体 ID 回查当前登录用户自己的 `ready` 主体。随后生成供应商参数 `subjectId / referenceImages 或 referenceVideo / category / description / voiceId`,并在真正提交模型前按后台模板生成隐藏一致性提示词;用户输入框始终保留原文。
- 默认隐藏模板为 `主体{{index}}「{{name}}」:{{description}}` 加身份、五官、发型、服装、体型、颜色、材质和标识一致性约束,只允许动作、表情、机位、光线和环境变化。该模板、支持模型关键字、总开关和单节点主体上限都在后台“视频节点主体引用”中可配置;默认只开启本轮确认过的 `Kling O3`,没有把未实测模型能力写死。
- 双端测试均未点击生成按钮。竞品积分保持 `4,044`,临时主体已从可灵主体库删除,画布缩放恢复 `100%`,测试节点恢复 Seedance 2.0 原状态。本地临时主体记录已删除,节点恢复 `Seedance1.5 Pro / 图生视频 / 16:9 · 720P · 5s · 1个`,原参考图保留,测试账号余额保持 `18`。
- 定向自动化覆盖结构化保存、去重读取、只移除主体、隐藏提示词模板、供应商参数映射、模型开关和后台归一化;本轮定向测试 `22/22`、主项目 helper 回归 `249/249`、TypeScript 与相关文件格式/差异检查通过。
## 逐项竞品实测:Kling O3 智能分镜(2026-07-14)
- 状态:模型入口、开关、镜头增删、时长调整、精确校验、清空、取消确认、保存、刷新持久化、关闭后只读保留和测试状态清理均已完成双端实测。后续不要重复本小功能主流程;没有点击付费生成,最终模型对多镜头指令的服从度仍属于独立边界。
- 竞品仅在 `Kling O3 3min 视频编辑模型、参考一致性、音画同出、多镜头` 下显示 `智能分镜`。节点保持 `全能参考`,参数为 `16:9 · 5s · 1个`,费用按每秒 11 积分计算:5 秒为 `55`,7 秒为 `77`。切回 Seedance 2.0 后入口消失。本地按后台配置中的模型关键字执行同一能力门控。
- 初次打开时开关关闭,只有一个禁用镜头,其内容取当前节点提示词并保留竞品的已有引用标签,时长等于节点总时长。打开开关后镜头可编辑并出现 `+ 镜头`;新增镜头默认 2 秒,单镜头最短 1 秒,支持每秒加减、数字输入和逐镜头删除。竞品数字输入没有显式最大值,实际总时长仍由 3–15 秒约束。
- 点击总时长会打开 `总时长` 滑杆,范围严格为 `3–15`、步长 1。调整总时长会在保存分镜前立即写回节点时长与费用;镜头时长不会被自动缩放。分镜打开时要求所有镜头时长之和与总时长完全相等,先校验空描述,再显示 `分镜总时长超出` 或 `分镜总时长不足`。
- 合法保存后弹窗关闭,入口显示镜头数量徽标;主提示词不被改写。刷新页面并重新选中节点后,开关、镜头描述、逐镜头时长、总时长、徽标和费用全部恢复。`全部清空` 会折叠为一个空镜头,并把该镜头时长设为当前总时长;删除镜头不会自动补齐时长。
- 有未保存修改时点击取消会显示内联确认 `确定要退出? / 取消后将不会保存任何内容`,按钮为 `确认 / 保存`;确认后恢复最近一次保存状态。关闭智能分镜并保存时,已保存镜头仍以禁用只读状态保留,入口计数徽标消失,即关闭能力不等于删除数据。
- 由交互反推,这不是自动拆分文本的生成式功能,而是确定性的结构化分镜编辑器。节点现保存 `{enabled,totalDurationSeconds,shots:[{id,description,durationSeconds}]}`,分别写入 `draftValues.smartStoryboard` 和 `params.smartStoryboard`;可见主提示词保持原文,仅在真正生成时按镜头顺序组装隐藏提示词。服务端重新归一化并校验模型、总时长、镜头描述和精确时长和,不能信任浏览器校验。
- 后台“视频画布”设置新增 `Kling O3 智能分镜`:可配置总开关、支持模型关键字、总时长上下限、单镜头最短时长、新增镜头默认时长、最大镜头数、主提示词模板和逐镜头模板。实现集中在 `lib/video-canvas/smart-storyboard.ts`、视频生成路由、视频画布组件、统一配置层和后台设置页。
- 双端均未点击生成。竞品临时第二镜头已删除,智能分镜关闭并保存,模型恢复 Seedance 2.0、缩放恢复 `100%`,积分保持 `4,044`;本地临时描述已清空,智能分镜关闭并保存,节点恢复 `Seedance1.5 Pro / 图生视频 / 16:9 · 720P · 5s · 1个`,测试账号余额保持 `18`。
- 定向自动化覆盖默认配置、模型门控、初始化、校验顺序、精确时长、结构化持久化、隐藏提示词序列化和后台归一化;本轮定向测试 `7/7`、主项目 helper 回归 `256/256`、TypeScript 与相关文件格式检查均通过。
## 逐项竞品实测:视频节点元素标记(2026-07-14)
- 状态:标记入口、元素选择模式、图片点选、分析状态、候选标签切换、提示词结构化展示、字数边界、刷新持久化、生成请求映射、后台模型/提示词配置和本地清理均已完成双端实测。竞品没有在已观察到的标签菜单、提示词退格、`返回节点` 或 `退出` 路径暴露删除动作,因此删除交互暂不宣称完全对齐;竞品测试节点仍保留一个 `黑色半身裙` 标记,后续发现官方删除入口时只清理该标记,不要重复创建。
- 竞品在视频节点工具栏的 `参考 / 标记 / 特效 / 角色库 / 运镜` 中进入 `标记`,顶部出现 `元素选择模式 / 点击图片选择局部元素 / 返回节点 / 退出`。点击画布图片的具体位置后先显示 `分析中...`,约 6 秒返回悬浮标签和指向点击位置的竖向引导线;本地使用同一状态机和界面文案。
- 竞品点中人物服装后默认返回 `黑色半身裙`,候选为 `女性模特`。点击悬浮标签或提示词末尾的结构化标签都会打开同一候选菜单,当前项显示圆点;切换候选会同步更新来源图片浮层和视频提示词标签。标签不是普通提示词文本,竞品普通字数始终保持 `122/135`。
- 本地原先因 SSRF 防护拒绝所有环回地址,导致 `/uploads/local/...` 图片无法交给视觉模型。本轮新增受信任本地图片解析:只允许当前仓库 `public/uploads/local` 下的真实文件,拒绝目录穿越、非上传路径和逃逸目录的符号链接;其他环回或内网 HTTP 地址仍继续阻断。
- 本地点选完成后现在与竞品一样打开被点图片节点的编辑器,同时继续把结果写回原视频节点。图片浮层候选菜单已由只显示当前项改为完整遍历 `labelOptions`,显示选中圆点并支持切换;目标视频 ID 始终保存在标记模式状态中,不会因图片节点被选中而写错节点。
- 标记以 `{id,sourceNodeId,imageUrl,clickX,clickY,labelOptions,selectedIndex}` 保存到目标视频的 `draftValues.magicMarks` 和 `params.magicMarks`。刷新后结构化标签恢复,普通提示词和字符计数保持不变;文本生视频存在标记时会把已标记图片作为结构化参考图,其他模式继续按模型参考图规则选择素材。
- 识别接口先把归一化点击坐标映射到缩放后的图片,用红色圆圈和十字线合成视觉输入,再调用后台 `element-mark` 功能项配置的多模态模型。当前配置为 `qwen3-vl-plus`,提示词要求识别圆心所属完整主体或局部、返回最多 4 个不超过 8 字的中文层级标签及置信度;模型和识别提示词都可在后台视频画布功能预设中编辑。
- 真正生成时不提前改写可见文本,而是读取每个标记当前选中的标签,再按 `element-mark.generationPrompt` 模板注入隐藏提示词。默认模板为 `重点遵循已标记元素:{{labels}}`;后台支持带 `@` 的 `{{labels}}` 和纯文本 `{{plainLabels}}`,留空可关闭注入。模板逻辑已提取到 `lib/video-canvas/video-marks.ts`,不再散落硬编码在画布组件中。
- 本地通过图片浮层的 `移除标记 <标签>` 按钮逐个删除 3 个测试标记,等待自动保存后刷新同一项目,数据库目标节点标记数组为 0,界面结构化标签也消失;账号余额保持 `18`,项目恢复 `4` 节点且缩放为 `100%`。竞品创建、切换和刷新均未扣积分,余额保持 `4,044`,最终缩放恢复 `100%`,但保留上述单个待清理标签。
- 安全图片输入、提示词模板和统一配置定向测试全部通过;本轮主项目 helper 回归 `262/262`、TypeScript 和相关文件格式检查通过。
## 逐项竞品实测:视频节点高级设置(2026-07-14)
- 状态:入口、折叠、两个开关、节点级持久化、后台默认值和生成请求映射均已完成双端实测;双方开关均已恢复开启。后续不要重复测试本小功能主流程;没有点击付费生成,联网检索的供应商实现和业务素材自动校验的具体判定规则仍属于独立边界。
- 竞品视频编辑器底部工具栏的第三个无文字图标用于展开或收起高级设置。展开后按顺序显示 `联网搜索` 和 `自动校验素材` 两个独立开关;当前节点初始均为开启,点击图标收起再打开时保留修改状态。
- 竞品把两个开关保存到当前视频节点:将二者关闭后刷新页面,重新选择同一节点并打开高级设置,两个开关仍为关闭。它们不是全局浏览器偏好,也不是只存在于弹层生命周期中的临时状态。
- 本地原实现的视觉、文案、顺序和折叠内状态与竞品一致,但刷新后两个开关会重置为开启。本轮已把每次切换立即合并到目标节点 `params.settings` 并走现有项目自动保存;Chrome 按相同步骤关闭、刷新和重新选择节点后仍为关闭,随后恢复开启并再次持久化。
- 兼容读取集中在 `lib/video-canvas/video-advanced-settings.ts`,规范字段为 `searchEnabled` 和 `autoValidateMaterial`,同时接受已有数据可能使用的 `search_enabled`、`searchable` 和 `auto_validate_material`。合并更新只覆盖这两个字段,不会丢失同一对象中的模式、模型或其他设置。
- 后台“视频高级设置默认值”可分别配置新节点或旧节点缺失字段时的默认联网搜索与自动校验素材状态。生成服务和视频生成接口均携带并回传本次实际采用的两个布尔值;服务端对缺失值再次使用后台默认值,不能信任客户端自行补齐。
- `自动校验素材=false` 不会关闭 URL、内网访问或 SSRF 等安全边界;这些检查始终强制执行。本轮也没有在缺乏付费生成证据时伪造联网搜索算法、供应商参数或素材合规提示词,只完成已观察到的状态、持久化和请求契约。
- 最终数据库确认本地测试节点保存 `searchEnabled=true`、`autoValidateMaterial=true`,账号余额保持 `18`;竞品面板已收起、缩放恢复 `100%`、两开关恢复开启,积分保持 `4,044`。定向测试覆盖默认值、旧字段兼容、无损合并和请求映射;本轮主项目 helper 回归 `266/266`、TypeScript 与相关文件格式检查均通过。
## 逐项竞品实测:视频生成参数面板(2026-07-14)
- 状态:比例、清晰度、时长、生成音频、生成数量、实时费用、输入提交、节点级持久化、后台配置和请求字段均已完成双端实测。后续不要重复测试本小功能主流程;真实 4K 成片和一次任务返回多条视频仍是独立的付费供应商边界。
- 竞品参数面板固定按 `比例 / 清晰度 / 视频时长 / 生成音频 / 生成数量` 排列。比例顺序为 `Auto / 16:9 / 4:3 / 1:1 / 3:4 / 9:16 / 21:9`,清晰度为 `480P / 720P / 1080P / 4K`,数量为 `1个 / 2个 / 4个`,音频为 `开启 / 关闭`;本地面板已使用同一顺序和约 `384×441px` 的尺寸。
- Seedance 2.0 时长滑杆与数字输入范围均为 `4–15s`、步长 1。数字输入只修改草稿,按 Enter 或失焦才提交到摘要;输入 `2` 会钳制到 `4`。选择任一参数后面板保持打开,摘要立即更新并保留末尾分隔点,例如 `3:4 · 1080P · 7s · 2个 ·`。
- 竞品费用可确定为 `清晰度每秒积分 × 时长 × 数量`:480P 为 13/秒、720P 为 27/秒、1080P 为 68/秒、4K 为 140/秒;实测 `1080P × 7s × 2 = 952`、`4K × 4s × 4 = 2240`。音频开关在本轮组合中不改变费用。Seedance 2.0 的 720P 六秒原价为 162,页面按模型折扣显示 `146 / 162`。
- 竞品把五项参数全部保存到当前视频节点。临时设置 `3:4 / 1080P / 7s / 关闭 / 2个` 在面板收起重开和整页刷新后都完整恢复;它不是弹层临时状态或全局用户偏好。
- 本地原实现只有面板内存状态,刷新后重置为 `16:9 / 720P / 5s / 开启 / 1个`,价格也一直固定为 135。本轮把规范字段同步写入节点顶层参数和 `params.settings`,兼容已有字符串/布尔/数字形式,并在合并时保留模型、模式、高级设置和其他节点数据。
- 本地按同一路径设置后,摘要立即变为 `3:4 · 1080P · 7s · 2个 ·`,费用变为 952;收起重开、自动保存、刷新重选节点和数据库查询均恢复五项精确值。随后已通过界面还原并再次刷新,数据库最终为 `audio=true / count=1 / duration=5 / ratio=16:9 / resolution=720p`。
- 后台“视频生成参数面板”可配置可见比例/清晰度/数量、四个默认值、音频默认开关、时长上下限与步长、参数计价开关,以及每个清晰度的每秒积分。配置会统一归一化,生成数量只允许竞品已确认的 `1 / 2 / 4`。
- 生成服务与接口现在传递并回显 `generateAudio` 和 `count`,服务端再次校验数量。由于没有点击付费生成,`count=2/4` 当前只作为明确的任务契约,不会假装已经完成多结果供应商调度;真正的批量生成、逐条扣费、部分失败和结果节点布局需要在独立付费测试中确认。
- 4K 不再被静默改写成 1080P;请求会保留 `4k`,未声明 4K 能力的当前模型会明确拒绝。Krea 音频参考路径也在未验证前拒绝 4K。真实 4K 输出能力、供应商参数和最终费用必须用支持该档位的模型另行付费验证。
- 双端均未点击生成按钮。竞品最终恢复 `16:9 / 720P / 6s / 开启 / 1个`、价格 `146 / 162`、缩放 `100%`、积分 `4,044`;本地恢复 `16:9 / 720P / 5s / 开启 / 1个`、价格 135、缩放 `100%`、余额 `18`。定向测试 `22/22`、主项目 helper 回归 `273/273`、TypeScript 和相关文件格式/差异检查均通过。
## 逐项竞品实测:视频模型选择器(2026-07-14)
- 状态:菜单入口、31 项目录、顺序、滚动、选中态、短名称、活动/会员标识、模型切换、参数模式联动、费用联动、节点级持久化、真实后端模型映射和后台配置均已完成双端实测。后续不要重复测试本小功能主流程;没有点击付费生成,不宣称本地单一视频后端能够复现 31 个竞品模型各自的成片质量。
- 竞品菜单约 `369×400px`,固定从编辑器模型按钮上方展开,列表超出后在面板内滚动。无搜索、分类或二级页;当前模型行显示背景和说明,普通行在悬停时展开说明。DOM 共 31 个按钮,本轮观察到的全部模型均可选,原本地误禁用的 `Hailuo 2.3 Fast / Kling3.0 动作迁移 / MJ Video` 已恢复可选。
- 竞品精确顺序为 `Seedance 2.0 VIP / Seedance 2.0 Fast VIP / Seedance 2.0 Mini / Happy Horse 1.1 / Happy Horse 1.0 / Kling O3 / Kling 3.0 / Wan 2.7 / Kling O1 / Kling 2.6 / Kling3.0 动作迁移 / MJ Video / Hailuo 02 / Vidu Q2 / Vidu Q2 Pro / Vidu Q2 Turbo / Kling 2.5 / Kling 2.1 / Pixverse V5.5 / Pixverse V5 / Kling 3.0 Turbo / Wan 2.6 / Hailuo 2.3 Fast / Hailuo 2.3 / Seedance1.5 Pro / Seedance 1.0 Pro / Seedance 1.0 Lite / Vidu Q3 Pro / OmniHuman 1.5 / Wan 2.2 / Wan 2.5`。本地已使用同一顺序、预计耗时、说明和已观察到的会员/折扣标识。
- 竞品从 Seedance 2.0 VIP 切到 Fast 后菜单立即关闭,按钮短名称从 `2.0` 变为 `2.0 Fast`,参考模式和参数保持 `全能参考 / 16:9 / 720P / 6s / 1个`,费用从 `146 / 162` 变为 `119 / 132`;刷新并重新选择节点后 Fast 仍恢复。切回 VIP 后恢复原短名称和费用。
- 由费用反推,VIP 720P 原价为 27 积分/秒并应用 0.9 折扣系数,Fast 为 22 积分/秒并应用同一系数:`27×6=162 -> 146`、`22×6=132 -> 119`。本地模型目录支持按清晰度覆盖每秒积分和折扣系数;五秒测试得到 VIP `122 / 135`、Fast `99 / 110`,Kling O3 继续保持已确认的 `11/秒`。
- 原本地菜单是前端硬编码目录,除首项外切换只改 `modelLabel` 并沿用上一次 `modelRef`。本轮新增 `videoModelCatalog` 统一配置:每项保存稳定 ID、完整/短名称、说明、预计耗时、品牌图标、会员/活动标识、显示开关、真实模型引用、各清晰度积分和折扣。点击任何展示别名都会解析明确的后端模型;配置为自动且功能默认仍为自动时,从 `/api/models` 中选择第一个真实视频模型,不再偷偷继承旧值。
- 本地复制的实际视频后端当前只有 `Agnes Video v2.0 / agnes-video-v2.0`。Chrome 切到 Fast 后,数据库确认节点 `model=settings.model=agnes-video-v2.0`、`modelLabel=Seedance 2.0 Fast VIP`、`mode=reference-images`;刷新后短名称、费用和模式均恢复。切回 Seedance1.5 Pro 后真实模型引用仍为 Agnes,模式按既有模型规则恢复 `first-frame`,显示和真实路由保持分层但不再含糊。
- 后台“视频模型选择器”已显示完整 31 项并保存到本地 `video_canvas_function_config`。管理员可展开每项编辑全部展示、排序、开关、费用和映射字段;真实模型下拉已实测包含 `Agnes Video v2.0 · agnes-video-v2.0`。实现集中在 `lib/video-canvas/video-model-catalog.ts`、统一视频画布配置、画布组件、后台设置页和定向测试。
- 双端均未点击生成。竞品最终恢复 `2.0 / 全能参考 / 16:9 · 720P · 6s · 1个 / 146/162 / 100% / 4,044`;本地恢复 `Seedance1.5 Pro / 图生视频 / 16:9 · 720P · 5s · 1个 / 135 / 100% / 18`,模型菜单关闭。定向回归 `21/21`、主项目 helper 回归 `278/278`、TypeScript、相关文件格式检查与差异检查通过;全仓格式检查仅被 5 个既有 `output/` 验收 JSON 的排版问题阻断。
## 逐项竞品实测:工具箱工作流模板(2026-07-14)
- 状态:入口、面板层级、分类切换、帮助提示、30 项模板目录、封面、使用动作、节点尺寸、相对坐标、3 条连线、当前视口落位、缩放保持、后台编辑和数据库持久化均已完成双端实测。后续不要重复本功能主流程;模板实际生成仍沿用各节点所选模型,不在浏览目录或插入预设时发起付费任务。
- 竞品工具箱是底部居中的 `480×460px` 面板,位于视频编辑器等画布浮层之上。顶栏包含 `我的工具箱 / 工具箱模板说明 / 周星驰经典名场面 / 关闭`;帮助气泡为 `280×58px`,文案为 `使用工具箱模板加速创作,快速构建你的专属工具箱。`,并提供同一飞书教程链接。
- `我的工具箱` 精确包含 25 项,顺序为:`左弧滑行 / 电商手机弹出效果 / 咖啡杯出场 / 360旋转展示 / 机械臂视角 / Live 2D / 瞳孔拉近 / 飞鸟解体 / 破盒而出 / 商品震撼登场 / 右弧滑行 / 左弧滑行 / 颠倒空间 / 反重力漂浮 / 粒子融解 / 旅拍转场 zoom in / 旅拍转场 zoom out / 旅拍转场 向右旋转 / 旅拍转场 向左旋转 / 旅拍转场 生长 / 英雄视角 / AI模特服饰动态展示 / 机械臂视角 / 大师分镜九宫格-经典暗调 / AI室内装修效果预览`,界面保留竞品重复项和原始顺序。
- `周星驰经典名场面` 精确包含 5 项:`喜剧之王-枪战 / 喜剧之王-神秘富豪 / 食神-食神大赛 / 食神-撒尿牛丸 / 七仔-修风扇`。两类共 30 个封面均按目录顺序展示,分类切换只过滤列表,不重排或改写模板。
- 点击首项 `使用` 会关闭面板并保持当前缩放为 `100%`,创建一个已选中的 `分组 3 个节点`。竞品实测分组为 `1493×658`;参考图相对分组为 `(40,40,350,525)`,提示词为 `(525,45,350,200)`,成品为 `(1104,93,350,525)`,连线固定为 `参考图→提示词 / 提示词→成品 / 参考图→成品`。
- 落位算法不是固定世界坐标或自动缩放,而是把分组原点放在当前屏幕中心再偏移 `(-175,-175)`,并通过当前 viewport 反算世界坐标。本地 1512×861 视口实测分组边框为 `x=581 / y=255.5 / 1493×658`,三节点相对位置与竞品一致,模板 3 条边叠加原项目 3 条边后为 6 条。
- 本地移除了组件内旧的 10 项硬编码目录。`lib/video-canvas/toolbox-workflows.ts` 现在统一维护分类、模板、封面、原创等价提示词、帮助链接、节点布局和落位参数;`video_canvas_function_config.toolbox` 负责运行时下发,前端不会复刻竞品受版权保护的长提示词原文。
- `/settings/video-canvas` 新增“工具箱工作流模板”,可编辑总开关、帮助文案与 URL、分类、排序、显示状态、封面、参考/成品 URL、提示词以及分组和三个节点的尺寸坐标。配置通过统一规范化层保存到数据库,禁用工具箱或单项后前端入口和列表会同步更新。
- 最终视觉复测确认分组标题以及 `参考图 / 提示词 / 成品` 标题均使用画布外侧 14px 紧凑样式,卡片占满配置高度;帮助气泡不再被面板滚动容器裁切。竞品和本地测试分组均已删除,面板关闭;本地数据库恢复 4 个原始节点、3 条原始边和 `{x:0,y:0,zoom:1}`,竞品积分与本地余额均未变化。
- 工具箱与统一配置定向测试 `21/21`、主项目 helper 回归 `283/283`、TypeScript 检查均通过;StoryAI 上游复用审计同时确认当前固定提交仍为上游 `main` 最新提交,并补齐本地静态发行包的 MIT 许可证与来源声明。
## 逐项竞品实测:历史资产(2026-07-14)
- 状态:入口开关、面板尺寸、三类媒体页签、真实数量、日期分组、时间排序、四档缩放、图片/视频/音频预览、翻页与 ESC、单项删除、批量选择/删除、下载入口、使用后节点创建、后台配置和数据库持久化均已完成双端实测。后续不要重复本小功能主流程;下载水印规则本身已在既有“视频下载与水印规则”功能记录中单独完成。
- 竞品面板宽度为 `min(90vw,1600px)`、高度为 `calc(100vh - 160px)`,1512px 宽视口实测为 `1360.8px @ x=75.6 / y=80`。账号当前页签为 `图片历史(17) / 视频历史(11) / 音频历史(8)`,记录按真实日期分组;`时间降序` 点击后直接切换为 `时间升序`,不是二级菜单。
- 缩放严格为四档:`75% / 100% / 125% / 150%`,卡片边长分别为 `130 / 144 / 182 / 248px`,到上下限后对应按钮禁用。100% 时横向间距 12px,1512px 视口每行最多 8 项;本地使用同一尺寸、间距和上下限行为。
- 图片、视频和音频卡片悬停均显示 `查看 / 使用 / 下载` 以及右上角删除按钮;图片左上角有 `35×17px` 来源标识。图片预览支持左右翻页、计数和 ESC;视频预览包含原生播放及 `1x` 速度显示并尝试自动播放;音频预览为原生 420px 音频控件并自动播放。本地图片标识使用可配置的本站 `AI` 文案,不复制竞品商标。
- 单项删除弹窗为 `删除记录 / 确认删除吗,删除后将不可恢复`,批量弹窗为 `批量删除 / 确认批量删除 N 项`。批量模式显示每个日期组和每张卡片的复选框,选中后 `删除 / 下载 / 使用` 同时启用,`取消选择` 返回普通模式。
- 竞品点击图片 `使用` 会关闭面板,在当前视窗中央创建已选中的完整图片节点;本轮临时节点实测为 `350×465px @ (581,170)`,删除后恢复空视窗。由交互可确定历史功能本身是确定性的媒体记录检索、按创建时间排序和节点复用,不涉及可反推的生成提示词;节点中的模型和提示词来自原资产或原节点元数据。
- 本地删除了 `DEFAULT_IMAGE_HISTORY_NODES / DEFAULT_VIDEO_HISTORY_NODES / DEFAULT_AUDIO_HISTORY_NODES` 假数据和固定 `2026-07-09`,也不再只取前 8 项。专用 `/api/video-canvas/history-assets` 只返回当前登录账号的 `ready image/video/audio MediaAsset`,排除主体素材,按 `createdAt` 降序并受后台数量上限控制;Chrome 从 StoryAI `全景图 -> 历史记录` 打开宿主面板后同样显示 `7 / 0 / 0`,确认两个入口复用同一真实数据源。
- 历史删除把 `videoCanvasHistoryHiddenAt` 写入资产元数据并从列表永久隐藏,不物理删除仍可能被画布引用的媒体文件。临时记录实际确认后,界面数量从 8 回到 7,数据库时间戳存在;该临时行随后已彻底清理。图片、视频、音频临时测试资产及复用节点也全部删除。
- `video_canvas_function_config.historyAssets` 已通过 `/settings/video-canvas` 保存到本地配置数据库:`enabled=true / maxItems=300 / defaultZoomPercent=100 / previewAutoplay=true / showWatermarkBadge=true / watermarkBadgeText=AI`。最终测试账号为 `7 / 0 / 0` 条真实媒体记录,项目恢复 4 节点、3 条边和 `{x:0,y:0,zoom:1}`;竞品积分 `4,044`、本地余额 `18` 均未变化。
- 历史资产与统一配置定向测试 `20/20`、TypeScript 和相关文件格式检查均通过;新增测试覆盖四档尺寸、ID 归一化、时区日期分组、默认配置与管理员输入钳制。
## 逐项竞品实测:快捷键帮助面板与核心执行键(2026-07-14)
- 状态:工具栏入口、激活态、面板尺寸、四列分组、全部帮助行、组合键键帽、手势图示、关闭边界,以及已确认的 `⌘L / Tab / ⌘D / ⌘Z / ⌘⇧Z / Delete` 执行链均已完成双端实测。后续不要重复帮助面板和这些核心按键;成组、合并分镜组、解组和两种修饰键拖拽已在后续独立边界完成,空格拖动画布已修复普通拖拽差异但仍需实体按住验收,剩余付费生成键。
- 1512×861 视口下,竞品快捷键面板为 `1152×436px @ (180,300)`、圆角 16px、24px 内边距、半透明 `#262626` 背景。四列标题均为 14px 青色文字,坐标依次为 `x=204.5 / 492.5 / 780.5 / 1068.5`,每列宽 239px;本地最终测量的尺寸、坐标、标题宽高和桌面端 `overflow: visible` 与竞品逐项相同。
- `创作` 列精确为 `成组 / 合并分镜组 / 解组 / 连线 / 复制节点和连线 / 生成 / 新建节点 / 节点复制 / 创建副本`;`缩放` 为 `放大 / 缩小 / 适应画布 / 触控板 / 鼠标`;`移动画布` 为 `键盘 / 触控板 / 鼠标 / 整理画布`;`其他` 为 `撤销 / 重做 / 删除`。本地补回原先缺失的 `创建副本`,并把 Command、Option、Shift、普通键、删除键和鼠标/触控板操作拆成与竞品一致的 28px 键帽或图示,不再显示错误的拼接字符串。
- 面板没有全屏遮罩。竞品和本地点击外部画布或按 `Esc` 都保持打开;再次点击底部快捷键入口或点击右上角关闭按钮才关闭。原本地全局 `Escape` 会误关面板,本轮已按当前活动面板做了例外处理,同时保留其他弹层和连线模式的 Escape 行为。
- 竞品临时文本节点实测:裸 `L` 无动作,`⌘L` 才进入连线并因文本节点规则显示 `不符合连线规则`;`⌘D` 把节点数从 1 变为 2,副本标题为 `文本节点 2 - 副本`,`⌘Z` 回到 1,`⌘⇧Z` 回到 2,删除回到 1,再次撤销恢复到 2。竞品空画布按 `Tab` 打开与工具栏相同的 `添加节点` 菜单,并可创建不扣费文本节点。
- 本地原实现使用裸 `L`,且 `Tab / ⌘D / ⌘Z / ⌘⇧Z` 都没有画布动作。本轮改为 Command/Ctrl 修饰的连线入口,增加当前画布快照栈,并把新增、复制、删除纳入可撤销/重做操作;`Tab` 在视口中心打开现有新增节点菜单,不复制第二套菜单。两种修饰键拖拽的真实指针手势已在 2026-07-15 独立边界完成。
- 本地 Chrome 同路径回归为原项目 4 节点,`Tab` 创建后 5,`⌘D` 后 6,撤销 5,重做 6,删除 5,撤销 6,清理两个临时节点后恢复 4。等待自动保存并整页刷新后仍为 4 节点、无临时标题、缩放 100%、余额 18;竞品两个临时文本节点也已通过节点菜单删除,最终 0 节点、缩放 100%、积分 4,044。
- 快捷键目录已集中到 `lib/canvas/shortcut-help.ts`,避免面板 JSX 内散落顺序和组合键字符串;3 项定向测试覆盖分组顺序、独立键帽、拖拽后缀、缩放/移动手势及撤销/重做/删除表示。
## 本地隔离测试数据库基线(2026-07-14)
- 状态:本地数据库运行时、全量迁移、服务器配置精简复制、固定管理员测试账号、固定空白视频工程、重启持久化和源/目标哈希校验均已完成,后续不要重复搭库。旧记录中的本地 `18` 积分与 `3/4` 节点属于上一套已离线测试库;从本节起以新基线为准。
- 服务器生产库实测约 `9.0 GB`,没有把 703 个用户及聊天、媒体、任务等业务数据整库下载。本地使用 `.local/pglite` 的隔离 PostgreSQL 兼容库,`.local/` 已加入忽略;`pnpm db:local:video-canvas` 固定监听 `127.0.0.1:15432`,与 `.env.local` 一致,停止并重启后数据完整保留。
- `pnpm db:sync:video-canvas` 只通过临时 SSH 只读隧道拉取视频画布所需配置,目标写入前强制验证为本地 PGlite,避免误写生产。当前复制并规范化哈希逐表核验通过:`34 AiProvider / 23 AiModel / 20 SystemSettings / 0 ToolConfig / 4 StorageConfig / 1 SiteSettings`;供应商和存储密钥随配置进入本地隔离库但不打印,外部登录密钥不复制,本地认证固定关闭钉钉/Google、允许邮箱密码登录。
- 固定账号为 `canvas-test@local.dev / CanvasTest2026!`,角色 `admin`、积分 `100000`;固定工程 ID 为 `00000000-0000-4000-8000-000000000201`,标题 `视频画布竞品对照`,初始状态为 `0 节点 / 0 连线 / 100%`。密码 bcrypt 校验、账号字段、工程归属和重启后四类复制计数均已再次查询通过。
- 直接打开 `/canvas/00000000-0000-4000-8000-000000000201` 现在会从项目自身的 `workspaceType=video` 选择视频画布,不再要求 URL 额外携带 `?workspaceType=video`;Chrome 已确认普通直达地址显示工作流/故事板切换并保持空白视频工程。显式查询参数仍可覆盖项目类型,兼容原有入口。
- PGlite Socket 与 Next.js 开发服务并发连接时会发生查询参数错配,表现为 `membership_config` 被误绑定到 UUID 查询、积分暂时显示 `0`。本地 `.env.local` 现固定 `POSTGRES_MAX_CONNECTIONS=1`;重启后会员、模型和画布接口均返回 `200`,同一固定工程再次显示 `100,000` 积分。该约束只用于隔离测试库,不改生产数据库连接池。
- 本地入口为 `http://127.0.0.1:3003/video-canvas`,未登录时正确跳转 `/login?callbackUrl=%2Fvideo-canvas`。本轮 TypeScript、相关文件 Biome、`git diff --check` 和主项目 helper `295/295` 通过;服务器 SSH 与数据库隧道在同步完成后均已关闭,本地运行不依赖生产连接。
## 逐项竞品实测:成组与解组(2026-07-14)
- 状态:`⌘G 成组 / ⌘⇧G 解组 / 撤销 / 重做 / 组内节点关系 / 组选中与拖动基础` 已完成竞品取证、本地实现和本地 Chrome 实页回归,后续不要重复这条主流程。`⌘⌥G 合并分镜组` 需要同时选中多个组,作为下一条独立边界继续;本轮只确认单个组以及三个普通子节点执行该命令均无动作。
- 竞品插入工具箱模板后得到一个 `分组 3 个节点` 外框和参考图、提示词、成品三个子节点。组选中时按 `⌘⇧G`,组数从 1 变为 0,三个子节点保留并全部进入选中态;紧接着按 `⌘G`,得到标题为 `整组执行` 的新组,只有组外框被选中,三个子节点取消选中。
- 竞品新组实测边界为内容联合矩形外扩:左、右各 20px,上方标题区 56px,底部 20px。`⌘Z` 回到“无组、三个子节点全选”,`⌘⇧Z` 恢复同一个组 ID、相同几何和单组选中态,说明成组/解组是确定性的画布状态变换,不涉及模型、提示词或后端生成算法。
- 本地原来只有单个 `selectedNodeId`,工具箱组也只是不可点击的视觉边框,子节点没有父组关系。本轮升级为持久化 `selectedNodeIds[]`,组保存 `childNodeIds`,子节点保存 `parentGroupId`;组标题和四条边框可点击、右键和拖动,框内区域保持指针穿透以继续编辑子节点。拖动组时递归带动全部子节点,删除组会删除其后代,复制组会复制子节点与组内连线。
- 本地 `⌘G` 使用同一外框留白创建 `整组执行`,选择组后 `⌘⇧G` 删除外框、清除子节点父组字段并一次选中全部子节点;两次操作都进入现有 100 步快照历史,撤销/重做同时恢复节点、连线、选中数组和视口。工具箱新建的三节点模板也写入相同关系,不再维护一套仅用于展示的特殊组数据。
- 本地 Chrome 在空工程创建两节点首帧视频夹具后实测:多选为 2 个,`⌘G` 后为 `3 节点 / 1 组 / 仅组被选中`,`⌘⇧G` 后为 `2 节点 / 0 组 / 两个子节点全选`;撤销恢复前者、重做恢复后者。连续撤销后工程精确回到测试前 `0 节点 / 0 组`,未留下本地夹具。
- 竞品本轮曾因重叠节点上的普通点击落到节点内部执行控件,积分从 `4,044` 意外变为 `3,948`;未主动发起付费生成,后续竞品节点交互必须优先用标题、ID 或键盘,避免在卡片内容区点击。竞品测试夹具清理的批量撤销在大型画布上超时且执行步数无法确认,为防止误撤销原项目操作,不再盲目重发;下次连接先按已知组 ID `g-M2dxX2RL1M / g-2lbzmhHvgo` 和对应九个子节点 ID 做存在性核查,再只清理仍存在的夹具。
- 新增 `lib/canvas/grouping.ts` 统一计算边界、绑定/解除父子关系、递归展开组后代和整组复制;5 项定向测试覆盖实测几何、字段保真、嵌套组展开、整组复制与单个组内子节点复制。全套辅助测试 `295/295`、TypeScript 与差异空白检查通过;主画布组件因文件为 1.3MiB、超过项目 Biome 1MiB 上限而不进入单文件格式器,新增独立文件均通过 Biome。
## 逐项竞品实测:合并分镜组(2026-07-15)
- 状态:`⌘⌥G 合并分镜组 / 精确多选 / 子节点与连线保留 / 撤销 / 重做 / 自动保存` 已完成竞品取证、本地实现和 Chrome 实页回归,后续不要重复这条主流程。普通组、单个分镜组和普通节点执行该命令均无动作,只有同时选中至少两个分镜组才进入合并。
- 竞品真实分镜组标题为 `分镜图 · 我在盛唐写天下`。复制粘贴产生的副本标题追加 ` - 副本`,位置相对原组精确偏移 `(+5,+5)`;用 Shift 窄框选只选中原组和副本后,帮助面板显示的产品快捷键为 `⌘⌥G`。Chrome 自动化环境需发送 `Control+Alt+G` 才能触发,这是浏览器键盘映射差异,本地同时支持 Command/Ctrl 与 Alt 组合。
- 竞品合并前为 `41 节点 / 33 连线 / 2 个已选分镜组`;合并后为 `40 节点 / 32 连线 / 1 个已选普通组`。两个分镜组外框被删除,34 个直接子节点保持原位置,新组标题精确为 `分组 34 个节点`,组选中工具栏显示 `整组执行 / 添加到工具箱 / 转分镜组 / 解组 / 批量下载`。唯一连接两个源组的组级连线被删除,子节点之间的连线全部保留。
- 竞品合并组在 10% 缩放下的可见边界为 `88.4976×682.4977px @ (533.5513,471.5013)`。一次撤销恢复相同两个源分镜组、41 个节点、33 条连线和双组选中态;一次重做恢复相同合并组 ID、40 个节点、32 条连线和单组选中态,说明 ID 与完整画布快照都进入既有历史栈。
- 本地新增 `mergeCanvasStoryboardGroups`,按选择顺序去重收集两个源组的直接子节点,使用既有 `左/右 20px、顶部 56px、底部 20px` 联合边界创建普通组;清除源组、重新绑定 34 个子节点、只过滤以源组为端点的连线,并把一次合并作为单个撤销快照。分镜组识别兼容 `CANVAS_STORYBOARD_GROUP`、`groupType=storyboard`、`storyboardGroup=true` 和持久化标题前缀。
- 本地同构夹具实测从 `36 节点 / 2 组 / 3 连线 / 双组选中` 变为 `35 节点 / 1 组 / 2 连线 / 单组选中`,标题为 `分组 34 个节点`;撤销精确恢复两个源组,重做恢复相同合并组 ID。等待自动保存并整页刷新后仍保持 35 个节点、2 条连线和同一组 ID,确认服务端持久化链路生效。
- 竞品复制组和合并组均已通过撤销清理,最终回到原项目 `31 节点 / 33 连线`;本地夹具已直接恢复固定空白工程并刷新核验为 `0 节点 / 0 组 / 0 连线 / 100%`。该功能完全是确定性的节点、父子关系、边和历史快照变换,不调用模型,也没有可反推的后端算法或提示词。
- 分组定向测试扩展为 `7/7`,新增覆盖双分镜组几何、34 类同序子节点重绑、组级边过滤、子节点连线保留和普通组无动作;全套 helper `297/297`、TypeScript、新增独立文件格式和差异空白检查均通过。
## 逐项竞品实测:修饰键拖拽复制(2026-07-15)
- 状态:`Option + 拖动节点 / Command + Option + 拖动 / 单节点 / 多选节点 / 选区内部连线 / 标题 / 选中态 / 撤销 / 重做 / 自动保存` 已完成竞品取证、本地实现和 Chrome 实页回归,后续不要重复这两条拖拽主流程。
- 竞品单文本节点实测时,两种手势都保留原节点、创建新 ID、把副本拖到指针释放位置,并把标题从 `文本节点 2` 变为 `文本节点 2 - 副本`;副本被选中,节点数从 32 变为 33,连线仍为 33。再次复制已有副本不会叠加第二个后缀。
- 多选边界使用两个文本节点和一条内部连线取证。`Option + 拖动` 从 `33 节点 / 34 连线` 变为 `35 / 34`,复制当前完整选区但不复制任何连线;`Command + Option + 拖动` 变为 `35 / 35`,只额外复制两个源节点之间的内部连线。外部连线不属于副本。多节点复制完成后不保留副本选中态;单节点复制仍选中唯一副本。
- 两种操作都是确定性的前端画布状态变换。竞品一次撤销分别删除本次生成的两个节点及可选内部边,一次重做恢复相同数量;不触发积分变化,不调用模型,也没有后端提示词或生成算法可反推。
- 本地新增通用 `duplicateCanvasNodeSelection`:展开选中组的全部后代、保持组内父子 ID 映射、对每个选区根节点幂等追加一个 ` - 副本`,并由 `includeInternalEdges` 精确决定是否重建选区内部边。拖拽未命中当前选区时只复制被按下节点;命中现有多选时复制整个选区。Command 和 Ctrl 都作为跨平台的 Command 语义。
- 本地同构夹具实测:两个节点和一条内部边执行 Option 后为 `4 节点 / 1 连线`,Command+Option 后为 `4 / 2`;撤销回到 `2 / 1`,重做恢复 `4 / 2`。等待自动保存并整页刷新后仍为 `4 / 2`,随后固定测试工程已恢复并刷新核验为 `0 节点 / 0 连线 / 100%`。竞品夹具也按历史逐步清理回 `31 节点 / 33 连线 / 10%`。
- Chrome 实测同时复现了超大合成层偶发不可见:`20000×20000px` 画布世界层在选择更新后可能被 GPU 合成器错误裁切,节点仍在 DOM 和数据库但截图与命中区域消失。本地把视觉世界层降为仍可溢出渲染的 `12000×12000px`,节点与边不受边界裁剪,重载、选中、连线和拖拽测试均恢复稳定。
- 分组定向测试扩展为 `9/9`,新增覆盖 Option 不复制内部边、Command+Option 只复制内部边、外部边排除和副本标题后缀幂等;全套 helper `299/299`、TypeScript、独立文件格式和差异空白检查均通过。
## 逐项竞品实测:空格拖动画布(2026-07-15,待实体按住验收)
- 状态:竞品普通左键空白拖拽、本地旧行为差异、本地 Space 键状态机、左键/中键激活规则、节点起拖优先级、松键与失焦清理、普通拖拽回归均已完成;Chrome 当前拖拽接口不能保持非修饰键,`Space + 左键` 的实体按住结果仍未直接取证,因此本节不标记为完整完成。后续只补这一次实体手势,不要重复普通左拖和实现工作。
- 双端在同一空白坐标从 `(300,200)` 拖到 `(420,260)`:竞品 React Flow 视口始终为 `translate(407.725px,542.475px) scale(0.1)`,普通左拖不平移;本地旧实现却从 `translate(0,0)` 变为 `translate(120px,60px)`。反向拖动恢复基线后重测结果相同,确认不是页面未聚焦或偶发丢事件。
- 竞品帮助面板把键盘平移精确表示为 `Space + 鼠标`,DOM 使用 `.react-flow__pane`。React Flow 官方交互契约同样把 `panActivationKeyCode` 默认设为 `Space`,并把中键/右键拖动、Space 加指针拖动列为画布平移方式;这与竞品“普通左拖不动”的直接证据一致。
- 本地新增 `lib/canvas/pan.ts`,普通左键空白按下只清除选中态,不再创建视口拖拽;按住 Space 的左键或中键才创建 canvas drag。Space 从节点外框起拖时在标记、引用、连线和节点移动之前拦截,保留节点世界坐标和现有选中态,只更新视口;键盘状态在 `keyup`、窗口失焦和组件卸载时清理,并显示 grab/grabbing 光标。
- Chrome 修复后再次成对执行普通空白左拖,竞品仍保持 `407.725/542.475/0.1`,本地保持 `0/0/1`,已消除误平移。单次 Space 点按后双方视口不动,本地根容器也没有残留 grab 状态;固定工程数据库保持 `0 节点 / 0 连线 / {x:0,y:0,zoom:1}`。
- 当前 Chrome CUA 的拖拽 `keys` 参数只转换 `Alt / Control / Meta / Shift` 四个修饰位,`Space / SPACE / 空格字符 / Spacebar` 都不会产生持续 keydown;并发 keypress 也不能覆盖拖拽期间。因此未用失真的自动化结果宣称竞品 Space 手势无效。该功能是纯前端视口交互,不调用模型、积分、后端算法或提示词。
- 新增 3 项 `canvas-pan` 定向测试并纳入 helper 配置,覆盖普通左键拒绝、Space 左键允许、中键允许、右键拒绝和物理 `Space` code;全套 helper `302/302`、TypeScript、新文件格式检查均通过。
## 逐项竞品实测:教程与联系入口(2026-07-15,交互完成、待运营地址)
- 状态:教程菜单入口、四项顺序、浮层尺寸、激活态、教程跳转、客服窗口、销售/公众号二维码互斥、外部点击、`Escape`、工具栏切换、客服独立关闭和本地数据库配置均已完成双端实测。后续不要重复本组交互主流程;只需在后台填写本站自己的客服 iframe、联系销售二维码和公众号二维码三个运营地址。
- 竞品菜单固定在工具栏教程按钮上方,1512×805 视口实测为约 `104×153.6px @ (864.5,582.4)`;背景 `#262626`、`0.5px #363636` 边框、12px 圆角、4px 内边距和双层阴影。四项精确为 `使用教程 / 联系客服 / 联系销售 / 关注公众号`,单项约 `95×36.15px`、13px 字号、8px 圆角和 `8px 12px` 内边距;当前项设置 pressed/高亮状态。
- 点击双方 `使用教程` 都在新标签页打开 `LibTV使用指南` 飞书文档,原菜单保持打开。竞品 `联系客服` 调起第三方七鱼客服 iframe,外层实测 `360×500px`、距右 20px、贴底、`z-index:99999`、8px 圆角和 `0 0 20px rgba(0,0,0,.15)` 阴影;重复点击为幂等打开,菜单关闭后客服窗仍独立保留,点击窗口关闭按钮后收起。
- `联系销售` 与 `关注公众号` 都在菜单右侧显示 `140×140px`、8px 圆角的二维码,菜单与二维码共用 `left:864.5px / bottom:69px / gap:8px` 的底部对齐容器;两种二维码互斥切换,但不会关闭已打开的客服窗。没有复制竞品七鱼账号、用户参数或二维码资源到本地,避免把对方运营身份写进产品。
- 关闭边界实测为:点击画布空白处关闭菜单及二维码,按 `Escape` 保持菜单,重复点击底部教程按钮切换关闭;客服窗不属于菜单关闭状态。修复前本地恰好相反:空白点击不关闭、`Escape` 会关闭,后三项只改变行高亮而没有实际内容。
- 本地新增 `tutorialSupport` 数据库配置,包含 `tutorialUrl / customerServiceUrl / salesQrImageUrl / officialAccountQrImageUrl`,只接受 HTTP(S) 或同源绝对路径;后台 `/settings/video-canvas` 新增“教程与联系入口”卡片。客服未配置时仍显示同尺寸站内占位窗,二维码未配置时显示明确的后台提示,不伪装成真实客服或本站二维码。
- 本地管理员页面已真实保存并刷新验证四个字段,隔离数据库中的教程地址存在,其余三个运营地址保持空值;菜单、客服窗和二维码占位均不修改节点、连线、视口、积分,也不调用模型、提示词或生成后端。配置定向测试扩展为 `18/18`,全套 helper `303/303`、TypeScript、相关文件格式和差异空白检查通过。
## 逐项竞品实测:项目名称编辑(2026-07-15)
- 状态:常驻输入框、点击与聚焦、Enter、失焦、Escape、空值、首尾空格、120 字符边界、未提交刷新和服务端持久化均已完成双端实测。后续不要重复本小功能;竞品相邻的 `画布 1` 是独立画布名称按钮,仍作为另一条功能边界。
- 竞品项目名称始终是 `aria-label="项目名称"` 的输入框,而不是点击文字后临时切换。样式固定为最小宽 30px、最大宽 100px、13px 常规字重、透明虚线下边框,原生 `maxlength=120`;本地已改成同一常驻结构、属性和样式。
- 双端输入后按 Enter 或点击空白失焦都会提交;竞品 Enter 后仍保持输入框结构,本地现在通过让输入框失焦走同一提交路径。Escape 不取消也不提交当前草稿,空标题提交时恢复上一个有效名称,` 空格标题 ` 提交后规范为 `空格标题`。
- 未提交的 130 字符自动化草稿在刷新后都不会覆盖已保存名称。自动化 `fill/type` 可绕过浏览器原生 `maxlength`,因此本地除前端 120 属性外,在共享规范化函数和 PUT 接口再次截断、去空格并处理空值,不能依赖浏览器校验。
- 本地保存采用乐观更新;接口失败时恢复上一有效名称并显示错误,不再静默留下界面与数据库不一致。新建与更新项目共用 `normalizeCanvasProjectTitle`,现有 256 字符数据库列只作为存储上限,不替代产品层 120 字符规则。
- 双端最终已恢复原名并整页刷新验证:竞品项目名为 `未命名工作区`、独立画布名为 `画布 1`;本地项目名为 `视频画布竞品对照`,隔离数据库仍为 `0 节点 / 0 连线 / {x:0,y:0,zoom:1}`。本轮没有点击生成或调用模型,名称保存是确定性文本规范化,不存在可反推的生成算法或提示词。
- 新增 3 项定向测试覆盖空格、空值回退与 120 字符边界;全套 helper `306/306`、TypeScript、相关独立文件格式和差异空白检查均通过。主画布组件超过项目格式器 1MiB 单文件上限,继续以 TypeScript、浏览器实页和差异检查验收。
## 逐项竞品实测:多画布名称与管理(2026-07-15)
- 状态:菜单、当前态、重命名、新建、切换、复制、删除、单画布禁删、分层关闭、独立 URL、刷新持久化和夹具清理均已完成双端实测。后续不要重复本功能主流程;本控件管理同一工作区中的独立画布,和上一节的工作区项目名称是两个数据层级。
- 顶部画布按钮打开约 `216×82px` 的深色菜单,标题为 `画布`,右侧是加号;列表按创建时间降序,新画布在最上方,当前行始终显示勾选。每行悬停显示 `更多操作`,二级菜单固定为 `重命名画布 / 复制画布 / 删除画布`;仅剩一个画布时删除项禁用。
- 重命名会把目标行切为 `aria-label="画布名称"` 的 14px 常驻行内输入框,原生 `maxlength=120`。Enter、Escape 和失焦都会提交并退出编辑;空值恢复上一有效名称,` 空格画布 ` 规范为 `空格画布`,现有菜单保持打开。
- 点击加号会立即创建一个独立项目并在列表顶部进入重命名,默认名称使用第一个未占用的 `画布 N`;本地原有 `画布 1` 时得到 `画布 2`。新名称提交后切换到新项目 URL,工作区名称保持不变,新画布初始为 `0 节点 / 0 连线 / 100%`。
- 点击其他行会关闭菜单并把 URL 的项目 ID 切到目标画布。竞品网络较慢时先短暂显示 `未命名 / 项目加载中`,随后恢复真实名称和该画布自己的节点状态;这证明画布是独立后端项目,不是同一页面中的临时选项卡。
- `复制画布` 会完整复制目标画布并立即切换到新项目,命名规则为不加空格的 `<原名>副本1`,已有同名副本时递增。后端复制会重建节点和连线 ID,同时保留组内父子引用、选中项与视口,避免关系表 ID 冲突;切换或复制前还会绕过 2.5 秒自动保存防抖,先静默刷新当前画布,保存失败则阻止跳转。
- 删除弹窗精确为 `删除画布 / 确定要删除画布「名称」吗?此操作不可恢复。 / 取消 / 确认`。Escape 只关闭删除确认并保留下面的画布菜单;确认删除当前画布后切到最新剩余画布,删除非当前画布时保持当前 URL。普通画布菜单和二级菜单都不响应 Escape;点击外部时先关二级菜单,再次点击才关一级菜单。
- 本地使用 `CanvasProject` 的独立记录和独立 URL,`videoCanvasWorkspace` 持久化元数据只负责工作区归属与画布名称;专用鉴权 API 提供列表、新建、复制、重命名和受保护删除。工作区项目名称会同步到同组项目,普通画布自动保存会保留该元数据,没有在前端硬编码临时列表。
- 双端最终均恢复为唯一的 `画布 1`,菜单关闭、删除禁用。竞品回到原项目 `22158f6eaae74c099bc89b8004f5fa23`,项目名称 `未命名工作区`;本地回到固定项目 `00000000-0000-4000-8000-000000000201`,数据库为 1 个视频项目、`0 节点 / 0 连线 / {x:0,y:0,zoom:1}`、项目名称 `视频画布竞品对照`。本轮没有点击生成或调用模型;竞品积分在网络加载期间从 `3,090` 外部回升到 `3,093`,不能归因于画布操作。
- 新增 5 项定向测试覆盖旧项目升级、工作区分组、名称规范、默认编号、副本编号、元数据保留以及节点/连线/组关系 ID 重建;全套 helper `311/311`、TypeScript、相关独立文件格式和差异空白检查均通过。
## 逐项竞品实测:工作流 / 故事板模式切换(2026-07-15)
- 状态:顶部模式控件、同项目切换、故事板数据投影、Agent 联动、刷新恢复和工作流基线恢复均已完成双端实测。后续不要重复本模式主流程;故事板内部的关键元素筛选、最大化、剪辑和 Agent 对话内容分别作为独立功能继续对照。
- 竞品顶部是固定居中的双项分段控件,按钮为 `工作流 / 故事板` 并用 `aria-pressed` 表示当前项;两项均约 `76×32px`,外层约 `178×42px`。切换时 URL 和项目 ID 完全不变,因此它是同一份画布数据的视图模式,不是路由、复制项目或新建画布。
- 进入故事板后,竞品隐藏原节点世界、左侧节点工具栏和底部缩放栏,把当前项目确定性投影为顶部 `关键元素 · 全部 / 关键元素 / 音频 / 收起` 区域,以及下面的 `文本 / 图片 / 视频` 三列。实测项目中的角色、场景、提示词、文本节点、剧本、镜头和成品都只是按类型重排,没有创建、转换或生成新节点。
- 本地现在采用相同的同页结构:故事板从现有节点的 `kind / inputType / draftValues.mediaType / action` 和明确的角色场景元数据分类,保留源节点顺序;关键元素与普通镜头图片分开,未知操作节点不进入投影。空项目显示三列稳定空态,点击已有投影项会回到工作流并定位源节点。
- 竞品每次从已关闭状态进入故事板都会自动打开右侧 Agent;切回工作流后 Agent 保持打开,只有点击关闭按钮才收起。关闭后再次进入故事板会重新打开;故事板状态下刷新会恢复 `故事板 [pressed]` 并再次显示 Agent。本地已按同一状态机实现,不把 Agent 的开关误当成工作流模式的一部分。
- 视图模式写入项目 `canvasData.videoCanvasViewMode`,切换时立即走现有鉴权保存接口,同时纳入自动保存签名、切换画布前刷新和页面离开保底保存。无字段、非法值和旧项目统一回退到 `workflow`,不会依赖浏览器本地存储,也不会改变节点、连线、选择关系或视口。
- 双端最终均恢复为 `工作流 [pressed]` 且 Agent 关闭并刷新确认;本地隔离项目保持 `0 节点 / 0 连线 / {x:0,y:0,zoom:1}`。本轮没有点击生成、消耗积分或调用模型;该功能是确定性的前端分类与项目状态持久化,不存在可反推的后端生成算法或提示词。
- 新增 4 项定向测试覆盖模式默认值与非法值、文本/图片/视频/音频分类顺序、关键元素分流和无关操作节点过滤;全套 helper `315/315`、TypeScript、相关独立文件格式和差异空白检查均通过。
## 逐项竞品实测:故事板关键元素筛选与收起(2026-07-15)
- 状态:关键元素主标签、分类下拉、音频页签、全部/人物/场景/物品/其他筛选、空态、收起展开、菜单关闭边界和刷新重置均已完成双端实测。后续不要重复本功能;关键元素卡片点击与“引用”按钮属于下一层独立交互。
- 竞品的顶部并不是三个平级筛选按钮:`关键元素 · 全部` 是当前主标签和分类名称,右侧独立下箭头打开菜单;`音频` 才是同级页签。下拉菜单固定为 `全部 / 人物 / 场景 / 物品 / 其他`,当前分类在右侧显示勾选。
- 使用竞品现有数据与本地临时隔离夹具逐类核对结果:人物都是 `现代沈昭昭 / 沈昭昭 / 皇帝` 3 项;场景都是 `金銮殿 / 长安城白昼 / 长安城夜景` 3 项;物品都是 `侍卫长刀` 1 项;其他都显示 `暂无素材`;全部都恢复 7 项。分类只替换顶部素材条,下面文本、图片、视频三列不重排也不隐藏。
- 点击音频后,竞品顶部替换为 10 个音频素材,本地夹具替换为对应音频节点;关键元素不再显示,三列标题和内容保持不变。点击带当前分类的关键元素主标签会回到关键元素页签,并保留本次会话中的分类。
- 分类菜单在选择任一项、再次点击箭头、点击菜单外部或按 `Escape` 时关闭。主标签和音频通过 14px 文字颜色表达当前态,不使用胶囊背景或额外的 `aria-pressed`;箭头按钮保留独立的 `aria-label="关键元素"`。
- 顶部区域实测有三种稳定高度:有素材且展开约 150px,空分类展开约 117px,收起约 50px;素材缩略图为 `64×64px`,标题在下方单行截断。收起只隐藏素材条并保留筛选头,按钮改为 `展开`,下面三列立即上移。
- 分类、关键元素/音频页签和收起状态都是组件会话态,不写入项目数据。双端在场景分类并收起后刷新,都恢复为 `关键元素 · 全部`、展开和关键元素列表;工作流/故事板模式仍按上一节独立持久化,两个状态层没有混用。
- 本地分类优先读取节点显式 `keyElementType / elementType / storyboardCategory / category` 元数据,再用节点种类、动作和标题兜底。显式类型优先级可避免把含“侍卫”的 `侍卫长刀` 错归人物;无明确类别统一进入其他,不在前端硬编码具体角色名称。
- 双端最终已恢复 `工作流 [pressed]`、Agent 关闭并刷新确认。本地临时 11 节点夹具已删除,隔离数据库保持唯一 `画布 1`、`0 节点 / 0 连线 / 0 选中项 / {x:0,y:0,zoom:1}`、`videoCanvasViewMode=workflow`。本轮没有调用模型、生成素材或消耗积分,筛选是确定性元数据分类,不存在生成算法或提示词。
- 原 4 项故事板投影定向测试已扩展覆盖人物、场景、物品、其他分类及显式元数据优先级;全套 helper `315/315`、TypeScript、相关独立文件格式和差异空白检查均通过。
## 逐项竞品实测:关键元素卡片、详情与 Agent 引用(2026-07-15)
- 状态:关键元素卡片悬停、选中、原位详情、详情内切换、返回、关闭、顶部引用、详情对话引用、同元素重复引用、标签预览、逐个退格删除、Agent 收起恢复和整页刷新重置均已完成双端实测。后续不要重复本组主流程;详情右上角 `更多` 本轮只确认入口存在,菜单内容仍作为下一层独立边界。
- 竞品关键元素的实际卡片是可拖拽按钮,缩略图为 `60×60px`、8px 圆角和 0.5px 边框,标题为 12px 单行居中截断。悬停或键盘聚焦时,卡片右上角显示 `引用` 浮层按钮;当前详情对应的卡片使用蓝色边框和外圈表示选中。
- 点击关键元素不会切回工作流,也不会改变 URL。竞品保持 `故事板 [pressed]` 和 Agent 打开,在顶部素材条下把普通 `文本 / 图片 / 视频` 三列替换成左侧图片列与右侧详情;详情头固定为 `返回 / 元素标题 / 更多 / 对话 / 关闭`。点击另一个顶部元素会在原详情位置替换内容,`返回` 和 `关闭` 都恢复普通三列且不关闭 Agent。
- 顶部 `引用` 不打开详情,详情 `对话` 也不关闭详情;两者都会向 Agent 的真实 contenteditable 写入带缩略图和名称的行内标签并启用发送按钮。同一元素可连续引用多次,不做去重。点击标签只打开该元素的大图预览,不切换工作流或详情;在编辑器末尾连续退格会逐个移除标签,最后一个删除后发送按钮重新禁用。
- 竞品收起 Agent 后再次点击 `打开 Agent`,未发送标签仍保留;整页刷新则同时清空未发送标签和详情展开态,但项目仍保持故事板模式。因此详情、标签和 Agent 草稿属于当前页面会话态,`videoCanvasViewMode` 才是项目持久态,本地已按同一层级处理:面板首次挂载后只隐藏不卸载,页面重载不写草稿存储。
- 本地引用为每次操作生成独立 `composerTagId`,所以相同 URL 也能重复出现并按实例删除;标签同时进入已有图片附件数组,发送时沿用共享多模态链路,不是仅有外观的假标签。卡片详情的提示词、风格、模型和分辨率从节点 `draftValues` 投影,未在 JSX 中硬编码角色名称、模型或提示词。
- 可从竞品 DOM 和状态可靠推断的是:引用实例允许重复,标签携带节点名称、预览图和定位语义,发送上下文至少包含对应素材引用;无法仅凭未发送标签证明其服务端隐藏提示词格式。本轮没有点击发送、生成或扣费,因此不伪造后端提示词结论,后续只有在安全的非付费请求或明确网络载荷证据出现时再补充。
- 本地使用 2 个关键人物和 1 张普通镜头临时夹具完成 Chrome 实页回归:详情原位切换和返回/关闭保持故事板,重复引用得到两个不同实例,点击出现第二个具名对话框,两次退格依次变为 `2→1→0`,发送按钮 `禁用→启用→禁用`,收起再打开保持 `1` 个标签,整页刷新归零。新增投影元数据与引用规范测试后,全套 helper `317/317`、TypeScript、相关独立文件格式和差异空白检查均通过。
## 逐项竞品实测:关键元素详情更多菜单(2026-07-15)
- 状态:菜单开关、分类子菜单、人物/场景/物品/其他、分类关系更新、创建副本、立即删除、选中态、撤销快照、自动保存、刷新持久化和本地夹具清理均已完成双端实测。后续不要重复本组主流程;没有点击生成、发送或任何付费模型动作。
- 竞品详情头的 `更多` 打开 `157×137px` 菜单,背景为深灰面板、12px 圆角、4px 内边距和 0.5px 边框;三个 `148×40px` 命令依次为 `设置关键元素 / 创建副本 / 删除`。第一项右侧显示箭头,并在右边打开 `109×181px` 子菜单,顺序固定为 `人物 / 场景 / 物品 / 其他`,不显示勾选。
- 分类不是简单改写原卡片。对原 `现代沈昭昭` 选择场景后,场景列表新增同名关系,总关键元素从 7 变为 8;再次选择人物只更新这条派生关系,没有继续新增第三张同名卡。由此可确定服务端语义是按源图片 upsert 一条关键元素关系,关系类别可再次修改,源图片与关系是两个身份层级。
- `创建副本` 会关闭菜单,在关键元素列表最前面插入同图副本,标题精确为 `<原标题> - 副本`,详情立即切换到副本。副本保留图片、提示词、风格、模型、分辨率和类别,不复制连线,也不继续继承派生关系身份。
- `删除` 立即执行,不显示确认框;当前卡片和关联连线被移除,详情关闭并恢复普通三列。本地复用共享删除事务,因此同步清理选择、检查器、上下文菜单和连线,并进入已有撤销栈;菜单打开时按 Escape 或点击外部只关闭菜单,不误关详情。
- 本地实现把四类选项集中在 `lib/video-canvas/storyboard-overview.ts`。首次分类写入 `storyboardDerivedKeyElement / storyboardKeyElementSourceId / storyboardCategory` 并前置派生节点,再次分类按来源更新同一节点;副本写入 `storyboardDuplicateOf`。这些字段都进入真实项目节点、2.5 秒自动保存和页面离开保底保存,不是故事板组件内的临时数组。
- 浏览器实页验证主菜单和子菜单边界框分别精确为 `157×137`、`109×181`;本地 3 节点夹具首次分类后数据库为 4 节点且只有 1 条派生关系,再改类仍为 4;副本后为 5、首项标题和媒体地址正确、选中 ID 指向副本,删除后无确认且副本消失。刷新确认节点、关系和选择数据持久化后,固定工程已恢复 `0 节点 / 0 连线 / 0 选中项 / {x:0,y:0,zoom:1}` 与 `workflow`。
- 该功能可可靠反推为确定性的关系和节点事务,不需要模型、算法提示词或隐藏生成提示词。本轮新增类别顺序与中文标签测试;全套 helper `318/318`、TypeScript 和差异空白检查均通过。
- 竞品测试期间同名源图与派生关系的删除目标出现歧义;已停止继续做破坏性尝试,从其生成历史把原始图片无付费恢复为工作流 `图片节点 3`,保留原 `现代沈昭昭` 关键元素关系,并恢复 `工作流 / Agent 关闭 / 100%`。竞品积分全程保持 `3,083`。
## 逐项竞品实测:故事板图片 / 视频最大化(2026-07-15)
- 状态:默认三列、图片最大化、视频最大化、主列直接切换、同按钮收起、Escape 收起、Agent 宽度联动、模式往返保留、刷新重置、多列真实媒体和夹具清理均已完成双端实测。后续不要重复本功能;视频头部 `全部` 筛选和底部 `剪辑` 是下一层独立功能。
- 竞品默认把可用宽度平均分给 `文本 / 图片 / 视频` 三个独立滚动面板。关闭 Agent、页面宽 1512px 时,下方内容区总宽约 1480px,三个面板各约 485px;图片和视频头部右侧均为 `28×28px` 图标按钮,名称分别是 `放大图片 / 放大视频`。
- 图片最大化不是全屏覆盖:布局变为 `文本 1/3 + 图片 2/3 + 视频 0`,实测宽度约 `481 / 987 / 0px`,按钮改名 `收起图片`,图片卡片从单列详情流切为四列紧凑画廊。视频最大化采用镜像但非完全对称的 `文本 0 + 图片 1/3 + 视频 2/3`,按钮改名 `收起视频`,视频卡片同样切为四列画廊。
- 视频最大化时,保留的图片列仍显示 `放大图片`;点击会直接把主列从视频切到图片,视频按钮恢复为 `放大视频` 并随视频列隐藏。图片最大化时视频列不可见,不能从隐藏列直接反向切换。这个非对称交互已在本地用同一单选状态机实现。
- 点击当前 `收起…` 按钮或按 `Escape` 都恢复默认三列。打开和关闭 Agent 不改变最大化项,只按剩余宽度重新计算比例;实测 Agent 打开时视频最大化为图片约 368px、视频约 760px。切到工作流再回故事板仍保留最大化项,整页刷新才清空该状态,同时独立持久化的故事板模式和 Agent 自动打开规则不受影响。
- 本地把最大化项提升到画布页面会话态,因此模式组件卸载时不会误丢失,换项目和页面刷新会重置;列比例集中由 `getVideoCanvasStoryboardColumnLayout` 返回 `1/1/1`、`1/2/0` 或 `0/1/2`,组件据此渲染真实面板、独立滚动区和 `auto-fill minmax(210px, 1fr)` 媒体画廊,没有写死具体素材数量或名称。
- Chrome 实页回归先验证空项目的三种稳定布局,再临时写入 `3 文本 + 8 图片 + 8 视频` 共 19 个真实投影节点,确认两种主媒体最大化后均显示四列且素材仍可操作。临时标签页关闭后数据库已恢复唯一固定项目、`0 节点 / 0 连线 / 0 选中项 / {x:0,y:0,zoom:1}` 与 `workflow`,新标签页重新加载确认无夹具回写。
- 本功能是确定性的前端布局和页面会话状态,不涉及生成模型、后端算法或隐藏提示词。本轮竞品没有发送、生成或消耗积分,最终恢复 `工作流 / Agent 关闭 / 100%`,积分仍为 `3,083`。
- 新增列比例纯函数定向测试;全套 helper `319/319`、TypeScript、相关独立文件格式和差异空白检查均通过。
## 逐项竞品实测:故事板视频全部 / 成片 / 片段筛选(2026-07-15)
- 状态:筛选触发器、菜单尺寸、选项顺序、当前勾选、全部/成片/片段结果、空态、按钮二次关闭、外部关闭、Escape 边界、Agent 联动、最大化联动、模式往返保留、刷新重置、真实媒体和夹具清理均已完成双端实测。后续不要重复本功能;底部 `剪辑` 仍是下一层独立功能。
- 竞品视频标题右侧是高 28px 的当前筛选按钮,初始文案 `全部`。菜单固定为约 `120×128px`,4px 内边距、12px 圆角,三行各 40px,顺序精确为 `全部 / 成片 / 片段`,当前项右侧显示勾选;选择后菜单立即关闭,触发器文字替换为所选项。
- 当前竞品项目选择 `成片` 后得到精确空态 `暂无视频`,选择 `片段` 则恢复全部现有视频,包括三个标题叫 `成品` 的 Kling O1 节点、`视频37` 和普通视频节点。这证明分类依据是视频资产在故事板剪辑链路中的结构化身份,而不是标题是否包含“成品/成片”;普通模型生成、裁剪和处理结果都属于片段,只有剪辑或合成导出的最终资产才属于成片。
- 菜单再次点击触发器或点击外部会关闭,按 `Escape` 不关闭。筛选值在 Agent 打开/收起、视频最大化/收起以及工作流与故事板往返中保持;整页刷新才恢复 `全部`。菜单开关本身在组件卸载时关闭,筛选值与上一节最大化项一样属于画布页面会话态,不写入项目数据库。
- 视频最大化不会改变筛选:`片段` 最大化后只把片段结果切成多列画廊,切到 `成片` 会在同一 2/3 主列显示 `暂无视频`;收起视频后仍保留当前筛选。文本、图片、关键元素和 URL 全程不受影响。
- 本地投影为视频项增加 `videoCategory=clip|film`。无显式身份的视频默认归片段;`storyboardVideoCategory / storyboardVideoType / videoAssetCategory / videoAssetKind` 支持结构化分类,`isStoryboardFilm / isFinalVideo` 和四个明确的编辑器导出动作归成片。没有使用标题正则把名为 `成品` 的普通节点误判成成片,也没有在 JSX 硬编码具体素材。
- Chrome 真实媒体回归临时写入 7 个视频节点:4 个普通片段和 3 个分别由显式类别、布尔标记、`VIDEO_EDITOR_EXPORT` 标记的成片。双端逐项确认本地 `全部=7 / 成片=3 / 片段=4`,其中标题为 `成品` 的普通节点仍在片段;筛选、最大化、模式往返和刷新结果全部与竞品状态机一致。
- 临时夹具标签页关闭后,本地固定项目已恢复 `0 节点 / 0 连线 / 0 选中项 / {x:0,y:0,zoom:1}` 与 `workflow`,新标签页重新加载确认无夹具回写。竞品恢复 `工作流 / Agent 关闭 / 100%`,积分仍为 `3,083`;本功能是确定性分类,不涉及模型调用、生成算法或隐藏提示词。
- 新增视频类别、筛选顺序和“标题不参与分类”定向测试;全套 helper `320/320`、TypeScript、相关独立文件格式和差异空白检查均通过。
## 逐项竞品实测:故事板剪辑工程入口与成片编辑器(2026-07-15)
- 状态:工程菜单、已有工程切换、故事板内原位编辑器、空态工具栏、素材池、真实视频入轨、本地分割/裁剪/撤销重做、字幕、转场、变速、吸附、缩放、预览收起、全屏预览、多工程隔离、自动保存恢复、真实成片编码、导出到本地/画布和成片分类写回均已实现并完成本地实页回归。竞品受信任拖拽及非空工程导出的服务端结果仍受自动化能力和潜在扣费约束,明确保留为未验证边界,不再重复本节已完成流程。
- 竞品底部 `剪辑` 不是“立即新建编辑器”。点击后先打开约 208px 宽的已有工程菜单,当前项目列出 `视频合成 11 / 视频合成 5`;选择任一项后 URL、项目 ID 和 `故事板 [pressed]` 都不变,而是在顶部关键元素条下原位展开底部成片编辑器。
- 工程菜单按 `VIDEO_CLIP_RESOURCE / nodeType=video-clip` 的真实工程节点生成,不是视频标题筛选。菜单展开后按 `Escape` 保持,重复点击 `剪辑` 也保持,点击外部或选择工程才关闭。本地两处入口(普通故事板和编辑器内工程切换)使用同一关闭边界。
- 1512×805 视口下,竞品编辑区为左侧约三分之一素材池和右侧约三分之二编辑区。左侧头部是 `视频 / 全部`,素材卡按真实故事板视频输出排列;右侧上方是 `视频合成 / 导出 / 退出剪辑`,中部为预览,下方为工具栏和时间线。两个现有竞品工程当前都是空时间线,显示 `00:00 / 00:00`,撤销、重做、分割、左右裁剪和变速禁用。
- 空态仍可打开 `字幕轨道` 和 `转场`。字幕面板固定为 `字幕 / 文本` 两页、`搜索字幕文本 / 新建字幕 / 暂无字幕`,时间线增加可见性按钮;转场打开后为 `转场库 / 中央预览 / 转场属性` 三栏,基础转场顺序精确为 `淡入淡出 / 黑场过渡 / 白场过渡`,未选中时属性显示 `未选择转场 / 时长 - / 删除禁用`。预览收起后右侧只保留完整时间线,按钮改为 `展开预览(占满时间轴上方)`,退出按钮移到工具栏最右侧。
- 竞品素材卡双击会打开右侧素材详情,不会加入时间线;关闭详情会回到普通故事板。真正入轨依赖拖拽,但当前 Chrome 控制接口无法产生页面要求的受信任拖拽事件,因此没有伪造竞品“已入轨”的证据,也没有修改原工程。
- 竞品空工程点击 `导出` 只展开约 200×120px 的位置菜单,不立即创建任务;菜单仅有标题 `导出位置` 和 `导出到本地 / 导出到画布` 两项,空时间线下两项都禁用,没有分辨率、格式、帧率或质量设置。由于现有两个竞品工程都为空且无法通过受信任拖拽加入素材,非空工程的校验、队列、编码格式、下载和画布写回结果仍未实测。打开菜单前后积分显示意外由 `3,083` 变为 `3,082`,没有足够证据证明与菜单操作存在因果关系,因此不据此反推扣费规则,也不再尝试潜在付费导出。
- 本地新增独立 `VideoCanvasStoryboardCompositionEditor`,工程数据持久化到对应节点的 `draftValues.storyboardComposition`。普通视频节点只进入素材池,合成工程不再误投影为视频卡;片段保存源节点 ID、媒体地址、入点、出点、速度和转场,字幕保存文本与时间范围,所有列表和时长都由真实节点与工程数据计算。
- 本地真实媒体夹具用 2 个视频节点和 2 个合成工程完成回归:5 秒片段播放到中段后分为两段,再加入第二个 5 秒片段得到 3 段、总时长 10 秒;为第三段应用 `淡入淡出` 和 `2x` 后总时长变为 7.5 秒。5 秒播放头处创建 `测试字幕:镜头切换`,字幕侧栏与轨道同步。切到空白 `视频合成 5` 为 0 段,切回 `视频合成 11` 仍为 3 段;等待自动保存并整页刷新后仍恢复 3 段、7.5 秒、字幕、转场和速度。
- 本地支持拖放素材,也为无障碍与自动化提供悬停加号;时间线选择后启用分割、左右裁剪和变速,修改进入工程内 50 步撤销/重做栈。吸附按 0.5 秒定位,缩放改变时间线最小宽度,全屏预览与 Escape 退出、预览收起/展开均为页面会话态,不污染工程节点。
- 本地导出菜单与竞品保持 `导出位置 / 导出到本地 / 导出到画布` 的结构,空工程禁用、非空工程启用。实际渲染按时间线顺序加载真实视频 Blob,以首片宽高比创建最长边不超过 1280px 的偶数尺寸 Canvas,通过 30fps `captureStream + MediaRecorder` 编码 WebM;入点、出点和倍速决定逐段时长,三种转场、活动字幕和源视频音轨在同一时间轴合成。导出到本地触发浏览器文件下载;导出到画布复用现有视频上传服务并创建带 `VIDEO_EDITOR_EXPORT / storyboardVideoCategory=film / compositionSnapshot` 的真实成片节点,不依赖生成模型或隐藏提示词。
- Chrome 真实回归使用两个 1.5 秒 MP4 片段、第二段 `淡入淡出` 和覆盖 0.3-2.7 秒的字幕,完成 3 秒成片编码与上传,得到约 692KB 的 WebM 和 `成品 / 视频合成 / 3秒` 节点。数据库从 3 个夹具节点变为 4 个且恰有 1 个 `VIDEO_EDITOR_EXPORT`;等待自动保存并整页刷新后,媒体地址、工程快照和成片分类均恢复,选择 `成片` 只显示该节点。临时标签页关闭后,本地固定项目恢复并新开页面核验为 `0 节点 / 0 连线 / 0 选中项 / {x:0,y:0,zoom:1}` 与 `workflow`。
- 新增导出尺寸纯函数测试;工程识别、普通视频分离、时间线规范化、片段分割和导出尺寸共 12 项定向测试通过。全套 helper `323/323`、TypeScript、新增独立文件格式和差异空白检查均通过。
## 逐项竞品实测:故事板音频素材卡、详情与 Agent 引用(2026-07-15)
- 状态:音频页签、素材卡、悬停引用、原位详情、详情内切换、返回/关闭、待生成空态、左侧文本/图片/视频模块、真实播放、截取/变速/下载工具栏、更多菜单、顶部与详情重复引用、标签定位和本地真实截取均已完成双端实测。后续不要重复本组主流程;竞品没有执行音频处理生成、下载或 Agent 发送,避免修改原项目或触发未知计费。
- 竞品 `音频` 与 `关键元素·全部` 是同级页签,当前项目精确投影 10 个音频节点。卡片为 `60×60px` 黑色方块、音乐图标和下方单行标题,悬停或键盘聚焦显示 `引用`。本地按节点结构化 `mediaType/inputType/action` 投影,不硬编码竞品标题;有媒体地址才显示引用,待生成节点仍保留卡片。
- 点击音频卡不会切回工作流。详情在故事板内原位展开,头部固定为 `返回 / 音频标题 / 更多 / 对话 / 关闭`;点击另一个音频卡只替换当前详情。已生成音频显示波形、`当前时间 / 总时长` 和播放键,竞品真实播放由 `00:00 / 00:05` 推进到 `00:01 / 00:05` 并在结尾停到 `00:05 / 00:05`;待生成音频精确显示 `待确认后生成`,没有播放器且对话禁用。
- 音频详情左侧默认 `视频 / 全部`,`切换模块,当前视频` 菜单顺序固定为 `文本 / 图片 / 视频`;切到文本只替换左栏,音频详情保持打开。详情打开时把顶部切回 `关键元素·全部` 也不会关闭详情,说明顶部素材页签、详情对象和左栏模块是三个独立页面会话状态。本地按相同层级保存,刷新才重置未持久化详情态。
- 选中播放器后出现 `截取 / 变速 / 下载` 浮动工具栏。竞品截取默认选择音频前三分之一,5 秒素材显示约 `1.69 s` 和 `00:00 - 00:01`;变速面板为 `0.1x–4.0x`、倍率输入、增减和确认。当地 1.96 秒 WAV 同样默认约三分之一,并真实完成浏览器解码、截取、WAV 上传和新节点写回,顶部新增 `音频10_截取`;变速与下载复用已存在的真实音频处理和媒体下载链路,不是静态按钮。
- 顶部 `引用` 不打开详情,详情 `对话` 不关闭详情;同一音频可重复引用,本轮双方均连续得到 3 个独立标签。音频标签使用音乐图标,具名语义精确为 `音频10 · 点击定位到画布节点`。后续文本标签的双模式复测修正了本轮的初步推断:在故事板内点击定位标签不会主动切换模式;只有已处于工作流时才选中源节点并居中定位。这与关键元素图片标签点击打开大图预览的行为不同。标签继续进入真实附件数组并启用发送按钮,逐个删除后附件实例同步减少。
- 引用协议新增 `mediaType` 与真实 `contentType`,节点投影优先读取 `contentType/mimeType/fileType`,音频默认 `audio/wav`;同源相对媒体地址在浏览器端转为绝对 HTTP(S) 地址后再经过共享规范校验。图片引用仍默认 `image/jpeg` 和大图预览,旧请求兼容不变。
- 更多菜单与竞品一致为 `设置关键元素 / 创建副本 / 删除`,分类子菜单顺序仍是 `人物 / 场景 / 物品 / 其他`。本轮只验证音频详情中的菜单结构和关闭边界,没有在竞品执行破坏性命令;可确定的音频截取/变速是本地媒体算法,未观察到竞品服务端生成请求或隐藏提示词,因此不做无证据反推。
- 本地专项测试仍为 `12/12`,全套 helper `323/323` 与 TypeScript 通过。回归结束后会删除测试生成的 WAV、恢复固定项目为 `0 节点 / 0 连线 / 0 选中项 / {x:0,y:0,zoom:1}` 与 `workflow`;竞品最终恢复工作流、100% 和 Agent 关闭,积分保持本轮观察值 `3,082`。
## 逐项竞品实测:故事板文本素材卡、详情、生成器与 Agent 引用(2026-07-15)
- 状态:文本素材卡、原位详情、详情内文本切换、返回/关闭、已生成 Markdown、待生成空态、左侧文本/图片/视频模块、底部文本生成器、模型菜单、翻译、费用提示、展开编辑、更多菜单、Agent 文本标签和双模式定位语义均已完成双端实测。后续不要重复本组主流程;竞品与本地均没有点击生成或发送,避免消耗积分或修改原项目内容。
- 竞品当前项目文本列精确包含 6 张卡:4 张 `提示词`、`文本节点 2` 和 `剧本`。点击卡片不会切回工作流,而是在故事板内展开两栏详情;左栏保留素材列表并高亮当前项,右栏头部固定为 `返回 / 标题 / 更多 / 对话 / 关闭`。点击另一张文本卡只替换当前详情,返回与关闭都恢复文本/图片/视频三栏总览。
- 已生成的 `提示词` 和 `剧本` 在详情正文按 Markdown/富文本渲染,覆盖标题、段落、粗体、引用、列表和代码,不是可编辑 textarea。未生成的 `文本节点 2` 显示文件图标与精确文案 `待确认后生成`;两种状态底部都保留同一生成器。
- 生成器占据详情底部,提示词占位文案为 `写下你想讲的故事、场景或角色设定。例如:一个来自未来的机器人,在城市屋顶看星星。`,右上角为展开按钮,底栏包含模型、翻译、积分和发送。竞品当前详情显示 `GVLM 3.1 / 6`;本地不硬编码该模型或价格,而是从 `video_canvas_function_config.functions.story-script`、可用多模态模型和会员积分配置实时解析,测试账号因会员规则显示 `自动模型 / VIP`。
- 详情左栏默认 `文本`,`切换模块,当前文本` 菜单顺序精确为 `文本 / 图片 / 视频`。切到图片或视频只替换左栏素材,右侧文本详情、底部生成器和 Agent 都保持不变;返回后重新打开任意文本卡,左栏重置为文本。该状态属于详情会话,不写入工程数据库。
- 本地生成提交复用真实 `/api/canvas/workflow/analyze` 的 `story-script` 路由:系统提示词、首选模型和启停状态来自后台视频画布配置,服务端按现有候选模型回退与积分事务执行;返回的结构化分镜会规范为可读 Markdown 并写回节点正文、模型、提示词、状态和分镜行。可从竞品界面可靠确认的是“文本模型 + 单次费用 + 自由提示词”的调用形态,无法仅凭未发送请求反推出其私有系统提示词原文,因此不伪造一模一样的隐藏提示词结论。
- 文本详情 `对话` 会插入具名标签,语义精确为 `剧本 · 点击定位到画布节点`。只含标签时发送按钮启用;文本正文作为隐藏上下文随真实 Agent 请求发送,不伪装成图片文件,也不在输入框重复显示全文。竞品和本地均确认:故事板内点击标签保持当前详情;手动切到工作流后点击才选中并居中定位源文本节点。图片标签仍打开预览,三类引用保持各自语义。
- 本地以 `剧本` 已生成节点、`文本节点 2` 待生成节点、1 张图片和 1 个视频节点完成 Chrome 实页回归:已生成正文、待生成空态、输入后发送启用、图片/视频模块切换、返回复开重置、文本标签 `0→1→0`、故事板不跳转和工作流定位全部通过。释放测试标签页后固定项目已恢复 `0 节点 / 0 连线 / 0 选中项 / {x:0,y:0,zoom:1}` 与 `workflow`;竞品恢复 `工作流 / Agent 关闭 / 100%`,积分保持 `3,082`。
- 故事板专项测试扩展为 `13/13`,新增覆盖文本正文、生成状态、模型引用和文本引用规范;全套 helper `324/324`、TypeScript、相关独立文件格式和差异空白检查均通过。
## 逐项竞品实测:故事板普通图片素材卡、详情、生成器与 Agent 引用(2026-07-15)
- 状态:普通图片卡、待生成/已生成原位详情、详情内图片切换、左侧文本/图片/视频模块、参考图、风格库、预设、动态模型、画质/清晰度/比例/张数、翻译、费用、展开编辑、更多菜单、真实生图写回和 Agent 定位引用均已完成双端实测。后续不要重复本组主流程;竞品与本地均未点击付费生成或发送 Agent 请求。
- 竞品普通图片卡点击后不会切回工作流,而是在故事板内展开两栏详情。右侧头部固定为 `返回 / 图片标题 / 更多 / 对话 / 关闭`;左栏默认图片列表,当前图片以选中态显示,切到文本或视频只替换左栏,右侧图片详情保持。返回后重新打开图片会恢复默认图片模块。
- 已生成图片详情只显示大幅 `object-contain` 预览,不显示底部生成器;待生成图片显示图片图标与精确文案 `待确认后生成`,底部保留真实图片生成器。原实现把普通图片卡交给工作流节点打开函数,本轮增加独立图片详情会话状态,彻底移除这次错误模式切换。
- 待生成器的参考区显示编号缩略图和 `可将资产、图片、视频拖拽到参考区`;提示词、参考图、模型、比例、画质、清晰度、张数和风格均从节点草稿恢复。参数菜单顺序为 `画质 / 清晰度 / 比例 / 生成数量`,覆盖竞品观察到的三档画质、1K/2K/4K、13 个比例和 1/2/4 张。
- 风格按钮复用现有完整风格素材库,保留 `风格广场 / 我的收藏 / 最近使用`、搜索、分类和 `仅看可商用`;模型菜单只读取后端 `/api/models` 可用图片模型,费用由会员积分和模型价格配置实时计算。没有把竞品 `Lib Image` 等模型清单或观察到的 26/30 积分写死到前端。
- 图片预设由 `video_canvas_function_config.functions` 提供,分组为 `分镜叙事 / 质感调节 / 空间与机位 / 设定图`;新增的故事板图片、调度故事板、普通故事板、人像质感和设定图提示均为后台可覆盖默认值。实际提交复用现有图片生成服务,按配置组合系统提示、模型和参考图;成功后写回生成状态、模型、参数、风格和媒体地址,多张结果创建同源副本节点。
- 普通图片与关键元素图片的 Agent 行为不同。竞品待生成 `镜头 16` 和已生成 `图片节点 3` 都插入 `标题 · 点击定位到画布节点`;点击时在故事板保持详情,已在工作流时才选中并居中。关键元素仍是 `点击查看关键元素` 并打开大图预览。本地引用协议新增显式 `activation=locate|preview`,不再用媒体类型猜交互。
- 待生成普通图片没有媒体地址也能引用:节点提示词和参考设定进入隐藏上下文,标签本身启用发送;已生成普通图片同时保留真实媒体附件和定位语义。关键元素预览仍要求有效 HTTP(S) 图片。标签删除会同步清理上下文与附件,避免残留不可见请求数据。
- 本地四节点夹具逐项验证原位打开、参考区、全部参数、系统预设、风格库、动态模型、更多菜单、跨模块详情保持、已生成大图、两种图片定位标签和返回复开重置。测试后固定项目恢复为 `0 节点 / 0 连线 / 0 选中项 / {x:0,y:0,zoom:1}` 与 `workflow`;竞品恢复 `工作流 / Agent 关闭 / 100%`,积分保持 `3,082`。
- 故事板专项测试扩展为 `14/14`,新增覆盖图片参考图、生成参数和无媒体/有媒体定位引用;全套 helper `325/325`、TypeScript、相关独立文件格式和差异空白检查均通过。主画布组件仍超过项目 Biome 1MiB 单文件上限,以 TypeScript、Chrome 实页回归和差异检查验收。
## 逐项竞品实测:故事板普通视频素材卡、详情、生成器与 Agent 引用(2026-07-15)
- 状态:普通视频卡、已生成/待确认原位详情、详情内视频切换、左侧文本/图片/视频模块、真实播放、顶部剪辑/高清/下载/全屏入口、更多菜单、参考图、动态模型、模式、参数、特效、角色库、运镜、计费、生成提交和两类 Agent 视频引用均已完成双端实测。后续不要重复本组主流程;既有视频高清、剪辑、下载、全屏、特效和运镜子功能仍以各自已完成记录为准。
- 竞品点击普通视频卡不会切回工作流,而是在故事板内展开两栏详情。左栏默认 `视频 / 全部` 并高亮当前卡,右栏头部为 `返回 / 标题 / 剪辑 / 高清 / 下载 / 全屏 / 更多 / 对话 / 关闭`;点击另一张同名 `成品` 或 `视频37` 只替换右侧媒体和编辑器,URL、故事板选中态与 Agent 都保持。
- 已生成视频使用真实播放器,实测 `成品` 和 `视频37` 均为 5 秒素材;详情底部仍保留视频编辑器,并非只读预览。待确认 `视频节点 3` 中央精确显示 `待确认后生成`,下方同样保留 `参考 / 特效 / 角色库 / 运镜`、提示词、模型、生成模式、比例、清晰度、时长、数量、音频、费用和提交按钮。
- 详情左栏的模块菜单顺序为 `文本 / 图片 / 视频`。竞品和本地逐步完成 `视频→文本→图片→视频` 往返,右侧详情和 `返回` 全程保持;本地在左栏点击其他普通视频也原位切换,不再调用会强制进入工作流的旧 `onOpenNode` 路径。
- 本地视频编辑器读取 `video_canvas_function_config.videoModelCatalog`、`videoGenerationParameters`、`videoReferenceSelection`、`motionPresets`、特效/角色配置及函数定义。参考图和运镜写入节点结构化草稿,提交复用既有 `submitVideoTargetComposer`、真实视频服务、后台模型回退、系统提示词组合、会员计费和多结果写回链路;没有在故事板 JSX 中复制竞品模型名、价格或隐藏提示词。
- 视频文件与封面被拆成两个投影字段。已生成视频的 Agent 标签使用 `mediaType=video / activation=locate`,真实附件保持 `video/mp4`,缩略图使用 `thumbnailUrl/posterUrl/previewImageUrl/coverUrl/poster`,不再把 MP4 交给 `<img>`。待确认视频即使没有媒体和提示词也能引用节点身份,标签不创建伪附件;两者标题语义均为 `标题 · 点击定位到画布节点`。
- 竞品已生成 `成品` 标签的 DOM 同样包含一个由 MP4 OSS 首帧快照生成的 `<img>`,点击标签在故事板保持当前详情;本地逐项确认已生成标签携带真实 MP4 与独立封面,待确认标签无 `data-attachment-url`,在故事板点击均不跳转,删除后上下文与附件同步归零。无法仅由标签和未发送草稿证明竞品服务端私有提示词格式,因此不做无证据反推。
- 本地 5 节点真实夹具完成卡片、两种详情、5 秒播放、三模块往返、更多菜单、后台模型目录、参考选择、参数面板、运镜库和两类视频引用实页回归。竞品没有再次触发已完成的高清/下载/剪辑子任务,没有生成、发送或扣费,积分保持 `3,082`。
- 故事板专项测试扩展为 `15/15`,新增覆盖 MP4/封面分离以及待确认/已生成视频引用规范;全套 helper `326/326` 与 TypeScript 通过。主画布组件仍超过项目 Biome 1MiB 单文件上限,相关其余文件格式检查和差异空白检查通过。
## 逐项竞品实测:添加节点菜单与非文本节点创建(2026-07-15)
- 状态:主菜单、脚本/素材库子菜单、分层 Escape、按钮二次关闭、上传输入、生成历史选择器、图片/视频/视频合成/导演台/音频/新旧脚本节点创建、默认值、落点、选中态、连线、会话编号、撤销和刷新重置均已完成双端逐步实测。后续不要重复本组主流程;上传文件、历史素材确认和模型生成仍是各自独立的后续功能。
- 竞品左侧 `添加节点` 打开约 `196×445px` 面板,主项顺序精确为 `文本 / 图片 / 视频 / 视频合成 Beta / 导演台 NEW / 音频 / 脚本 / 素材库 NEW`,分区 `添加资源` 下为 `上传 / 从生成历史选择`,每行约 `179×32px`。脚本子菜单为 `脚本 NEW / 脚本(旧版) Beta`,素材库悬停子菜单为 `风格库 / 特效库`。
- Escape 采用分层关闭:子菜单打开时第一次只关闭子菜单并保留主菜单,第二次才关闭主菜单;再次点击左侧添加按钮也关闭主菜单。本地为子菜单增加独立键盘边界并阻止事件冒泡,避免全局处理器一次关闭两层。
- 上传入口是支持多选的真实文件输入,`accept=image/*,video/*,audio/*`。`从生成历史选择` 打开具备对话框语义的 `选择图片`,来源页签为 `LibTV / Lib生成器 / WebUI / ComfyUI / AI应用`,媒体页签为 `图片 / 视频 / 音频`,显示 `0/10 张`、`15条/页` 和禁用的确认按钮;Escape 关闭对话框。本轮没有触发文件选择或确认历史素材。
- 所有新节点均处于选中态、不自动创建连线,左上角落在当前视口中心。图片和视频卡均为 `622×350px`:图片默认 `Lib Image / 16:9 / 标准画质 / 2K / 1张`;视频默认 `Seedance1.5 Pro / 文生视频 / 16:9 / 1080P / 5s / 1个` 并开启生成音频。
- 视频合成为 `350×350px`,空态 `空空如也,请连接视频节点后操作`;导演台同尺寸,显示 `在3D空间中搭建场景并进行多视角截图 / 打开导演台`。音频同尺寸,默认 `Minimax-speech-2.8-hd / 少女音色 / 1个`,并直接展开语速、声调、音量、音高、强度、音色和音效调节。新脚本提供 `剧本生成分镜脚本 / 角色生成分镜脚本 / 自己编写分镜脚本`,旧版中间项为 `视频参考生成分镜脚本`。
- 创建编号属于页面会话:每次创建消耗下一个编号,撤销节点不会回退编号;刷新后根据当前项目节点重新计算。竞品以 `图片节点 2→视频节点 3→视频合成 4→导演台 5→音频节点 6` 验证,全部逐个撤销后刷新再建图片恢复为 `图片节点 2`;本地从空项目验证 `1→2→3→4→5`,刷新后恢复为 `图片节点 1`。
- 本地新增规范化的 `nodeCreation` 系统配置,图片尺寸与参数、视频尺寸与模型目录 ID、视频参数和音频高级区默认展开均从后端设置下发;节点自身已保存的选择优先。管理页新增 `新建节点默认值` 区域,Chrome 实页核对数据库配置与页面字段、模型下拉和开关完全一致,没有在画布 JSX 中硬编码竞品模型目录或价格。
- 本轮新增 5 项节点创建纯函数测试和 1 项配置集成测试;全套 helper `332/332`、TypeScript、相关独立文件格式和差异空白检查均通过。固定本地工程最终恢复 `0 节点 / 0 连线 / 0 选中项 / {x:0,y:0,zoom:1}` 与 `workflow`。
- 竞品最终恢复原有 `2 节点 / 3 连线 / 工作流 / Agent 关闭`。没有执行生成、上传、确认素材或任何付费动作;刷新页面自动领取每日登录奖励 20 积分,显示值由 `3,082` 变为 `3,102`,不据此反推任何节点创建费用或后端算法。
## 逐项竞品实测:添加资源的生成历史选择(2026-07-16)
- 状态:生成历史的来源/媒体切换、分页、跨页多选、来源切换清空选择、确认、关闭、节点尺寸/落位/命名、持久化和删除清理均已完成双端逐步实测,后续不要重复本组主流程。主菜单的原生文件上传仍是独立未完成项:后端已验证,竞品/本地 Chrome 文件选择器待扩展文件 URL 权限开启且 Mac 解锁后继续。
- 竞品弹窗在 `1512×805` 视口中为 `1360.8×645 @ (75.6,80)`,标题 `选择图片`,最多 `10` 张,每页真实渲染 `15` 条。来源固定为 `LibTV / Lib生成器 / WebUI / ComfyUI / AI应用`;LibTV 显示图片/视频/音频,Lib生成器和 WebUI 显示图片/视频,ComfyUI 和 AI应用显示图片/音视频。
- LibTV 实测资源数为图片 `17`、视频 `11`、音频 `8`。图片第 1 页选择后切到第 2 页仍保持 `1/10`,再选变为 `2/10`,返回第 1 页保留勾选;切换来源则清空选择。确认按钮只在有选择时启用,选择数据源不受当前页过滤。
- 单选图片按短边 `350px` 保持原比例:`400×531` 素材生成 `350×466` 节点;视频为 `622×350`,音频为 `350×148`。第一个节点左上角放在当前视口中心;多选按 X 轴每项 `450px` 、相同 Y 排列,不自动选中,不创建连线。确认后等待保存并刷新仍恢复。
- 竞品的默认标题是按媒体类型独立递增的 `图片节点 N / 视频节点 N / 音频节点 N`,页面会话中删除后不回收序号;历史资产已有自定义标题时保留原标题。本地将序号、动态尺寸、分页和落位算法集中为纯函数,避免在组件中猜测。
- 本地的选择上限、短边、页容量、落位间距、来源开关和每来源媒体模式全部由 `video_canvas_function_config.historyAssets.picker` 下发,管理页已提供完整配置。本地 Chrome 实页确认 15 张首页、第二页、跨页 `2/10`、来源媒体页签、`350×350 + 700×350`、水平间距 450 与同 Y 坐标全部一致。
- 主菜单上传已从不可持久的 `blob:` URL 改为受认证文件接口,上传约束来自 `nodeCreation.upload`,资产以 `sourceType=video_canvas_upload` 注册。本轮用真实 PNG、WebM 和 WAV 调用接口,三者均得到独立持久 URL,并能由历史接口回读为 `image/png / video/webm / audio/wav`。存储层补齐常见音视频 MIME 映射,不再把 WebM/WAV 记为 `application/octet-stream`。
- 测试结束后已删除 23 条测试资产和 5 个上传副本;固定本地工程恢复 `0 节点 / 0 连线 / 0 选中项 / {x:0,y:0,zoom:1}`。生成历史/节点创建定向测试 `34/34`、全套 helper `339/339` 与 TypeScript 通过;竞品最终仍为原有 `2 节点 / 3 连线`,积分保持 `3,102`,本轮未触发生成或付费动作。

View File

@ -0,0 +1,980 @@
# 策划 Skill 系统化功能开发文档
## 1. 背景
桌面目录 `/Users/a134/Desktop/策划Skills` 中包含一组可复用的 Codex Skill:
- `jinbao-detail-page-production`:电商详情页策划、视觉提示词、主图、推广素材、评论洞察、竞品分析、AI 模拟评论。
- `beautiful-html-templates`:HTML 演示稿模板库。
- `lovart-api`:Lovart 图片、视频、音频生成 API Skill。
- `guided-reading-companion`:引导式阅读、资料理解、记忆与表达辅助。
- `beautiful-feishu-whiteboard`:飞书白板生成 Skill。
本项目已有商品策划、Canvas、详情页 UI 协议、Agent 工作流、AI 供应商配置等能力。新的目标不是把这些 Skill 当成外部工具临时调用,而是把它们产品化为系统内部可维护、可路由、可版本化、可测试的策划生产能力。
重要决策:
- 不使用飞书作为结果载体。
- 尽量使用原 Skill 文件和 references 中的规则。
- 系统页面负责结构化展示、编辑、确认、出图、归档。
- Skill 规则不直接变成一段不可控大 prompt,而是通过 Skill Runtime 读取、路由、组装和校验。
## 2. 建设目标
新增一个系统内功能页面:`详情页策划工坊`。
推荐路由:
```text
/product-planning/detail-production
```
它用于完成:
1. 产品事实录入与素材管理。
2. 评论、问大家、竞品、SKU、商品参数等证据整理。
3. 基于 Skill 生成逐屏内容卡。
4. 基于 Skill 生成主图、详情页、买家秀、推广素材提示词。
5. 经过确认门后接入现有生图、Canvas、详情 UI 预览能力。
6. 支持用户维护、Fork、发布、回滚业务 Skill。
## 3. 当前项目可复用能力
### 3.1 商品策划工具
当前已有:
```text
app/(chat)/product-planning/page.tsx
components/product-planning/product-planning-page.tsx
app/api/product-planning/analyze/route.ts
app/api/product-planning/analyze-image/route.ts
app/api/product-planning/analyze-sku/route.ts
app/api/product-planning/marketplace/items/route.ts
app/api/product-planning/taobao/*
app/api/product-planning/jd/*
lib/product-planning/*
```
可复用能力:
- 商品 ID 查询。
- 淘宝/京东商品详情标准化。
- 评价与问大家获取。
- 关键词拆词。
- 竞品图片分析。
- SKU 分析。
- 模板管理。
- Excel/Markdown 导出。
### 3.2 Canvas 与详情页能力
当前已有:
```text
app/api/canvas/detail-plan/route.ts
lib/detail-ui-design-skill.ts
lib/detail-ui-protocol.ts
lib/detail-ui-surface-adapter.ts
components/canvas/DetailSurfaceInspectorPanel.tsx
components/canvas/DetailUiDomRenderer.tsx
```
可复用能力:
- 790x1300 详情页 UI 协议。
- AGENT 详情排版模式。
- 可编辑详情 UI 层。
- 详情 Surface 校验。
- Canvas 中详情页预览和后续编辑。
### 3.3 Agent 与 Skill 快捷入口
当前已有:
```text
lib/ai/skill-router.ts
lib/ai/tools-registry.ts
lib/canvas-skill-tags.ts
lib/ai/runtime/agent-workflow-state.ts
```
可复用能力:
- 用户意图路由。
- Agent 工具白名单。
- Canvas 快捷技能标签。
- 需要确认的工作流状态。
## 4. Skill 使用策略
### 4.1 核心使用 Skill
第一阶段重点接入:
```text
/Users/a134/Desktop/策划Skills/jinbao-detail-page-production
```
该 Skill 是本功能的业务主干,应尽量原样使用:
- `SKILL.md` 作为技能入口和任务路由。
- `references/default-handling.md` 作为默认处理规则。
- `references/module-1-content-planning.md` 作为详情页内容策划规则。
- `references/prompt-generation-framework.md` 作为视觉提示词通用框架。
- `references/module-2-visual-design.md` 作为详情页逐屏视觉提示词规则。
- `references/ecommerce-main-image-prompts.md` 作为主图提示词规则。
- `references/module-4-comment-insights.md` 作为评论洞察规则。
- `references/module-5-competitor-analysis.md` 作为竞品轻分析规则。
- `references/module-6-buyer-show.md` 作为 AI 模拟评论与买家秀四宫格规则。
- `references/product-naming.md` 作为产品命名优化规则。
- `references/module-3-*` 作为后续推广素材扩展。
### 4.2 暂不作为核心执行的 Skill
`beautiful-feishu-whiteboard` 不接入飞书写入流程。可借鉴其中两类思想:
- 结果必须可编辑,而不是只给图片。
- 视觉产物必须经过溢出、遮挡、裁切、文字可读性检查。
但不要在系统中依赖:
- `lark-cli`
- 飞书登录态
- 飞书白板 block
- 飞书链接
### 4.3 后续可选 Skill
`beautiful-html-templates` 后续可作为“HTML 策划案/汇报 Deck 导出”能力。
`lovart-api` 后续可作为一个可选生成供应商 Skill,但不替代当前项目已有模型供应商体系。
`guided-reading-companion` 后续可用于资料研读、评论陪读式分析、用户访谈理解,不作为详情页生产 MVP 必需项。
## 5. 产品形态
### 5.1 页面名称
推荐名称:
```text
详情页策划工坊
```
### 5.2 页面主流程
```text
产品事实
→ 证据池
→ 逐屏内容卡
→ 视觉提示词
→ 确认后生成
→ 详情页预览
→ 版本归档
```
### 5.3 页面区域
#### 产品事实区
字段建议:
- 产品名
- 品类
- 品牌
- 产品定位
- 目标人群
- 核心场景
- 核心卖点
- 参数、材质、规格
- 认证、检测、服务边界
- 禁用表达
- 品牌调性
- 产品参考图
- 模特参考图
- SKU 图
- 详情页参考图
- 竞品参考材料
#### 证据池
证据来源:
- 商品详情接口
- 商品参数
- SKU 数据
- 评价
- 追评
- 问大家
- 竞品主图
- 竞品详情页
- 用户手动输入事实
证据状态:
- `已确认`
- `待核验`
- `不可用`
- `不适用`
每条策划结论都应能回到证据来源。
#### 逐屏内容卡
结构应贴合 `module-1-content-planning.md`:
```text
屏序:
屏幕类型:首屏 / 痛点 / 卖点 / 场景 / 基础信息 / 决策收口
信息任务:
主标题:
副标题:
辅助文案:
唯一核心卖点或具体场景:
证据状态:
风险边界:
```
前端能力:
- 卡片编辑
- 卡片排序
- 卡片复制
- 卡片删除
- 单屏重生成
- 全部重生成
- 证据引用查看
- 风险提示展开
#### 视觉提示词表
结构应贴合 `prompt-generation-framework.md`:
```text
屏序
屏幕任务与类型
主体
主体配饰
镜头与构图
场景
风格
最终生成载荷
证据缺口
确认状态
生成图片
```
重要规则:
- 详情页逐屏默认比例为 `3:4`。
- 默认不让生图模型生成可见文字。
- 文案进入系统可编辑 UI 层。
- 提示词整套确认后才允许出图。
- 任一提示词发生实质修改后,原确认失效。
#### 详情页预览区
系统内展示,不使用飞书白板。
可选展示方式:
1. 手机长图预览。
2. 单屏卡片预览。
3. Canvas Detail Surface 预览。
4. 可编辑 UI 层预览。
5. 图片层 + 文案层分离预览。
#### 生成记录区
记录:
- 每屏生成版本
- 使用模型
- 使用提示词版本
- 生成状态
- 失败原因
- 用户选择的最终版本
- 重生图历史
## 6. Skill Runtime 架构
### 6.1 总体流程
```mermaid
flowchart TD
A["用户输入 / 商品数据 / 素材"] --> B["Skill Router"]
B --> C["Skill Package Loader"]
C --> D["读取 SKILL.md"]
D --> E["按任务读取 references"]
E --> F["Prompt Assembler"]
F --> G["平台硬规则注入"]
G --> H["JSON Schema 约束"]
H --> I["AI Model Runtime"]
I --> J["结果解析与校验"]
J --> K["证据与风险校验"]
K --> L["保存 Skill Run"]
L --> M["页面结构化展示"]
```
### 6.2 分层原则
```text
平台硬规则:系统锁定,不可被用户修改
官方 Skill:从原始 Skill 包导入,作为默认版本
业务 Skill:用户 Fork 后可维护
运行时规则:按任务动态读取对应模块
输出协议:系统强制 JSON Schema 校验
```
### 6.3 平台硬规则
以下规则应由系统固定注入,不允许用户删除:
- 不编造参数、认证、检测、价格、功效、案例、平台规则、真实评价、竞品事实。
- 评论和问大家必须分离。
- AI 模拟评论必须明确标识,不能作为真实用户证据。
- 竞品素材不能默认获得视觉使用权。
- 缺少产品外观证据时,不能补造结构、材质、颜色、连接关系。
- 视觉提示词确认后才允许出图。
- 输出必须符合系统 JSON Schema。
- 失败时返回结构化错误,不返回半截不可解析结果。
## 7. 推荐目录结构
```text
app/(chat)/product-planning/detail-production/page.tsx
components/product-planning/detail-production/
detail-production-page.tsx
product-facts-panel.tsx
evidence-pool-panel.tsx
content-card-board.tsx
visual-prompt-table.tsx
detail-preview-panel.tsx
generation-history-panel.tsx
skill-version-selector.tsx
app/api/product-planning/detail-production/
content-plan/route.ts
visual-prompts/route.ts
main-image-prompts/route.ts
buyer-show-prompts/route.ts
competitor-lite/route.ts
comment-insights/route.ts
runs/route.ts
runs/[id]/route.ts
app/api/product-planning/skills/
route.ts
[id]/route.ts
[id]/versions/route.ts
[id]/versions/[versionId]/route.ts
[id]/fork/route.ts
[id]/test-run/route.ts
[id]/publish/route.ts
[id]/rollback/route.ts
lib/product-planning/skill-runtime/
skill-package-loader.ts
skill-router.ts
skill-prompt-assembler.ts
skill-schema.ts
skill-versioning.ts
skill-runner.ts
skill-validation.ts
platform-rules.ts
lib/product-planning/detail-production/
types.ts
schemas.ts
content-plan.ts
visual-prompts.ts
evidence.ts
confirmation.ts
prompt-cleaner.ts
result-normalizer.ts
```
## 8. 数据模型建议
### 8.1 MVP 可先使用 SystemSettings
第一版可以用 `SystemSettings` 存:
- 官方 Skill 索引
- 用户 Skill 草稿
- 当前发布版本
- Skill 配置
优点是迁移小、开发快。
缺点是版本对比、运行日志、权限审计会比较弱。
### 8.2 正式版建议新增表
建议后续新增:
```text
PlanningSkill
PlanningSkillVersion
PlanningSkillRun
PlanningSkillArtifact
PlanningEvidence
```
#### PlanningSkill
```ts
type PlanningSkill = {
id: string;
slug: string;
name: string;
description: string;
sourceType: "builtin" | "user_fork";
baseSkillId?: string;
ownerUserId?: string;
status: "active" | "archived";
createdAt: string;
updatedAt: string;
};
```
#### PlanningSkillVersion
```ts
type PlanningSkillVersion = {
id: string;
skillId: string;
version: number;
status: "draft" | "published" | "archived";
entryMarkdown: string;
references: Array<{
path: string;
content: string;
}>;
triggerKeywords: string[];
inputSchema: unknown;
outputSchema: unknown;
changelog?: string;
createdBy: string;
createdAt: string;
publishedAt?: string;
};
```
#### PlanningSkillRun
```ts
type PlanningSkillRun = {
id: string;
userId: string;
skillId: string;
skillVersionId: string;
taskType:
| "content_plan"
| "visual_prompts"
| "main_image_prompts"
| "comment_insights"
| "competitor_lite"
| "buyer_show"
| "promotion_materials";
input: unknown;
output: unknown;
status: "running" | "completed" | "failed";
error?: string;
modelId?: string;
createdAt: string;
completedAt?: string;
};
```
#### PlanningEvidence
```ts
type PlanningEvidence = {
id: string;
runId: string;
sourceType:
| "product_fact"
| "product_parameter"
| "sku"
| "review"
| "qa"
| "competitor"
| "image"
| "manual";
sourceId?: string;
content: string;
status: "confirmed" | "pending_verification" | "unavailable" | "not_applicable";
trace: unknown;
createdAt: string;
};
```
## 9. API 设计
### 9.1 生成逐屏内容卡
```http
POST /api/product-planning/detail-production/content-plan
```
请求:
```ts
type ContentPlanRequest = {
skillVersionId?: string;
productFacts: ProductFacts;
evidenceIds?: string[];
mode?: "guided" | "continuous" | "review";
screenCount?: number;
};
```
响应:
```ts
type ContentPlanResponse = {
success: boolean;
runId?: string;
summary?: string;
cards?: DetailContentCard[];
evidence?: EvidenceItem[];
risks?: RiskItem[];
missingRequiredFields?: string[];
error?: string;
};
```
### 9.2 生成详情页视觉提示词
```http
POST /api/product-planning/detail-production/visual-prompts
```
请求:
```ts
type VisualPromptsRequest = {
skillVersionId?: string;
contentPlanRunId?: string;
cards: DetailContentCard[];
productFacts: ProductFacts;
referenceImages: ReferenceImage[];
brandStyle?: string;
confirmationBypass?: boolean;
};
```
响应:
```ts
type VisualPromptsResponse = {
success: boolean;
runId?: string;
confirmationRequired: boolean;
confirmationToken?: string;
prompts?: DetailVisualPrompt[];
blockedScreens?: BlockedScreen[];
risks?: RiskItem[];
error?: string;
};
```
### 9.3 确认提示词并出图
出图可以复用现有 Canvas 生图接口或新增聚合接口。
建议接口:
```http
POST /api/product-planning/detail-production/generate-screens
```
请求:
```ts
type GenerateScreensRequest = {
visualPromptRunId: string;
confirmationToken: string;
screenIndexes?: number[];
modelId?: string;
publishToCanvas?: boolean;
};
```
关键规则:
- 没有确认 token 不允许生成。
- 提示词版本变更后 token 失效。
- 单屏重生成也要记录版本。
### 9.4 Skill 管理
```http
GET /api/product-planning/skills
POST /api/product-planning/skills
GET /api/product-planning/skills/[id]
PATCH /api/product-planning/skills/[id]
POST /api/product-planning/skills/[id]/fork
POST /api/product-planning/skills/[id]/test-run
POST /api/product-planning/skills/[id]/publish
POST /api/product-planning/skills/[id]/rollback
```
权限:
- 普通用户:使用已发布 Skill,查看自己的运行结果。
- 管理员 / agent_admin:创建、编辑、发布、回滚、查看运行日志。
- Fork 后的用户 Skill 可按团队策略决定是否仅自己可见。
## 10. 前端交互设计
### 10.1 顶部状态栏
展示:
- 当前 Skill
- 当前 Skill 版本
- 当前流程状态
- 产品事实完整度
- 证据完整度
- 提示词确认状态
### 10.2 左侧输入区
包含:
- 产品事实表单
- 素材上传
- 商品 ID 导入
- 评价/问大家导入
- 竞品链接/ID 导入
### 10.3 中间工作区
用 Tabs 或分步导航:
```text
事实
证据
内容卡
视觉提示词
预览
记录
```
### 10.4 右侧检查区
展示:
- 缺失字段
- 待核验证据
- 合规风险
- 视觉证据缺口
- 生成前检查项
### 10.5 Skill 版本选择
支持:
- 查看当前使用的 Skill 名称和版本。
- 切换已发布版本。
- 查看版本说明。
- 管理员进入编辑页。
- 用户 Fork 官方 Skill。
## 11. 输出结构
### 11.1 ProductFacts
```ts
type ProductFacts = {
productName: string;
category?: string;
brand?: string;
positioning?: string;
targetAudience: string;
coreScenario?: string;
sellingPoints: string[];
parameters?: Array<{ name: string; value: string; evidenceStatus: EvidenceStatus }>;
certifications?: Array<{ name: string; evidenceStatus: EvidenceStatus }>;
serviceBoundaries?: string[];
bannedWords?: string[];
brandTone?: string;
};
```
### 11.2 DetailContentCard
```ts
type DetailContentCard = {
index: number;
screenType: "hero" | "pain_point" | "selling_point" | "scenario" | "basic_info" | "closing";
informationTask: string;
title: string;
subtitle: string;
supportingCopy?: string;
corePoint: string;
evidenceStatus: EvidenceStatus;
evidenceIds: string[];
riskBoundary?: string;
};
```
### 11.3 DetailVisualPrompt
```ts
type DetailVisualPrompt = {
index: number;
screenTask: string;
subject: string;
subjectAccessories?: string;
cameraAndComposition: string;
scene: string;
style: string;
finalPayload: string;
evidenceGaps: string[];
confirmationStatus: "draft" | "confirmed" | "invalidated";
generatedImages?: GeneratedImageVersion[];
};
```
### 11.4 RiskItem
```ts
type RiskItem = {
level: "info" | "warning" | "blocking";
code: string;
message: string;
relatedField?: string;
relatedScreenIndex?: number;
};
```
## 12. Skill 可维护能力
### 12.1 用户可编辑内容
允许维护:
- Skill 名称
- Skill 描述
- 触发关键词
- 输入字段说明
- 处理流程
- Prompt 模板
- 输出字段
- 示例案例
- 禁用词
- 品类专属规则
- 品牌表达规则
- 默认屏数
- 默认模块
### 12.2 用户不可编辑内容
平台锁定:
- 安全与合规规则
- 事实不可编造规则
- 模拟评论标识规则
- 竞品素材授权规则
- 确认门规则
- JSON Schema 强制输出规则
- 运行日志记录规则
### 12.3 版本状态
```text
draft:草稿
published:已发布
archived:历史归档
```
发布前必须通过:
- Markdown 结构检查
- references 链接检查
- 输出 Schema 检查
- 样例测试运行
- 平台硬规则冲突检查
## 13. 实施阶段
### 阶段一:MVP
目标:系统内可完整跑通详情页策划文本链路。
范围:
1. 新增详情页策划工坊页面。
2. 导入 `jinbao-detail-page-production` 官方 Skill。
3. 实现 Skill Package Loader。
4. 实现内容策划 API。
5. 实现逐屏内容卡展示与编辑。
6. 实现证据池基础展示。
7. 保存运行记录。
不做:
- 飞书。
- HTML Deck。
- Lovart。
- 自动出图。
- Skill 可视化高级编辑器。
### 阶段二:视觉提示词与确认门
目标:把内容卡转成逐屏视觉提示词,并严格执行确认后出图。
范围:
1. 接入 `prompt-generation-framework.md`。
2. 接入 `module-2-visual-design.md`。
3. 建立参考图证据表。
4. 生成视觉提示词表。
5. 实现整套确认 token。
6. 接入现有 Canvas 生图或详情页生成能力。
7. 展示生成记录。
### 阶段三:主图、竞品、评论、买家秀
目标:补齐 `jinbao-detail-page-production` 中高价值模块。
范围:
1. 主图文案与提示词。
2. 评论洞察严谨版。
3. 竞品轻分析严谨版。
4. AI 模拟评论与手机随拍四宫格提示词。
5. 与现有商品策划页分析结果互通。
### 阶段四:Skill 管理后台
目标:让管理员和业务用户维护 Skill。
范围:
1. Skill 列表。
2. Skill 详情。
3. Fork 官方 Skill。
4. 编辑草稿。
5. 测试运行。
6. 发布版本。
7. 回滚版本。
8. 运行日志。
### 阶段五:导出与扩展
目标:把策划结果变成更多交付物。
范围:
1. HTML 策划案导出。
2. Markdown/PDF 导出。
3. 长图拼接。
4. 详情页方案包下载。
5. 可选接入 Lovart Provider。
## 14. 验收标准
### MVP 验收
1. 用户能打开 `/product-planning/detail-production`。
2. 用户能填写产品事实并提交。
3. 系统能读取官方 Skill 与对应 references。
4. 系统能生成结构化逐屏内容卡。
5. 内容卡能编辑、排序、保存。
6. 每条关键结论能看到证据状态。
7. 缺少产品名、目标人群、核心卖点时系统能阻止或提示。
8. 输出不是单纯 Markdown,而是结构化 JSON + 页面渲染。
9. 运行结果能保存并重新打开。
### 视觉提示词验收
1. 系统能生成每屏五字段提示词。
2. 系统能标注产品外观证据缺口。
3. 详情页提示词默认 `3:4`。
4. 默认不让生图模型生成文字。
5. 未确认时不能出图。
6. 提示词修改后确认状态失效。
7. 生成记录可追溯到提示词版本。
### Skill 管理验收
1. 管理员能查看官方 Skill。
2. 管理员能 Fork 为业务 Skill。
3. 草稿修改不会影响已发布版本。
4. 发布前必须测试运行。
5. 可查看版本差异。
6. 可回滚历史版本。
7. 普通用户只能使用已发布版本。
## 15. 风险与建议
### 15.1 风险:直接塞大 prompt
不要把 Skill 全部拼成一个大 prompt 后直接调用模型。这样会导致:
- 输出不可控。
- 难以定位错误。
- 难以版本管理。
- 难以测试。
- 用户修改容易破坏底层规则。
建议:
- 按任务读取最小必要 references。
- 用 JSON Schema 约束输出。
- 用平台硬规则兜底。
- 对关键字段做后置校验。
### 15.2 风险:结果只做 Markdown
Markdown 适合阅读,不适合系统化生产。
建议:
- 后端返回结构化 JSON。
- 页面渲染为卡片、表格、预览和记录。
- Markdown 只作为导出格式。
### 15.3 风险:用户可维护导致规则失控
用户维护 Skill 是必要能力,但不能允许绕过平台规则。
建议:
- 官方 Skill 只读。
- 用户通过 Fork 维护业务 Skill。
- 平台硬规则不可编辑。
- 发布前做冲突检查和测试运行。
### 15.4 风险:飞书 Skill 误接入
本系统不使用飞书。不要引入 `lark-cli`、飞书登录、飞书白板写入作为生产依赖。
建议:
- 保留“可编辑结果”和“视觉检查”思想。
- 系统页面自行展示结果。
- Canvas 和 Detail Surface 作为主要承载。
## 16. 推荐最终方案
最终建议采用:
```text
独立页面 + Skill Runtime + 官方 Skill 导入 + 用户可 Fork 维护 + 结构化结果展示 + Canvas/Detail Surface 承载
```
第一优先级:
```text
jinbao-detail-page-production → 详情页策划工坊
```
不要先做飞书,不要先做 Lovart,不要先做 HTML Deck。先把电商详情页策划这条主链路跑通,形成稳定的系统能力,再扩展其他交付物。

View File

@ -21,6 +21,32 @@ export interface KreaModelPreset {
sortOrder: number;
}
export function applyKreaAudioReferences(
body: Record<string, any>,
requestFields: Iterable<string>,
audioUrls: string[],
) {
const fields = new Set(requestFields);
const normalizedUrls = audioUrls.map((url) => String(url || "").trim()).filter(Boolean);
const referenceField = fields.has("reference_audios")
? "reference_audios"
: fields.has("referenceAudios")
? "referenceAudios"
: null;
if (referenceField && normalizedUrls.length > 0) {
body[referenceField] = normalizedUrls.slice(0, 3);
}
const singleField = fields.has("audio_url")
? "audio_url"
: fields.has("audioUrl")
? "audioUrl"
: null;
if (singleField && normalizedUrls[0]) {
body[singleField] = normalizedUrls[0];
}
return body;
}
const KREA_OPENAPI_URL = "https://api.krea.ai/openapi.json";
const REFERENCE_INPUT_FIELDS = new Set([
@ -132,6 +158,14 @@ function createVideoCapabilities(modelId: string, fields: string[]) {
) {
supportedModes.add("reference-images");
}
if (
fields.includes("audio_url") ||
fields.includes("audioUrl") ||
fields.includes("referenceAudios") ||
fields.includes("reference_audios")
) {
supportedModes.add("audio-to-video");
}
if (supportedModes.size === 0) supportedModes.add("text-to-video");
const durationOptions = fields.includes("duration") ? createVideoDurationOptions(modelId) : [5];

View File

@ -3,6 +3,7 @@ import "server-only";
import sharp from "sharp";
import { resolveLongestEdgeDimensions } from "@/lib/ai/image-size-constraints";
import {
applyKreaAudioReferences,
getKreaModelPresetById,
inferKreaModelKind,
type KreaModelKind,
@ -25,12 +26,13 @@ interface KreaImageParams {
}
interface KreaVideoParams {
mode?: "text-to-video" | "first-frame" | "first-last-frame" | "reference-images";
mode?: "text-to-video" | "first-frame" | "first-last-frame" | "reference-images" | "audio-to-video";
ratio?: string;
duration?: number;
resolution?: string;
seed?: number;
generateAudio?: boolean;
audioUrls?: string[];
}
interface UploadedImage {
@ -473,6 +475,10 @@ function summarizeKreaReferenceFields(body: Record<string, any>) {
"style_references",
"elementReferences",
"element_references",
"referenceAudios",
"reference_audios",
"audioUrl",
"audio_url",
]) {
const value = body[field];
if (Array.isArray(value)) {
@ -840,6 +846,7 @@ function buildKreaBody(params: {
prompt: string;
imageUrls: string[];
videoUrls: string[];
audioUrls: string[];
referenceAspectRatio?: string;
imageParams?: KreaImageParams;
videoParams?: KreaVideoParams;
@ -852,6 +859,7 @@ function buildKreaBody(params: {
prompt,
imageUrls,
videoUrls,
audioUrls,
referenceAspectRatio,
imageParams,
videoParams,
@ -1077,6 +1085,8 @@ function buildKreaBody(params: {
body[referenceVideosField] = videoUrls.slice(0, 3);
}
applyKreaAudioReferences(body, fields, audioUrls);
if (
(required.has("referenceImages") || required.has("reference_images")) &&
!body.referenceImages &&
@ -1090,6 +1100,15 @@ function buildKreaBody(params: {
if (required.has("init_video") && !body.init_video) {
throw new Error("该 Krea 模型需要上传视频");
}
if (
videoMode === "audio-to-video" &&
!body.referenceAudios &&
!body.reference_audios &&
!body.audio_url &&
!body.audioUrl
) {
throw new Error("该 Krea 模型不支持音频参考输入");
}
return { body, imageSize: imageDimensions.imageSize };
}
@ -1120,6 +1139,11 @@ export function createKrea({ apiKey, baseURL }: KreaConfig) {
const normalizedVideoUrls = (
await Promise.all(videoUrls.map((url) => normalizeAssetUrl(url)))
).filter(Boolean);
const normalizedAudioUrls = (
await Promise.all(
(options.videoParams?.audioUrls || []).map((url) => normalizeAssetUrl(url))
)
).filter(Boolean);
const kreaImageUrls = (
await Promise.all(
normalizedImageUrls.map((url) => uploadKreaReferenceAsset(baseURL, apiKey, url))
@ -1154,6 +1178,7 @@ export function createKrea({ apiKey, baseURL }: KreaConfig) {
prompt: promptForKrea,
imageUrls: kreaImageUrls,
videoUrls: normalizedVideoUrls,
audioUrls: normalizedAudioUrls,
referenceAspectRatio,
imageParams: options.imageParams,
videoParams: options.videoParams,
@ -1165,6 +1190,7 @@ export function createKrea({ apiKey, baseURL }: KreaConfig) {
requestFields: Object.keys(body),
imageCount: normalizedImageUrls.length,
videoCount: normalizedVideoUrls.length,
audioCount: normalizedAudioUrls.length,
referenceAspectRatio,
referenceFields: summarizeKreaReferenceFields(body),
referencePreservingPrompt: promptForKrea !== prompt,

View File

@ -46,6 +46,7 @@ export async function callQwen3Vl(params: {
prompt: string;
temperature?: number;
maxTokens?: number;
modelIds?: string[];
timeoutMs?: number;
retries?: number;
}) {
@ -65,8 +66,14 @@ export async function callQwen3Vl(params: {
}
let lastError: Error | null = null;
const modelIds = Array.from(
new Set([
...(Array.isArray(params.modelIds) ? params.modelIds : []),
...QWEN3_VL_MODELS,
].map((item) => String(item || "").trim()).filter(Boolean)),
);
for (const model of QWEN3_VL_MODELS) {
for (const model of modelIds) {
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const controller = new AbortController();

View File

@ -11,6 +11,8 @@ import {
getSubModelsByParentModelId
} from "@/lib/db/queries";
import { getConfiguredVideoDurationOptions } from "@/lib/ai/video-model-config";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { getKreaModelPresetById } from "@/lib/ai/krea-models";
import type {
VideoGenerationMode,
VideoModelCapabilities,
@ -21,9 +23,11 @@ export interface ExecuteDirectVideoGenerationParams {
prompt: string;
mode: VideoGenerationMode;
images?: string[];
resolution?: "480p" | "720p" | "1080p";
audioUrls?: string[];
resolution?: "480p" | "720p" | "1080p" | "4k";
ratio?: "16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9" | "adaptive";
duration?: number;
generateAudio?: boolean;
watermark?: boolean;
cameraFixed?: boolean;
seed?: number;
@ -54,6 +58,123 @@ function inferSupportedModesFromModelId(modelId: string): VideoGenerationMode[]
return ["text-to-video", "first-frame", "first-last-frame", "reference-images"];
}
function getModelSupportedModes(model: any): VideoGenerationMode[] {
const configured = model.model.capabilities as Partial<VideoModelCapabilities> | undefined;
const configuredModes = configured?.supportedModes;
const modes = new Set<VideoGenerationMode>(
Array.isArray(configuredModes) ? (configuredModes as VideoGenerationMode[]) : [],
);
if (model.provider.type === "krea") {
const preset = getKreaModelPresetById(String(model.model.modelId || ""));
const presetModes = preset?.capabilities?.supportedModes;
if (Array.isArray(presetModes)) {
for (const mode of presetModes as VideoGenerationMode[]) modes.add(mode);
}
}
if (modes.size > 0) return Array.from(modes);
return inferSupportedModesFromModelId(String(model.model.modelId || ""));
}
function extractVideoUrlFromGeneratedText(value: unknown) {
const text = String(value || "");
const srcMatch = text.match(/<video[^>]+src=["'](https?:\/\/[^"']+)["']/i);
if (srcMatch?.[1]) return srcMatch[1];
const markdownMatch = text.match(/\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/i);
if (markdownMatch?.[1]) return markdownMatch[1];
return text.match(/https?:\/\/[^\s"'<>]+/i)?.[0];
}
async function executeKreaAudioVideoGeneration(params: {
prompt: string;
audioUrls: string[];
resolution?: "480p" | "720p" | "1080p" | "4k";
ratio?: "16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9" | "adaptive";
duration?: number;
generateAudio?: boolean;
seed?: number;
preferredModelRef?: string;
}): Promise<ExecuteDirectVideoGenerationResult> {
if (params.audioUrls.length === 0) {
throw new Error("音频生视频至少需要 1 个可访问的音频素材");
}
if (params.resolution === "4k") {
throw new Error("当前音频参考视频模型尚未验证 4K 输出,请选择 1080P 或更低清晰度");
}
const allModels = await getEnabledAiModelsWithProvider(true);
const candidates = allModels.filter(
(item) =>
item.provider.type === "krea" &&
item.model.isEnabled &&
getModelSupportedModes(item).includes("audio-to-video"),
);
if (candidates.length === 0) {
throw new Error("没有启用支持音频参考的 Seedance 2.0 或 Wan 2.5 视频模型");
}
const preferredRef = String(params.preferredModelRef || "").trim();
const preferred = preferredRef
? candidates.find(
(item) =>
item.model.id === preferredRef ||
item.model.modelId === preferredRef ||
item.model.displayName === preferredRef,
)
: undefined;
if (preferredRef && !preferred) {
throw new Error("所选模型不支持音频参考,请选择 Seedance 2.0 或 Wan 2.5");
}
const selected =
preferred ||
[...candidates].sort((left, right) => {
const score = (modelId: string) => {
if (modelId.includes("/seedance-2") && !modelId.includes("fast")) return 3;
if (modelId.includes("/seedance-2-fast")) return 2;
if (modelId.includes("/wan-2.5")) return 1;
return 0;
};
return score(right.model.modelId) - score(left.model.modelId);
})[0]!;
const languageModel = await getLanguageModelById(selected.model.id);
if (!languageModel || typeof (languageModel as any).doGenerate !== "function") {
throw new Error("音频参考视频模型初始化失败");
}
const result = await (languageModel as any).doGenerate({
messages: [{ role: "user", content: [{ type: "text", text: params.prompt }] }],
overrideModelId: selected.model.modelId,
videoParams: {
audioUrls: params.audioUrls,
duration: params.duration || 5,
generateAudio: params.generateAudio ?? true,
mode: "audio-to-video",
ratio: params.ratio || "16:9",
resolution: params.resolution || "720p",
seed: params.seed,
},
});
if (result?.finishReason === "error") {
throw new Error(String(result?.text || "音频生视频生成失败"));
}
const videoUrl = extractVideoUrlFromGeneratedText(result?.text);
if (!videoUrl) {
throw new Error("音频生视频模型未返回可用视频地址");
}
return {
success: true,
videoUrl,
metadata: {
audioReferenceCount: params.audioUrls.length,
provider: "krea",
},
modelDisplayName: String(selected.model.displayName || selected.model.modelId),
modelId: String(selected.model.modelId || selected.model.id),
modelRef: String(selected.model.id || selected.model.modelId),
};
}
function getSupportedModes(caps: Partial<VideoModelCapabilities> | undefined, modelId: string): VideoGenerationMode[] {
const modes = (caps?.supportedModes || []) as VideoGenerationMode[];
if (Array.isArray(modes) && modes.length > 0) {
@ -305,7 +426,8 @@ function buildVideoOptions(
images: string[] | undefined,
prompt: string,
params: any,
modelId: string
modelId: string,
maxReferenceImages = 4,
): VideoGenerationOptions {
const options: VideoGenerationOptions = {
prompt,
@ -314,6 +436,7 @@ function buildVideoOptions(
resolution: params.resolution || "720p",
ratio: params.ratio || "16:9",
duration: params.duration || 5,
generateAudio: params.generateAudio ?? true,
watermark: params.watermark ?? true,
cameraFixed: params.cameraFixed ?? false,
fps: 24,
@ -326,7 +449,10 @@ function buildVideoOptions(
options.firstFrameImage = images[0];
options.lastFrameImage = images[1];
} else if (mode === "reference-images" && images && images.length > 0) {
options.referenceImages = images.slice(0, 4);
options.referenceImages = images.slice(
0,
Math.max(1, Math.round(maxReferenceImages)),
);
}
return options;
@ -358,6 +484,19 @@ export async function executeDirectVideoGeneration(
throw new Error("缺少视频生成提示词");
}
if (params.mode === "audio-to-video") {
return executeKreaAudioVideoGeneration({
audioUrls: params.audioUrls || [],
duration: params.duration,
generateAudio: params.generateAudio,
preferredModelRef: params.preferredModelRef,
prompt,
ratio: params.ratio,
resolution: params.resolution,
seed: params.seed,
});
}
const preferredParentModelId = await resolvePreferredVideoParentModelId(
params.preferredModelRef,
);
@ -392,6 +531,7 @@ export async function executeDirectVideoGeneration(
resolution: params.resolution || "720p",
ratio: params.ratio || "16:9",
duration: params.duration || 5,
generateAudio: params.generateAudio ?? true,
watermark: params.watermark ?? true,
cameraFixed: params.cameraFixed ?? false,
seed: params.seed,
@ -432,6 +572,7 @@ export async function executeDirectVideoGeneration(
prompt,
generationParams,
model.model.modelId,
capabilities.maxReferenceImages,
);
let videoMetadata: Record<string, any> | null = null;
@ -467,12 +608,16 @@ export function generateVideo({ dataStream }: { dataStream: any }) {
inputSchema: z.object({
prompt: z.string().describe("视频生成提示词,描述视频内容、镜头运动等"),
mode: z
.enum(["text-to-video", "first-frame", "first-last-frame", "reference-images"])
.describe("生成模式:文生视频/图生视频-首帧/首尾帧/参考图"),
.enum(["text-to-video", "first-frame", "first-last-frame", "reference-images", "audio-to-video"])
.describe("生成模式:文生视频/图生视频-首帧/首尾帧/参考图/音频参考"),
images: z
.array(z.string())
.optional()
.describe("图片 URL 数组(根据模式不同,需要 0-4 张)"),
.describe("图片 URL 数组(数量按当前视频模型能力动态校验)"),
audioUrls: z
.array(z.string())
.optional()
.describe("音频参考 URL 数组(音频生视频模式至少需要 1 个)"),
resolution: z
.enum(["480p", "720p", "1080p"])
.default("720p")
@ -495,7 +640,7 @@ export function generateVideo({ dataStream }: { dataStream: any }) {
.default(false)
.describe("是否返回尾帧(用于连续生成)"),
}),
execute: async ({ prompt, mode, images, resolution, ratio, duration, watermark, cameraFixed, seed, returnLastFrame }) => {
execute: async ({ prompt, mode, images, audioUrls, resolution, ratio, duration, watermark, cameraFixed, seed, returnLastFrame }) => {
try {
console.log("[Generate Video Tool] 🎬 开始生成视频");
console.log("[Generate Video Tool] 模式:", mode);
@ -513,6 +658,7 @@ export function generateVideo({ dataStream }: { dataStream: any }) {
prompt,
mode,
images,
audioUrls,
resolution,
ratio,
duration,

View File

@ -29,7 +29,11 @@ const PORTRAIT_RATIOS = ["16:9", "9:16", "1:1"];
const STANDARD_RESOLUTIONS: VideoToolResolution[] = ["480p", "720p", "1080p"];
const STANDARD_DURATIONS = [5, 10];
type ParsedVideoCapabilities = Partial<VideoModelCapabilities> & {
type ParsedVideoCapabilities = Omit<
Partial<VideoModelCapabilities>,
"constraints" | "supportedModes"
> & {
supportedModes?: VideoToolMode[];
constraints?: Partial<Record<VideoToolMode, VideoToolModeConstraint>>;
durationOptions?: number[];
};
@ -98,7 +102,7 @@ function normalizeModeConstraints(
const resolutionOptions = Array.isArray(entry.resolutionOptions)
? entry.resolutionOptions.filter(
(item): item is VideoToolResolution =>
item === "480p" || item === "720p" || item === "1080p"
item === "480p" || item === "720p" || item === "1080p" || item === "4k"
)
: undefined;
const defaults = asObject(entry.defaults);
@ -152,7 +156,8 @@ function normalizeVideoCapabilities(rawCapabilities: unknown): ParsedVideoCapabi
const supportedResolutions = Array.isArray(raw.supportedResolutions)
? raw.supportedResolutions.filter(
(item): item is VideoToolResolution => item === "480p" || item === "720p" || item === "1080p"
(item): item is VideoToolResolution =>
item === "480p" || item === "720p" || item === "1080p" || item === "4k"
)
: undefined;

315
lib/canvas/arrange.ts Normal file
View File

@ -0,0 +1,315 @@
import type { ELK, ElkNode } from "elkjs/lib/elk.bundled.js";
export const CANVAS_ARRANGE_NODE_GAP = 100;
export const CANVAS_ARRANGE_COMPONENT_GAP = 1.5 * CANVAS_ARRANGE_NODE_GAP;
export const CANVAS_ARRANGE_MAX_ROW_WIDTH = 3200;
export const CANVAS_ARRANGE_SNAP_GRID_SIZE = 12;
export const CANVAS_ARRANGE_VIEWPORT_OFFSET = 48;
export const CANVAS_ARRANGE_VIEWPORT_PADDING = 0.12;
export type CanvasArrangeNode = {
height: number;
id: string;
parentId?: string;
width: number;
x: number;
y: number;
};
export type CanvasArrangeEdge = {
source: string;
target: string;
};
export type CanvasArrangePositionUpdate = {
id: string;
position: { x: number; y: number };
};
type LayoutComponent = {
height: number;
positions: Map<string, { x: number; y: number }>;
width: number;
};
let elkPromise: Promise<ELK> | null = null;
function getElk() {
if (!elkPromise) {
elkPromise = import("elkjs/lib/elk.bundled.js")
.then(({ default: Elk }) => new Elk())
.catch((error) => {
elkPromise = null;
throw error;
});
}
return elkPromise;
}
function snapArrangeCoordinate(value: number) {
return Math.round(value / CANVAS_ARRANGE_SNAP_GRID_SIZE) * CANVAS_ARRANGE_SNAP_GRID_SIZE;
}
function getRootNodeId(nodeId: string, nodesById: Map<string, CanvasArrangeNode>) {
let currentId = nodeId;
const visited = new Set<string>();
for (let index = 0; index < 256; index += 1) {
const node = nodesById.get(currentId);
if (!node?.parentId || visited.has(currentId)) break;
visited.add(currentId);
currentId = node.parentId;
}
return currentId;
}
function groupConnectedRootNodes(
nodes: CanvasArrangeNode[],
edges: CanvasArrangeEdge[],
nodesById: Map<string, CanvasArrangeNode>
) {
const rootNodes = nodes.filter((node) => !node.parentId);
if (rootNodes.length === 0) return [];
const rootIds = new Set(rootNodes.map((node) => node.id));
const parents = new Map(rootNodes.map((node) => [node.id, node.id]));
const find = (nodeId: string) => {
let rootId = nodeId;
while (parents.get(rootId) !== rootId) {
rootId = parents.get(rootId) || rootId;
}
let currentId = nodeId;
while (parents.get(currentId) !== rootId) {
const nextId = parents.get(currentId);
parents.set(currentId, rootId);
if (!nextId) break;
currentId = nextId;
}
return rootId;
};
for (const edge of edges) {
const sourceId = getRootNodeId(edge.source, nodesById);
const targetId = getRootNodeId(edge.target, nodesById);
if (sourceId === targetId || !rootIds.has(sourceId) || !rootIds.has(targetId)) continue;
const sourceRoot = find(sourceId);
const targetRoot = find(targetId);
if (sourceRoot !== targetRoot) parents.set(sourceRoot, targetRoot);
}
const groups = new Map<string, CanvasArrangeNode[]>();
for (const node of rootNodes) {
const rootId = find(node.id);
const group = groups.get(rootId) || [];
group.push(node);
groups.set(rootId, group);
}
const sortedGroups = [...groups.values()].map((group) =>
group.sort((first, second) => {
const yDifference = first.y - second.y;
return Math.abs(yDifference) > 1 ? yDifference : first.x - second.x;
})
);
return sortedGroups.sort((first, second) => {
const firstY = Math.min(...first.map((node) => node.y));
const secondY = Math.min(...second.map((node) => node.y));
if (Math.abs(firstY - secondY) > 1) return firstY - secondY;
return (
Math.min(...first.map((node) => node.x)) -
Math.min(...second.map((node) => node.x))
);
});
}
async function layoutComponent(
componentNodes: CanvasArrangeNode[],
edges: CanvasArrangeEdge[],
nodesById: Map<string, CanvasArrangeNode>
): Promise<LayoutComponent | null> {
const componentIds = new Set(componentNodes.map((node) => node.id));
const seenEdges = new Set<string>();
const layoutEdges: NonNullable<ElkNode["edges"]> = [];
for (const edge of edges) {
const sourceId = getRootNodeId(edge.source, nodesById);
const targetId = getRootNodeId(edge.target, nodesById);
if (
sourceId === targetId ||
!componentIds.has(sourceId) ||
!componentIds.has(targetId)
) {
continue;
}
const edgeKey = `${sourceId}->${targetId}`;
if (seenEdges.has(edgeKey)) continue;
seenEdges.add(edgeKey);
layoutEdges.push({
id: `e-${sourceId}-${targetId}`,
sources: [sourceId],
targets: [targetId],
});
}
const graph: ElkNode = {
children: componentNodes.map((node) => ({
height: Math.max(1, node.height),
id: node.id,
width: Math.max(1, node.width),
})),
edges: layoutEdges,
id: "root",
layoutOptions: {
"elk.algorithm": "layered",
"elk.direction": "RIGHT",
"elk.layered.spacing.nodeNodeBetweenLayers": String(2 * CANVAS_ARRANGE_NODE_GAP),
"elk.padding": "[ top=0, left=0, bottom=0, right=0 ]",
"elk.spacing.nodeNode": String(CANVAS_ARRANGE_NODE_GAP),
},
};
let layout: ElkNode;
try {
layout = await (await getElk()).layout(graph);
} catch {
return null;
}
const positions = new Map<string, { x: number; y: number }>();
for (const child of layout.children || []) {
if (!child.id) continue;
positions.set(child.id, { x: child.x || 0, y: child.y || 0 });
}
const minX = Math.min(...[...positions.values()].map((position) => position.x));
const minY = Math.min(...[...positions.values()].map((position) => position.y));
if (Number.isFinite(minX) && Number.isFinite(minY)) {
for (const position of positions.values()) {
position.x -= minX;
position.y -= minY;
}
}
let width = 0;
let height = 0;
for (const [nodeId, position] of positions) {
const node = nodesById.get(nodeId);
if (!node) continue;
width = Math.max(width, position.x + node.width);
height = Math.max(height, position.y + node.height);
}
return positions.size > 0 ? { height, positions, width } : null;
}
export async function arrangeCanvasNodes(
nodes: CanvasArrangeNode[],
edges: CanvasArrangeEdge[]
): Promise<CanvasArrangePositionUpdate[]> {
const rootNodes = nodes.filter((node) => !node.parentId);
if (rootNodes.length <= 1) return [];
const nodesById = new Map(nodes.map((node) => [node.id, node]));
const groups = groupConnectedRootNodes(nodes, edges, nodesById);
const components: LayoutComponent[] = [];
for (const group of groups) {
const component = await layoutComponent(group, edges, nodesById);
if (component) components.push(component);
}
const originX = snapArrangeCoordinate(Math.min(...rootNodes.map((node) => node.x)));
const originY = snapArrangeCoordinate(Math.min(...rootNodes.map((node) => node.y)));
const updates: CanvasArrangePositionUpdate[] = [];
let cursorX = originX;
let cursorY = originY;
let rowHeight = 0;
for (const component of components) {
if (
cursorX > originX &&
cursorX + component.width > originX + CANVAS_ARRANGE_MAX_ROW_WIDTH
) {
cursorY += rowHeight + CANVAS_ARRANGE_COMPONENT_GAP;
cursorX = originX;
rowHeight = 0;
}
for (const [nodeId, position] of component.positions) {
const node = nodesById.get(nodeId);
if (!node) continue;
const x = snapArrangeCoordinate(cursorX + position.x);
const y = snapArrangeCoordinate(cursorY + position.y);
if (Math.abs(x - node.x) > 0.5 || Math.abs(y - node.y) > 0.5) {
updates.push({ id: nodeId, position: { x, y } });
}
}
cursorX += component.width + CANVAS_ARRANGE_COMPONENT_GAP;
rowHeight = Math.max(rowHeight, component.height);
}
return updates;
}
export function applyCanvasArrangeUpdates<TNode extends CanvasArrangeNode>(
nodes: TNode[],
updates: CanvasArrangePositionUpdate[]
) {
const positionsById = new Map(updates.map((update) => [update.id, update.position]));
return nodes.map((node) => {
const position = positionsById.get(node.id);
return position ? { ...node, ...position } : node;
});
}
export function getArrangeCanvasViewport({
canvasHeight,
canvasWidth,
maxZoom,
minZoom,
nodes,
}: {
canvasHeight: number;
canvasWidth: number;
maxZoom: number;
minZoom: number;
nodes: CanvasArrangeNode[];
}) {
if (nodes.length === 0 || canvasWidth <= 0 || canvasHeight <= 0) return null;
const minX = Math.min(...nodes.map((node) => node.x));
const minY = Math.min(...nodes.map((node) => node.y));
const maxX = Math.max(...nodes.map((node) => node.x + node.width));
const maxY = Math.max(...nodes.map((node) => node.y + node.height));
const width = maxX - minX;
const height = maxY - minY;
if (width <= 0 || height <= 0) return null;
const horizontalPadding = Math.floor(
((canvasWidth - canvasWidth / (1 + CANVAS_ARRANGE_VIEWPORT_PADDING)) * 0.5)
);
const verticalPadding = Math.floor(
((canvasHeight - canvasHeight / (1 + CANVAS_ARRANGE_VIEWPORT_PADDING)) * 0.5)
);
const zoom = Math.min(
maxZoom,
Math.max(
minZoom,
Math.min(
(canvasWidth - 2 * horizontalPadding) / width,
(canvasHeight - 2 * verticalPadding) / height
)
)
);
return {
x: CANVAS_ARRANGE_VIEWPORT_OFFSET - minX * zoom,
y: CANVAS_ARRANGE_VIEWPORT_OFFSET - minY * zoom,
zoom,
};
}

View File

@ -0,0 +1,34 @@
export const WORKSPACE_PANEL_WIDTH_STORAGE_KEY =
"libtv:workspacePanelDrawer:width";
export const WORKSPACE_PANEL_MIN_WIDTH = 240;
export const WORKSPACE_PANEL_MAX_WIDTH = 380;
export const WORKSPACE_PANEL_VIEWPORT_GUTTER = 48;
export function clampWorkspacePanelWidth(
requestedWidth: number,
viewportWidth: number,
) {
const availableWidth = Math.min(
WORKSPACE_PANEL_MAX_WIDTH,
Math.max(0, viewportWidth - WORKSPACE_PANEL_VIEWPORT_GUTTER),
);
const minimumWidth = Math.min(WORKSPACE_PANEL_MIN_WIDTH, availableWidth);
const roundedWidth = Number.isFinite(requestedWidth)
? Math.round(requestedWidth)
: minimumWidth;
return Math.max(minimumWidth, Math.min(availableWidth, roundedWidth));
}
export function getInitialWorkspacePanelWidth(
viewportWidth: number,
persistedWidth?: string | number | null,
) {
const parsedWidth = Number(persistedWidth ?? "");
if (Number.isFinite(parsedWidth) && parsedWidth > 0) {
return clampWorkspacePanelWidth(parsedWidth, viewportWidth);
}
return clampWorkspacePanelWidth(viewportWidth > 1520 ? 280 : 240, viewportWidth);
}

28
lib/canvas/grid-snap.ts Normal file
View File

@ -0,0 +1,28 @@
export const CANVAS_GRID_SNAP_SIZE = 10;
export const CANVAS_GRID_SNAP_STORAGE_KEY = "video-canvas:grid-snap-enabled:v1";
export function resolveCanvasGridSnapPosition({
deltaX,
deltaY,
enabled,
startX,
startY,
}: {
deltaX: number;
deltaY: number;
enabled: boolean;
startX: number;
startY: number;
}) {
if (!enabled) {
return {
x: Math.round(startX + deltaX),
y: Math.round(startY + deltaY),
};
}
return {
x: startX + Math.round(deltaX / CANVAS_GRID_SNAP_SIZE) * CANVAS_GRID_SNAP_SIZE,
y: startY + Math.round(deltaY / CANVAS_GRID_SNAP_SIZE) * CANVAS_GRID_SNAP_SIZE,
};
}

381
lib/canvas/grouping.ts Normal file
View File

@ -0,0 +1,381 @@
export type CanvasGroupingNode = {
draftValues?: Record<string, unknown>;
height: number;
id: string;
title?: string;
width: number;
x: number;
y: number;
};
export type CanvasGroupingEdge = {
id: string;
source: string;
target: string;
};
export const CANVAS_GROUP_PADDING = {
bottom: 20,
left: 20,
right: 20,
top: 56,
} as const;
function readStringArray(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter(Boolean);
}
export function isCanvasGroupNode(node: CanvasGroupingNode | null | undefined) {
const action = node?.draftValues?.action;
return (
node?.draftValues?.nodeType === "group" ||
action === "CANVAS_GROUP" ||
action === "CANVAS_STORYBOARD_GROUP" ||
action === "TOOLBOX_TEMPLATE_GROUP"
);
}
export function isCanvasStoryboardGroupNode(
node: CanvasGroupingNode | null | undefined,
) {
if (!isCanvasGroupNode(node)) return false;
const draftValues = node?.draftValues;
const title = node?.title?.trim() || "";
return (
draftValues?.action === "CANVAS_STORYBOARD_GROUP" ||
draftValues?.groupType === "storyboard" ||
draftValues?.storyboardGroup === true ||
/^分镜图(?:\s*·|\s|$)/u.test(title)
);
}
export function getCanvasGroupChildIds(node: CanvasGroupingNode | null | undefined) {
return readStringArray(node?.draftValues?.childNodeIds);
}
export function getCanvasGroupBounds(nodes: CanvasGroupingNode[]) {
if (nodes.length === 0) return null;
const minX = Math.min(...nodes.map((node) => node.x));
const minY = Math.min(...nodes.map((node) => node.y));
const maxX = Math.max(...nodes.map((node) => node.x + node.width));
const maxY = Math.max(...nodes.map((node) => node.y + node.height));
return {
height: maxY - minY + CANVAS_GROUP_PADDING.top + CANVAS_GROUP_PADDING.bottom,
width: maxX - minX + CANVAS_GROUP_PADDING.left + CANVAS_GROUP_PADDING.right,
x: minX - CANVAS_GROUP_PADDING.left,
y: minY - CANVAS_GROUP_PADDING.top,
};
}
export function attachCanvasNodesToGroup<T extends CanvasGroupingNode>(
nodes: T[],
groupId: string,
childNodeIds: string[],
) {
const childIds = new Set(childNodeIds);
return nodes.map((node) => {
if (node.id === groupId) {
return {
...node,
draftValues: {
...(node.draftValues || {}),
childNodeIds: [...childIds],
},
};
}
if (!childIds.has(node.id)) return node;
return {
...node,
draftValues: {
...(node.draftValues || {}),
parentGroupId: groupId,
},
};
});
}
export function collectCanvasGroupDescendantIds(
nodes: CanvasGroupingNode[],
groupIds: string[],
) {
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const descendants = new Set<string>();
const pending = [...groupIds];
while (pending.length > 0) {
const groupId = pending.pop();
if (!groupId) continue;
const group = nodeById.get(groupId);
for (const childId of getCanvasGroupChildIds(group)) {
if (descendants.has(childId)) continue;
descendants.add(childId);
if (isCanvasGroupNode(nodeById.get(childId))) pending.push(childId);
}
}
return [...descendants];
}
export function expandCanvasGroupSelection(
nodes: CanvasGroupingNode[],
selectedNodeIds: string[],
) {
const expanded = new Set(selectedNodeIds);
const selectedGroupIds = nodes
.filter((node) => expanded.has(node.id) && isCanvasGroupNode(node))
.map((node) => node.id);
for (const childId of collectCanvasGroupDescendantIds(nodes, selectedGroupIds)) {
expanded.add(childId);
}
return [...expanded];
}
function getCanvasCopyTitle(title: string) {
return title.endsWith(" - 副本") ? title : `${title} - 副本`;
}
export function duplicateCanvasNodeSelection<
TNode extends CanvasGroupingNode & { title: string },
TEdge extends CanvasGroupingEdge,
>({
createEdgeId,
createNodeId,
edges,
includeInternalEdges,
nodes,
offsetX = 0,
offsetY = 0,
selectedNodeIds,
}: {
createEdgeId: () => string;
createNodeId: () => string;
edges: TEdge[];
includeInternalEdges: boolean;
nodes: TNode[];
offsetX?: number;
offsetY?: number;
selectedNodeIds: string[];
}) {
const selectedNodeIdSet = new Set(selectedNodeIds);
const selectedGroupIds = nodes
.filter(
(node) => selectedNodeIdSet.has(node.id) && isCanvasGroupNode(node),
)
.map((node) => node.id);
const selectedGroupDescendantIds = new Set(
collectCanvasGroupDescendantIds(nodes, selectedGroupIds),
);
const sourceNodeIdSet = new Set(
expandCanvasGroupSelection(nodes, selectedNodeIds),
);
const sourceNodes = nodes.filter((node) => sourceNodeIdSet.has(node.id));
if (sourceNodes.length === 0) return null;
const rootNodeIds = selectedNodeIds.filter(
(nodeId) =>
sourceNodeIdSet.has(nodeId) &&
!selectedGroupDescendantIds.has(nodeId),
);
const rootNodeIdSet = new Set(rootNodeIds);
const idMap = new Map(
sourceNodes.map((sourceNode) => [sourceNode.id, createNodeId()]),
);
const duplicateNodes = sourceNodes.map((node) => {
const draftValues = { ...(node.draftValues || {}) };
const childNodeIds = getCanvasGroupChildIds(node);
if (childNodeIds.length > 0) {
draftValues.childNodeIds = childNodeIds
.map((childNodeId) => idMap.get(childNodeId))
.filter((childNodeId): childNodeId is string => Boolean(childNodeId));
}
const parentGroupId =
typeof draftValues.parentGroupId === "string"
? draftValues.parentGroupId
: "";
if (parentGroupId && idMap.has(parentGroupId)) {
draftValues.parentGroupId = idMap.get(parentGroupId);
} else {
delete draftValues.parentGroupId;
}
return {
...node,
draftValues,
id: idMap.get(node.id) || createNodeId(),
title: rootNodeIdSet.has(node.id)
? getCanvasCopyTitle(node.title)
: node.title,
x: node.x + offsetX,
y: node.y + offsetY,
};
});
const duplicateEdges = includeInternalEdges
? edges
.filter(
(edge) =>
sourceNodeIdSet.has(edge.source) &&
sourceNodeIdSet.has(edge.target),
)
.map((edge) => ({
...edge,
id: createEdgeId(),
source: idMap.get(edge.source) || edge.source,
target: idMap.get(edge.target) || edge.target,
}))
: [];
return {
duplicateEdges,
duplicateNodes,
rootNodeIds: rootNodeIds
.map((nodeId) => idMap.get(nodeId))
.filter((nodeId): nodeId is string => Boolean(nodeId)),
};
}
export function duplicateCanvasNodeTree<
TNode extends CanvasGroupingNode & { title: string },
TEdge extends CanvasGroupingEdge,
>({
createEdgeId,
createNodeId,
edges,
nodes,
offsetX = 0,
offsetY = 0,
rootNodeId,
}: {
createEdgeId: () => string;
createNodeId: () => string;
edges: TEdge[];
nodes: TNode[];
offsetX?: number;
offsetY?: number;
rootNodeId: string;
}) {
const rootNode = nodes.find((node) => node.id === rootNodeId);
if (!rootNode) return null;
const result = duplicateCanvasNodeSelection({
createEdgeId,
createNodeId,
edges,
includeInternalEdges: true,
nodes,
offsetX,
offsetY,
selectedNodeIds: [rootNodeId],
});
if (!result) return null;
return {
duplicateEdges: result.duplicateEdges,
duplicateNodes: result.duplicateNodes,
rootNodeId: result.rootNodeIds[0] || result.duplicateNodes[0]?.id || "",
};
}
export function detachCanvasGroups<T extends CanvasGroupingNode>(
nodes: T[],
groupIds: string[],
) {
const groups = new Set(groupIds);
const detachedChildIds = new Set<string>();
for (const groupId of groups) {
const group = nodes.find((node) => node.id === groupId);
for (const childId of getCanvasGroupChildIds(group)) {
detachedChildIds.add(childId);
}
}
const nextNodes = nodes
.filter((node) => !groups.has(node.id))
.map((node) => {
if (!detachedChildIds.has(node.id)) return node;
const draftValues = { ...(node.draftValues || {}) };
delete draftValues.parentGroupId;
return { ...node, draftValues };
});
return {
detachedChildIds: [...detachedChildIds],
nodes: nextNodes,
};
}
export function mergeCanvasStoryboardGroups<
TNode extends CanvasGroupingNode,
TEdge extends CanvasGroupingEdge,
>({
createGroup,
edges,
nodes,
selectedNodeIds,
}: {
createGroup: (input: {
bounds: NonNullable<ReturnType<typeof getCanvasGroupBounds>>;
childNodeIds: string[];
sourceGroupIds: string[];
}) => TNode;
edges: TEdge[];
nodes: TNode[];
selectedNodeIds: string[];
}) {
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const sourceGroupIds = selectedNodeIds.filter((nodeId) =>
isCanvasStoryboardGroupNode(nodeById.get(nodeId)),
);
if (sourceGroupIds.length < 2) return null;
const sourceGroupIdSet = new Set(sourceGroupIds);
const childNodeIds = [
...new Set(
sourceGroupIds.flatMap((groupId) =>
getCanvasGroupChildIds(nodeById.get(groupId)),
),
),
].filter(
(nodeId) => nodeById.has(nodeId) && !sourceGroupIdSet.has(nodeId),
);
const childNodeIdSet = new Set(childNodeIds);
const childNodes = childNodeIds.flatMap((nodeId) => {
const node = nodeById.get(nodeId);
return node ? [node] : [];
});
const bounds = getCanvasGroupBounds(childNodes);
if (!bounds || childNodes.length === 0) return null;
const detachedNodes = nodes
.filter((node) => !sourceGroupIdSet.has(node.id))
.map((node) => {
const draftValues = { ...(node.draftValues || {}) };
let changed = false;
if (childNodeIdSet.has(node.id)) {
delete draftValues.parentGroupId;
changed = true;
}
const groupChildIds = getCanvasGroupChildIds(node);
if (groupChildIds.some((childId) => sourceGroupIdSet.has(childId))) {
draftValues.childNodeIds = groupChildIds.filter(
(childId) => !sourceGroupIdSet.has(childId),
);
changed = true;
}
return changed ? { ...node, draftValues } : node;
});
const group = createGroup({ bounds, childNodeIds, sourceGroupIds });
const mergedNodes = attachCanvasNodesToGroup(
[...detachedNodes, group],
group.id,
childNodeIds,
);
const mergedEdges = edges.filter(
(edge) =>
!sourceGroupIdSet.has(edge.source) &&
!sourceGroupIdSet.has(edge.target),
);
return {
childNodeIds,
edges: mergedEdges,
group,
nodes: mergedNodes,
sourceGroupIds,
};
}

13
lib/canvas/pan.ts Normal file
View File

@ -0,0 +1,13 @@
export function isCanvasKeyboardPanKey(code: string) {
return code === "Space";
}
export function shouldStartCanvasPointerPan({
button,
keyboardPanPressed,
}: {
button: number;
keyboardPanPressed: boolean;
}) {
return button === 1 || (button === 0 && keyboardPanPressed);
}

View File

@ -0,0 +1,16 @@
export const CANVAS_PROJECT_TITLE_MAX_LENGTH = 120;
export function normalizeCanvasProjectTitle(
value: unknown,
fallback = "未命名",
) {
const normalizedFallback =
String(fallback || "未命名")
.slice(0, CANVAS_PROJECT_TITLE_MAX_LENGTH)
.trim() || "未命名";
const candidate = String(value ?? "")
.slice(0, CANVAS_PROJECT_TITLE_MAX_LENGTH)
.trim();
return candidate || normalizedFallback;
}

View File

@ -0,0 +1,67 @@
export type CanvasShortcutHelpToken =
| "command"
| "delete"
| "mouse"
| "mouse-pan"
| "minus"
| "plus"
| "trackpad-pan"
| "trackpad-zoom"
| string;
export type CanvasShortcutHelpRow = {
label: string;
suffix?: string;
tokens: CanvasShortcutHelpToken[];
};
export const VIDEO_CANVAS_SHORTCUT_HELP_GROUPS: Array<{
rows: CanvasShortcutHelpRow[];
title: string;
}> = [
{
rows: [
{ label: "成组", tokens: ["command", "G"] },
{ label: "合并分镜组", tokens: ["command", "⌥", "G"] },
{ label: "解组", tokens: ["command", "⇧", "G"] },
{ label: "连线", tokens: ["command", "L"] },
{ label: "复制节点和连线", tokens: ["command", "D"] },
{ label: "生成", tokens: ["command", "Enter"] },
{ label: "新建节点", tokens: ["Tab"] },
{ label: "节点复制", suffix: "+拖动节点", tokens: ["Option"] },
{
label: "创建副本",
suffix: "+拖动",
tokens: ["command", "Option"],
},
],
title: "创作",
},
{
rows: [
{ label: "放大", tokens: ["command", "plus"] },
{ label: "缩小", tokens: ["command", "minus"] },
{ label: "适应画布", tokens: ["command", "0"] },
{ label: "触控板", tokens: ["trackpad-zoom"] },
{ label: "鼠标", tokens: ["command", "mouse"] },
],
title: "缩放",
},
{
rows: [
{ label: "键盘", tokens: ["Space", "mouse"] },
{ label: "触控板", tokens: ["trackpad-pan"] },
{ label: "鼠标", tokens: ["mouse-pan"] },
{ label: "整理画布", tokens: ["⌥", "⇧", "F"] },
],
title: "移动画布",
},
{
rows: [
{ label: "撤销", tokens: ["command", "Z"] },
{ label: "重做", tokens: ["command", "⇧", "Z"] },
{ label: "删除", tokens: ["delete"] },
],
title: "其他",
},
];

View File

@ -0,0 +1,89 @@
import type {
VideoCanvasReferenceBehavior,
VideoCanvasReferenceSelectionConfig,
} from "@/lib/video-canvas/config";
import type { VideoGenerationMode } from "@/types/ai";
export type VideoReferenceEdge = {
id: string;
source: string;
target: string;
};
export type VideoReferenceToggleAction = "add" | "limit" | "remove";
function normalizeModelIdentity(value: string) {
return value.toLowerCase().replace(/[\s._-]+/g, "");
}
export function resolveVideoReferenceSelectionRule(
config: VideoCanvasReferenceSelectionConfig,
modelIdentity: string,
): VideoCanvasReferenceBehavior {
const normalizedIdentity = normalizeModelIdentity(modelIdentity);
const matchedRule = config.modelRules.find((rule) => {
const normalizedPattern = normalizeModelIdentity(rule.modelPattern);
return normalizedPattern && normalizedIdentity.includes(normalizedPattern);
});
const rule = matchedRule || config.defaultRule;
return {
autoExit: rule.autoExit,
hideActionAtLimit: rule.hideActionAtLimit,
maxImages: rule.maxImages,
mode: rule.mode,
};
}
export function resolveVideoReferenceToggleAction<T extends VideoReferenceEdge>(
referenceEdges: T[],
sourceId: string,
targetId: string,
maxReferences: number,
): VideoReferenceToggleAction {
if (
referenceEdges.some(
(edge) => edge.source === sourceId && edge.target === targetId,
)
) {
return "remove";
}
const referenceCount = referenceEdges.filter(
(edge) => edge.target === targetId,
).length;
return referenceCount >= Math.max(1, Math.round(maxReferences)) ? "limit" : "add";
}
export function selectVideoReferenceImages(
images: string[],
mode: VideoGenerationMode,
maxReferenceImages = 4,
) {
if (mode === "text-to-video" || mode === "audio-to-video") return [];
if (mode === "first-frame") return images.slice(0, 1);
if (mode === "first-last-frame") return images.slice(0, 2);
return images.slice(0, Math.max(1, Math.round(maxReferenceImages)));
}
export function removeVideoReferenceEdge<T extends VideoReferenceEdge>(
edges: T[],
sourceId: string,
targetId: string
) {
return edges.filter(
(edge) => !(edge.source === sourceId && edge.target === targetId)
);
}
export function toggleVideoReferenceEdge<T extends VideoReferenceEdge>(
edges: T[],
sourceId: string,
targetId: string,
createEdge: () => T
) {
const connected = edges.some(
(edge) => edge.source === sourceId && edge.target === targetId
);
return connected
? removeVideoReferenceEdge(edges, sourceId, targetId)
: [...edges, createEdge()];
}

View File

@ -0,0 +1,177 @@
import { CANVAS_PROJECT_TITLE_MAX_LENGTH } from "@/lib/canvas/project-title";
export const VIDEO_CANVAS_WORKSPACE_META_KEY = "videoCanvasWorkspace";
export const DEFAULT_VIDEO_CANVAS_NAME = "画布 1";
export type VideoCanvasWorkspaceMeta = {
canvasName: string;
workspaceId: string;
};
export type VideoCanvasWorkspaceProjectLike = {
canvasData?: unknown;
id: string;
};
export type VideoCanvasDataRecord = Record<string, unknown> & {
edges: Array<Record<string, unknown>>;
nodes: Array<Record<string, unknown>>;
selectedNodeIds?: string[];
viewport: { x: number; y: number; zoom: number };
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
export function normalizeVideoCanvasName(
value: unknown,
fallback = DEFAULT_VIDEO_CANVAS_NAME,
) {
const normalizedFallback =
String(fallback || DEFAULT_VIDEO_CANVAS_NAME)
.slice(0, CANVAS_PROJECT_TITLE_MAX_LENGTH)
.trim() || DEFAULT_VIDEO_CANVAS_NAME;
const candidate = String(value ?? "")
.slice(0, CANVAS_PROJECT_TITLE_MAX_LENGTH)
.trim();
return candidate || normalizedFallback;
}
export function getExplicitVideoCanvasWorkspaceMeta(
canvasData: unknown,
): VideoCanvasWorkspaceMeta | null {
const record = asRecord(canvasData);
const meta = asRecord(record?.[VIDEO_CANVAS_WORKSPACE_META_KEY]);
const workspaceId = String(meta?.workspaceId || "").trim();
const canvasName = normalizeVideoCanvasName(meta?.canvasName, "");
if (!(workspaceId && canvasName)) return null;
return { canvasName, workspaceId };
}
export function resolveVideoCanvasWorkspaceMeta(
project: VideoCanvasWorkspaceProjectLike,
): VideoCanvasWorkspaceMeta {
return (
getExplicitVideoCanvasWorkspaceMeta(project.canvasData) || {
canvasName: DEFAULT_VIDEO_CANVAS_NAME,
workspaceId: project.id,
}
);
}
export function getVideoCanvasWorkspaceProjects<
T extends VideoCanvasWorkspaceProjectLike,
>(projects: T[], currentProject: T) {
const { workspaceId } = resolveVideoCanvasWorkspaceMeta(currentProject);
return projects.filter((project) => {
if (project.id === currentProject.id || project.id === workspaceId) {
return true;
}
return (
getExplicitVideoCanvasWorkspaceMeta(project.canvasData)?.workspaceId ===
workspaceId
);
});
}
export function withVideoCanvasWorkspaceMeta(
canvasData: unknown,
meta: VideoCanvasWorkspaceMeta,
): VideoCanvasDataRecord {
const record = asRecord(canvasData) || {};
const viewport = asRecord(record.viewport);
const selectedNodeIds = Array.isArray(record.selectedNodeIds)
? record.selectedNodeIds.filter(
(nodeId): nodeId is string => typeof nodeId === "string",
)
: undefined;
return {
...record,
edges: Array.isArray(record.edges)
? record.edges.map((edge) => asRecord(edge) || {})
: [],
nodes: Array.isArray(record.nodes)
? record.nodes.map((node) => asRecord(node) || {})
: [],
...(selectedNodeIds ? { selectedNodeIds } : {}),
viewport: {
x: Number(viewport?.x) || 0,
y: Number(viewport?.y) || 0,
zoom: Number(viewport?.zoom) || 1,
},
[VIDEO_CANVAS_WORKSPACE_META_KEY]: {
canvasName: normalizeVideoCanvasName(meta.canvasName),
workspaceId: meta.workspaceId,
},
};
}
export function getNextVideoCanvasName(existingNames: string[]) {
const names = new Set(existingNames.map((name) => normalizeVideoCanvasName(name)));
let index = 1;
while (names.has(`画布 ${index}`)) index += 1;
return `画布 ${index}`;
}
export function getVideoCanvasCopyName(
sourceName: string,
existingNames: string[],
) {
const names = new Set(existingNames.map((name) => normalizeVideoCanvasName(name)));
const normalizedSource = normalizeVideoCanvasName(sourceName);
let index = 1;
while (true) {
const suffix = `副本${index}`;
const candidate = `${normalizedSource.slice(
0,
CANVAS_PROJECT_TITLE_MAX_LENGTH - suffix.length,
)}${suffix}`;
if (!names.has(candidate)) return candidate;
index += 1;
}
}
function remapCanvasValue(
value: unknown,
nodeIds: Map<string, string>,
): unknown {
if (typeof value === "string") return nodeIds.get(value) || value;
if (Array.isArray(value)) {
return value.map((item) => remapCanvasValue(item, nodeIds));
}
const record = asRecord(value);
if (!record) return value;
return Object.fromEntries(
Object.entries(record).map(([key, item]) => [
key,
remapCanvasValue(item, nodeIds),
]),
);
}
export function duplicateVideoCanvasData(
canvasData: unknown,
meta: VideoCanvasWorkspaceMeta,
createId = () => crypto.randomUUID(),
) {
const record = asRecord(canvasData) || {};
const nodes = Array.isArray(record.nodes) ? record.nodes : [];
const nodeIds = new Map<string, string>();
for (const node of nodes) {
const nodeId = String(asRecord(node)?.id || "").trim();
if (nodeId) nodeIds.set(nodeId, `node-${createId()}`);
}
const remapped = remapCanvasValue(record, nodeIds) as Record<string, unknown>;
const edges = Array.isArray(remapped.edges) ? remapped.edges : [];
remapped.edges = edges.map((edge) => ({
...(asRecord(edge) || {}),
id: `edge-${createId()}`,
}));
return withVideoCanvasWorkspaceMeta(remapped, meta);
}

27
lib/canvas/zoom.ts Normal file
View File

@ -0,0 +1,27 @@
export const CANVAS_MIN_ZOOM = 0.1;
export const CANVAS_MAX_ZOOM = 8;
export const CANVAS_ZOOM_FACTOR = 1.2;
export type CanvasZoomDirection = "in" | "out";
export function clampCanvasZoom(value: number) {
return Math.min(
CANVAS_MAX_ZOOM,
Math.max(CANVAS_MIN_ZOOM, Number(value.toFixed(2)))
);
}
export function stepCanvasZoom(
value: number,
direction: CanvasZoomDirection
) {
return clampCanvasZoom(
direction === "in"
? value * CANVAS_ZOOM_FACTOR
: value / CANVAS_ZOOM_FACTOR
);
}
export function sanitizeCanvasZoomInput(value: string) {
return value.replace(/\D/g, "");
}

View File

@ -1,3 +1,5 @@
import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments";
const DEFAULT_MAX_RETRIES = 3;
const MAX_BACKOFF_MS = 8000;
const MAX_CONCURRENT_UPLOADS = 3;
@ -30,6 +32,13 @@ const DIRECT_UPLOAD_IMAGE_TYPES = new Set([
"image/png",
"image/webp",
]);
const DIRECT_UPLOAD_OPENCLAW_VIDEO_TYPES = new Set([
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-m4v",
"video/x-matroska",
]);
const BROWSER_IMAGE_TYPES_BY_EXTENSION: Record<string, string> = {
jpg: "image/jpeg",
@ -298,7 +307,12 @@ function shouldTryDirectUpload(file: UploadFormFile) {
if (file.blob.size <= 0 || file.blob.size > DIRECT_UPLOAD_MAX_BYTES) {
return false;
}
if (!DIRECT_UPLOAD_IMAGE_TYPES.has(file.contentType.toLowerCase())) {
const normalizedContentType = file.contentType.toLowerCase();
const supportsDirectUpload =
DIRECT_UPLOAD_IMAGE_TYPES.has(normalizedContentType) ||
(file.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
DIRECT_UPLOAD_OPENCLAW_VIDEO_TYPES.has(normalizedContentType));
if (!supportsDirectUpload) {
return false;
}
if (file.purpose === "agent-workflow-preview") {
@ -848,7 +862,7 @@ async function fetchDirectUpload(
return createJsonResponse(
{
error:
"图片超过 50MB,必须使用浏览器直传;当前直传失败,请刷新后重试,或联系管理员检查存储桶 CORS/ETag 配置。",
"文件超过 50MB,必须使用浏览器直传;当前直传失败,请刷新后重试,或联系管理员检查存储桶 CORS/ETag 配置。",
details: error instanceof Error ? error.message : String(error),
clientUploadId: file.clientUploadId,
},

19
lib/home/sidebar-auth.ts Normal file
View File

@ -0,0 +1,19 @@
type AuthenticatedSidebarActionOptions = {
action: () => void;
isAuthenticated: boolean;
onRequireLogin: () => void;
};
export function runAuthenticatedSidebarAction({
action,
isAuthenticated,
onRequireLogin,
}: AuthenticatedSidebarActionOptions) {
if (!isAuthenticated) {
onRequireLogin();
return false;
}
action();
return true;
}

View File

@ -0,0 +1,183 @@
export const OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE = "openclaw-chat-video";
export const OPENCLAW_CHAT_VIDEO_MAX_FILES = 2;
export const OPENCLAW_CHAT_VIDEO_MAX_BYTES = 500 * 1024 * 1024;
export const OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS = 16;
export const OPENCLAW_CHAT_VIDEO_ACCEPT =
"video/mp4,video/quicktime,video/webm,video/x-m4v,video/x-matroska,.mp4,.mov,.webm,.m4v,.mkv";
const VIDEO_CONTENT_TYPES = new Set([
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-m4v",
"video/x-matroska",
]);
const VIDEO_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
m4v: "video/x-m4v",
mkv: "video/x-matroska",
mov: "video/quicktime",
mp4: "video/mp4",
webm: "video/webm",
};
const VIDEO_EXTENSION_PATTERN = /\.(mp4|mov|webm|m4v|mkv)(?:$|[?#])/i;
export type OpenClawChatMediaAttachment = {
type: "image" | "video";
url: string;
name?: string;
content_type?: string;
};
type OpenClawChatAttachmentLike = {
type?: unknown;
url?: unknown;
name?: unknown;
contentType?: unknown;
content_type?: unknown;
};
type OpenClawChatVideoFileLike = {
name: string;
type: string;
};
function normalizeContentType(value: unknown) {
return String(value || "")
.split(";", 1)[0]
.trim()
.toLowerCase();
}
function getExtension(value: string) {
const cleanValue = String(value || "").split(/[?#]/, 1)[0];
return cleanValue.split(".").pop()?.trim().toLowerCase() || "";
}
function isHttpUrl(value: string) {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
export function getOpenClawChatVideoContentType(
file: OpenClawChatVideoFileLike
) {
const contentType = normalizeContentType(file.type);
if (VIDEO_CONTENT_TYPES.has(contentType)) {
return contentType;
}
return VIDEO_CONTENT_TYPE_BY_EXTENSION[getExtension(file.name)] || "";
}
export function isOpenClawChatVideoFile(file: OpenClawChatVideoFileLike) {
return Boolean(getOpenClawChatVideoContentType(file));
}
export function isOpenClawChatVideoAttachment(
url: string,
contentType?: string | null
) {
return (
normalizeContentType(contentType).startsWith("video/") ||
VIDEO_EXTENSION_PATTERN.test(String(url || ""))
);
}
export function normalizeOpenClawChatMediaAttachments(
value: unknown
): OpenClawChatMediaAttachment[] {
if (!Array.isArray(value)) {
return [];
}
const result: OpenClawChatMediaAttachment[] = [];
const seenUrls = new Set<string>();
let videoCount = 0;
for (const item of value) {
if (!item || typeof item !== "object") {
continue;
}
const raw = item as OpenClawChatAttachmentLike;
const url = String(raw.url || "").trim();
if (!url || !isHttpUrl(url) || seenUrls.has(url)) {
continue;
}
const requestedType = String(raw.type || "").trim().toLowerCase();
const rawContentType = normalizeContentType(
raw.content_type || raw.contentType
);
const isVideo =
requestedType === "video" ||
isOpenClawChatVideoAttachment(url, rawContentType);
if (isVideo && videoCount >= OPENCLAW_CHAT_VIDEO_MAX_FILES) {
continue;
}
if (result.length >= OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS) {
break;
}
const type = isVideo ? "video" : "image";
const fallbackContentType = isVideo
? VIDEO_CONTENT_TYPE_BY_EXTENSION[getExtension(url)] || "video/mp4"
: "image/jpeg";
const contentType =
rawContentType.startsWith(`${type}/`) ? rawContentType : fallbackContentType;
const name = String(raw.name || "")
.trim()
.slice(0, 200);
result.push({
type,
url,
...(name ? { name } : {}),
content_type: contentType,
});
seenUrls.add(url);
if (isVideo) {
videoCount += 1;
}
}
return result;
}
export function buildOpenClawVideoDisplayText(
attachments: OpenClawChatMediaAttachment[]
) {
const videos = attachments.filter((attachment) => attachment.type === "video");
if (videos.length === 0) {
return "";
}
const lines = videos.map(
(attachment, index) => `- ${attachment.name || `视频${index + 1}`}`
);
return `已上传视频:\n${lines.join("\n")}`;
}
export function buildOpenClawVideoReferenceText(
attachments: OpenClawChatMediaAttachment[]
) {
const videos = attachments.filter((attachment) => attachment.type === "video");
if (videos.length === 0) {
return "";
}
const lines = videos.map(
(attachment, index) =>
`- ${attachment.name || `视频${index + 1}`}: ${attachment.url}`
);
return [
"用户上传了以下视频,请调用视频理解能力并按视频文件地址处理:",
lines.join("\n"),
].join("\n");
}

View File

@ -13,6 +13,7 @@ type FeaturePointsChargeInput = {
messageId?: string | null;
note: string;
pointsConfig?: PointsSystemConfig;
forceEnabled?: boolean;
};
type FeaturePointsChargeOk = {
@ -57,9 +58,12 @@ export async function prepareFeaturePointsCharge(
): Promise<FeaturePointsChargeOk | FeaturePointsChargeBlocked> {
const unitCost = normalizeRuntimeCost(input.unitCost);
const pointsConfig = input.pointsConfig || (await getPointsSystemConfig());
const chargeable = pointsConfig.enabled === true && unitCost > 0;
const chargeable = (pointsConfig.enabled === true || input.forceEnabled === true) && unitCost > 0;
if (chargeable && pointsConfig.blockOnInsufficient !== false) {
if (
chargeable &&
(input.forceEnabled === true || pointsConfig.blockOnInsufficient !== false)
) {
const userData = await getUserById({ id: input.userId });
if (!userData?.isHighVip) {
const currentBalance = Math.max(0, Number(userData?.pointsBalance || 0));

View File

@ -0,0 +1,48 @@
const CONTENT_TYPES: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
psd: "image/vnd.adobe.photoshop",
psb: "application/octet-stream",
pdf: "application/pdf",
json: "application/json",
txt: "text/plain",
html: "text/html",
css: "text/css",
js: "application/javascript",
ttf: "font/ttf",
otf: "font/otf",
woff: "font/woff",
woff2: "font/woff2",
glb: "model/gltf-binary",
gltf: "model/gltf+json",
mp4: "video/mp4",
mov: "video/quicktime",
webm: "video/webm",
m4v: "video/x-m4v",
mkv: "video/x-matroska",
mp3: "audio/mpeg",
wav: "audio/wav",
m4a: "audio/mp4",
aac: "audio/aac",
ogg: "audio/ogg",
flac: "audio/flac",
usdz: "model/vnd.usdz+zip",
fbx: "application/octet-stream",
obj: "text/plain",
stl: "model/stl",
"3mf": "model/3mf",
ply: "application/octet-stream",
spz: "application/octet-stream",
splat: "application/octet-stream",
ksplat: "application/octet-stream",
zip: "application/zip",
};
export function getContentType(filename: string): string {
const extension = filename.toLowerCase().split(".").pop() ?? "";
return CONTENT_TYPES[extension] ?? "application/octet-stream";
}

View File

@ -1,5 +1,7 @@
import "server-only";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { put } from "@vercel/blob";
import {
@ -20,6 +22,9 @@ import {
} from "@/lib/storage/migration-config";
import type { StorageConfig } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger";
import { getContentType } from "@/lib/storage/content-type";
export { getContentType } from "@/lib/storage/content-type";
const logger = createLogger("storage");
export type S3UploadConfig = {
@ -99,44 +104,6 @@ function normalizePublicUrl(publicUrl?: string): string | undefined {
return trimmed || undefined;
}
// 获取文件的 Content-Type
export function getContentType(filename: string): string {
const ext = filename.toLowerCase().split(".").pop();
const types: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
psd: "image/vnd.adobe.photoshop",
psb: "application/octet-stream",
pdf: "application/pdf",
json: "application/json",
txt: "text/plain",
html: "text/html",
css: "text/css",
js: "application/javascript",
ttf: "font/ttf",
otf: "font/otf",
woff: "font/woff",
woff2: "font/woff2",
glb: "model/gltf-binary",
gltf: "model/gltf+json",
usdz: "model/vnd.usdz+zip",
fbx: "application/octet-stream",
obj: "text/plain",
stl: "model/stl",
"3mf": "model/3mf",
ply: "application/octet-stream",
spz: "application/octet-stream",
splat: "application/octet-stream",
ksplat: "application/octet-stream",
zip: "application/zip",
};
return types[ext ?? ""] ?? "application/octet-stream";
}
export function createS3Client(config: S3UploadConfig): S3Client {
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
const lowerEndpoint = normalizedEndpoint.toLowerCase();
@ -179,6 +146,21 @@ export function buildS3PublicUrl(config: S3UploadConfig, key: string): string {
return `${normalizedEndpoint}/${config.bucket}/${cleanKey}`;
}
async function uploadToLocalDevelopmentStorage(
filename: string,
buffer: ArrayBuffer,
): Promise<UploadedFileResult> {
const pathname = buildStorageObjectKey(filename, "uploads/local");
const absolutePath = path.join(process.cwd(), "public", pathname);
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, Buffer.from(buffer));
return {
contentType: getContentType(filename),
pathname,
url: `/${pathname}`,
};
}
export async function resolveS3UploadTarget(): Promise<
| {
config: S3UploadConfig;
@ -499,6 +481,14 @@ export async function uploadFile(
): Promise<UploadedFileResult> {
logger.debug(`[Upload] Starting upload for file: ${filename}`);
if (
process.env.NODE_ENV !== "production" &&
process.env.USE_REMOTE_STORAGE_IN_DEV !== "true"
) {
logger.debug("[Upload] Using local public storage for development");
return uploadToLocalDevelopmentStorage(filename, buffer);
}
let dbS3Config: S3UploadConfig | null = null;
let primaryStorageConfigId: string | undefined;
let selectedStorageConfig: StorageConfig | undefined;

View File

@ -0,0 +1,117 @@
import type { AudioToolRequest } from "@/lib/video-canvas/audio-tools";
import {
normalizeAudioPlaybackRate,
normalizeAudioTrimRange,
} from "@/lib/video-canvas/audio-tools";
function getAudioContextConstructor() {
return window.AudioContext ||
(window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
}
export async function decodeAudioArrayBuffer(arrayBuffer: ArrayBuffer) {
const AudioContextCtor = getAudioContextConstructor();
if (!AudioContextCtor) throw new Error("当前浏览器不支持音频处理");
const context = new AudioContextCtor();
try {
return await context.decodeAudioData(arrayBuffer.slice(0));
} finally {
await context.close().catch(() => undefined);
}
}
async function decodeAudioSource(mediaUrl: string) {
const parsedUrl = new URL(mediaUrl, window.location.origin);
const sourceUrl =
parsedUrl.origin === window.location.origin
? parsedUrl.toString()
: `/api/canvas/audio-source?url=${encodeURIComponent(parsedUrl.toString())}`;
const response = await fetch(sourceUrl, { credentials: "include" });
if (!response.ok) throw new Error("音频素材读取失败");
return decodeAudioArrayBuffer(await response.arrayBuffer());
}
function trimAudioBuffer(source: AudioBuffer, startSeconds: number, endSeconds: number) {
const range = normalizeAudioTrimRange(source.duration, startSeconds, endSeconds);
const startFrame = Math.floor(range.startSeconds * source.sampleRate);
const endFrame = Math.min(source.length, Math.ceil(range.endSeconds * source.sampleRate));
const frameCount = Math.max(1, endFrame - startFrame);
const output = new AudioBuffer({
length: frameCount,
numberOfChannels: source.numberOfChannels,
sampleRate: source.sampleRate,
});
for (let channel = 0; channel < source.numberOfChannels; channel += 1) {
output.copyToChannel(source.getChannelData(channel).subarray(startFrame, endFrame), channel);
}
return output;
}
async function changeAudioPlaybackRate(source: AudioBuffer, playbackRate: number) {
const rate = normalizeAudioPlaybackRate(playbackRate);
const frameCount = Math.max(1, Math.ceil(source.length / rate));
const offline = new OfflineAudioContext(
source.numberOfChannels,
frameCount,
source.sampleRate,
);
const sourceNode = offline.createBufferSource();
sourceNode.buffer = source;
sourceNode.playbackRate.value = rate;
sourceNode.connect(offline.destination);
sourceNode.start(0);
return offline.startRendering();
}
function writeAscii(view: DataView, offset: number, value: string) {
for (let index = 0; index < value.length; index += 1) {
view.setUint8(offset + index, value.charCodeAt(index));
}
}
export function encodeAudioBufferAsWav(buffer: AudioBuffer) {
const channelCount = Math.max(1, Math.min(2, buffer.numberOfChannels));
const bytesPerSample = 2;
const dataSize = buffer.length * channelCount * bytesPerSample;
const arrayBuffer = new ArrayBuffer(44 + dataSize);
const view = new DataView(arrayBuffer);
writeAscii(view, 0, "RIFF");
view.setUint32(4, 36 + dataSize, true);
writeAscii(view, 8, "WAVE");
writeAscii(view, 12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channelCount, true);
view.setUint32(24, buffer.sampleRate, true);
view.setUint32(28, buffer.sampleRate * channelCount * bytesPerSample, true);
view.setUint16(32, channelCount * bytesPerSample, true);
view.setUint16(34, 16, true);
writeAscii(view, 36, "data");
view.setUint32(40, dataSize, true);
const channels = Array.from({ length: channelCount }, (_, index) =>
buffer.getChannelData(Math.min(index, buffer.numberOfChannels - 1)),
);
let offset = 44;
for (let frame = 0; frame < buffer.length; frame += 1) {
for (let channel = 0; channel < channelCount; channel += 1) {
const sample = Math.max(-1, Math.min(1, channels[channel]![frame] || 0));
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
offset += bytesPerSample;
}
}
return new Blob([arrayBuffer], { type: "audio/wav" });
}
export async function processCanvasAudio(mediaUrl: string, request: AudioToolRequest) {
const source = await decodeAudioSource(mediaUrl);
const output =
request.kind === "trim"
? trimAudioBuffer(source, request.startSeconds, request.endSeconds)
: await changeAudioPlaybackRate(source, request.playbackRate);
return {
blob: encodeAudioBufferAsWav(output),
durationSeconds: output.duration,
sampleRate: output.sampleRate,
};
}

View File

@ -0,0 +1,171 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
const MAX_MEDIA_SOURCE_BYTES = 100 * 1024 * 1024;
const PRIVATE_IPV4_RANGES = [
/^10\./,
/^0\./,
/^127\./,
/^169\.254\./,
/^172\.(1[6-9]|2\d|3[01])\./,
/^192\.168\./,
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
/^(22[4-9]|23\d|24\d|25[0-5])\./,
] as const;
function isPrivateAddress(address: string) {
const normalized = address.toLowerCase();
if (isIP(normalized) === 4) {
return PRIVATE_IPV4_RANGES.some((pattern) => pattern.test(normalized));
}
if (isIP(normalized) === 6) {
const mappedIpv4 = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
return (
normalized === "::" ||
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
normalized.startsWith("fe80:") ||
Boolean(mappedIpv4 && PRIVATE_IPV4_RANGES.some((pattern) => pattern.test(mappedIpv4)))
);
}
return false;
}
async function resolvesToPrivateAddress(hostname: string) {
const normalized = hostname.toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
if (
normalized === "localhost" ||
normalized.endsWith(".localhost") ||
normalized.endsWith(".local") ||
normalized.endsWith(".internal")
) {
return true;
}
if (isIP(normalized)) return isPrivateAddress(normalized);
const addresses = await lookup(normalized, { all: true, verbatim: true });
return addresses.length === 0 || addresses.some(({ address }) => isPrivateAddress(address));
}
type MediaSourceProxyOptions = {
accept: string;
contentTypePrefix: "audio/" | "video/";
extensionContentTypes: Readonly<Record<string, string>>;
};
function inferMediaContentType(sourceUrl: URL, options: MediaSourceProxyOptions) {
const extension = sourceUrl.pathname.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
return extension ? options.extensionContentTypes[extension] : undefined;
}
async function proxyCanvasMediaSource(
request: Request,
options: MediaSourceProxyOptions,
fetcher: typeof fetch = fetch,
) {
const source = new URL(request.url).searchParams.get("url");
if (!source) {
return Response.json({ error: "Audio URL is required" }, { status: 400 });
}
let sourceUrl: URL;
try {
sourceUrl = new URL(source);
} catch {
return Response.json({ error: "Invalid audio URL" }, { status: 400 });
}
if (sourceUrl.protocol !== "https:") {
return Response.json({ error: "Unsupported audio URL" }, { status: 400 });
}
try {
if (await resolvesToPrivateAddress(sourceUrl.hostname)) {
return Response.json({ error: "Unsupported audio URL" }, { status: 400 });
}
const upstream = await fetcher(sourceUrl, {
headers: { Accept: options.accept },
redirect: "error",
signal: AbortSignal.timeout(15_000),
});
if (!upstream.ok || !upstream.body) {
return Response.json({ error: "Audio source unavailable" }, { status: 502 });
}
const upstreamContentType = upstream.headers.get("content-type")?.toLowerCase() || "";
const inferredContentType = inferMediaContentType(sourceUrl, options);
const isGenericOrMislabeled =
upstreamContentType === "application/octet-stream" || upstreamContentType === "text/plain";
if (
!upstreamContentType.startsWith(options.contentTypePrefix) &&
!(isGenericOrMislabeled && inferredContentType)
) {
return Response.json({ error: "Unsupported media response" }, { status: 415 });
}
const contentType = upstreamContentType.startsWith(options.contentTypePrefix)
? upstreamContentType
: inferredContentType || "application/octet-stream";
const contentLength = Number(upstream.headers.get("content-length") || 0);
if (contentLength > MAX_MEDIA_SOURCE_BYTES) {
return Response.json({ error: "Media source is too large" }, { status: 413 });
}
let transferredBytes = 0;
const limitedBody = upstream.body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
transferredBytes += chunk.byteLength;
if (transferredBytes > MAX_MEDIA_SOURCE_BYTES) {
throw new Error("Media source is too large");
}
controller.enqueue(chunk);
},
}),
);
return new Response(limitedBody, {
headers: {
"Cache-Control": "private, max-age=300",
"Content-Type": contentType || "application/octet-stream",
},
});
} catch {
return Response.json({ error: "Audio source unavailable" }, { status: 502 });
}
}
export function proxyCanvasAudioSource(request: Request, fetcher: typeof fetch = fetch) {
return proxyCanvasMediaSource(
request,
{
accept: "audio/*,application/octet-stream;q=0.8",
contentTypePrefix: "audio/",
extensionContentTypes: {
aac: "audio/aac",
flac: "audio/flac",
m4a: "audio/mp4",
mp3: "audio/mpeg",
ogg: "audio/ogg",
wav: "audio/wav",
},
},
fetcher,
);
}
export function proxyCanvasVideoSource(request: Request, fetcher: typeof fetch = fetch) {
return proxyCanvasMediaSource(
request,
{
accept: "video/*,application/octet-stream;q=0.8",
contentTypePrefix: "video/",
extensionContentTypes: {
m4v: "video/mp4",
mov: "video/quicktime",
mp4: "video/mp4",
ogv: "video/ogg",
webm: "video/webm",
},
},
fetcher,
);
}

View File

@ -0,0 +1,61 @@
export const MIN_AUDIO_PLAYBACK_RATE = 0.1;
export const MAX_AUDIO_PLAYBACK_RATE = 4;
export const AUDIO_PLAYBACK_RATE_STEP = 0.1;
export type AudioToolRequest =
| { kind: "trim"; startSeconds: number; endSeconds: number }
| { kind: "speed"; playbackRate: number };
export function normalizeAudioPlaybackRate(value: unknown, fallback = 1) {
const parsed = Number.parseFloat(String(value ?? "").replace(/x$/i, ""));
const safe = Number.isFinite(parsed) ? parsed : fallback;
return Math.round(
Math.min(MAX_AUDIO_PLAYBACK_RATE, Math.max(MIN_AUDIO_PLAYBACK_RATE, safe)) * 100,
) / 100;
}
export function stepAudioPlaybackRate(value: unknown, direction: 1 | -1) {
return normalizeAudioPlaybackRate(
normalizeAudioPlaybackRate(value) + direction * AUDIO_PLAYBACK_RATE_STEP,
);
}
export function formatAudioPlaybackRate(value: unknown) {
return `${normalizeAudioPlaybackRate(value).toFixed(2)}x`;
}
export function formatAudioPlaybackRateSuffix(value: unknown) {
const rate = normalizeAudioPlaybackRate(value);
return Number.isInteger(rate) ? `${rate}x` : `${rate.toFixed(2).replace(/0+$/, "")}x`;
}
export function getDefaultAudioTrimRange(durationSeconds: unknown) {
const duration = Math.max(0.1, Number(durationSeconds) || 0.1);
return {
startSeconds: 0,
endSeconds: Math.min(duration, Math.max(0.1, duration / 3)),
};
}
export function normalizeAudioTrimRange(
durationSeconds: unknown,
startSeconds: unknown,
endSeconds: unknown,
) {
const duration = Math.max(0.1, Number(durationSeconds) || 0.1);
const start = Math.min(duration - 0.05, Math.max(0, Number(startSeconds) || 0));
const end = Math.min(duration, Math.max(start + 0.05, Number(endSeconds) || duration));
return { startSeconds: start, endSeconds: end };
}
export function getProcessedAudioTitle(sourceTitle: string, request: AudioToolRequest) {
const base = String(sourceTitle || "音频").trim() || "音频";
return request.kind === "trim"
? `${base}_截取`
: `${base}_${formatAudioPlaybackRateSuffix(request.playbackRate)}`;
}
export function getAudioDownloadExtension(source: unknown) {
const pathname = String(source || "").split(/[?#]/, 1)[0];
return pathname.match(/\.(wav|mp3|m4a|aac|ogg|webm)$/i)?.[1]?.toLowerCase() || "mp3";
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
export type VideoCanvasDirectorCameraPreset = {
id: string;
label: string;
source: "current" | "fixed";
fov: number;
position: [number, number, number];
target: [number, number, number];
};
export const defaultVideoCanvasDirectorCameraPresets: VideoCanvasDirectorCameraPreset[] = [
{ id: "current", label: "当前视角", source: "current", fov: 50, position: [0, 2.2, 10], target: [0, 1.2, 0] },
{ id: "front-medium", label: "正面中景", source: "fixed", fov: 50, position: [0, 1.5, 4], target: [0, 1.2, 0] },
{ id: "front-close", label: "正面特写", source: "fixed", fov: 35, position: [0, 1.7, 2], target: [0, 1.6, 0] },
{ id: "front-wide", label: "正面全景", source: "fixed", fov: 60, position: [0, 2.5, 8], target: [0, 1, 0] },
{ id: "side-follow", label: "侧面跟拍", source: "fixed", fov: 50, position: [4, 1.5, 0], target: [0, 1.2, 0] },
{ id: "side-close", label: "侧面近景", source: "fixed", fov: 40, position: [2.5, 1.5, 0], target: [0, 1.5, 0] },
{ id: "back-medium", label: "背面中景", source: "fixed", fov: 50, position: [0, 1.5, -4], target: [0, 1.2, 0] },
{ id: "overhead-wide", label: "俯拍全景", source: "fixed", fov: 55, position: [0, 8, 3], target: [0, 0, 0] },
{ id: "overhead-45", label: "45° 俯拍", source: "fixed", fov: 50, position: [0, 5, 5], target: [0, 1, 0] },
{ id: "low-angle", label: "低角度仰拍", source: "fixed", fov: 45, position: [0, 0.3, 3], target: [0, 1.8, 0] },
{ id: "low-wide", label: "低角度广角", source: "fixed", fov: 70, position: [0, 0.5, 5], target: [0, 1, 0] },
{ id: "over-shoulder-left", label: "过肩镜头", source: "fixed", fov: 50, position: [-0.5, 1.7, 2.5], target: [0.5, 1.6, 0] },
{ id: "over-shoulder-right", label: "过肩镜头(右)", source: "fixed", fov: 50, position: [0.5, 1.7, 2.5], target: [-0.5, 1.6, 0] },
{ id: "birds-eye", label: "鸟瞰", source: "fixed", fov: 60, position: [0, 12, 0.5], target: [0, 0, 0] },
{ id: "dutch", label: "荷兰角", source: "fixed", fov: 50, position: [1, 1.5, 3.5], target: [0, 1.2, 0] },
];

View File

@ -0,0 +1,414 @@
export type VideoCanvasDirectorPosePreset = {
id: string;
label: string;
controls: Record<string, number>;
};
export const defaultVideoCanvasDirectorPosePresets: VideoCanvasDirectorPosePreset[] = [
{
id: "stand",
label: "站立",
controls: {
"torso.pitch": 2,
"head.pitch": -10,
"leftShoulder.pitch": -5,
"leftShoulder.spread": 12,
"rightShoulder.pitch": -5,
"rightShoulder.spread": 14,
"leftElbow.bend": 15,
"rightElbow.bend": 15,
},
},
{
id: "t-pose",
label: "T型",
controls: {
"leftShoulder.pitch": 16,
"leftShoulder.spread": 60,
"leftShoulder.twist": 40,
"rightShoulder.pitch": 22,
"rightShoulder.spread": 54,
"rightShoulder.twist": 41,
},
},
{
id: "walk",
label: "行走",
controls: {
"torso.pitch": 3,
"torso.yaw": 5,
"head.pitch": -5,
"leftShoulder.pitch": 40,
"leftShoulder.spread": 9,
"rightShoulder.pitch": -8,
"rightShoulder.spread": 27,
"rightShoulder.twist": -4,
"leftElbow.bend": 37,
"rightElbow.bend": 26,
"leftHip.pitch": -10,
"rightHip.pitch": 36,
"rightHip.spread": -2,
"leftKnee.bend": 22,
"rightKnee.bend": 41,
},
},
{
id: "run",
label: "跑步",
controls: {
"body.pitch": -2,
"torso.pitch": 3,
"torso.yaw": 5,
"head.pitch": -10,
"leftShoulder.pitch": -26,
"leftShoulder.spread": 23,
"leftShoulder.twist": 14,
"rightShoulder.pitch": 23,
"rightShoulder.spread": 14,
"rightShoulder.twist": 19,
"leftElbow.bend": 60,
"rightElbow.bend": 89,
"leftHip.pitch": 50,
"rightHip.pitch": -20,
"leftKnee.bend": 18,
"rightKnee.bend": 36,
},
},
{
id: "sit",
label: "坐姿",
controls: {
"body.pitch": -5,
"torso.pitch": 5,
"head.pitch": -5,
"leftShoulder.spread": 8,
"leftShoulder.twist": 6,
"rightShoulder.spread": 15,
"rightShoulder.twist": 16,
"leftElbow.bend": 80,
"rightElbow.bend": 80,
"leftHip.pitch": 90,
"leftHip.spread": 5,
"rightHip.pitch": 90,
"rightHip.spread": 5,
"leftKnee.bend": 90,
"rightKnee.bend": 90,
},
},
{
id: "crouch",
label: "蹲下",
controls: {
"body.pitch": -3,
"torso.pitch": 45,
"head.pitch": -15,
"leftShoulder.pitch": 39,
"leftShoulder.spread": 8,
"leftShoulder.twist": 26,
"rightShoulder.pitch": 20,
"rightShoulder.spread": 15,
"rightShoulder.twist": 30,
"leftElbow.bend": 87,
"rightElbow.bend": 100,
"leftHip.pitch": 100,
"leftHip.spread": 15,
"leftHip.twist": 16,
"rightHip.pitch": 90,
"rightHip.spread": 15,
"rightHip.twist": 16,
"leftKnee.bend": 104,
"rightKnee.bend": 97,
},
},
{
id: "kneel-one",
label: "单膝跪",
controls: {
"body.cameraTargetOffsetY": 0.56,
"body.pitch": -11,
"torso.pitch": 21,
"head.pitch": -5,
"leftShoulder.pitch": 41,
"leftShoulder.spread": -4,
"leftShoulder.twist": -24,
"rightShoulder.spread": 16,
"leftElbow.bend": 71,
"rightElbow.bend": 16,
"leftHip.pitch": 90,
"leftHip.rigPitch": 70,
"leftHip.twist": 4,
"rightHip.pitch": 27,
"rightHip.rigPitch": 15,
"leftKnee.bend": 74,
"leftKnee.rigBend": 40,
"rightKnee.bend": 89,
"rightKnee.rigBend": -90,
"leftFoot.pitch": 20,
"rightFoot.pitch": 60,
},
},
{
id: "kneel-two",
label: "双膝跪",
controls: {
"body.modelScale": 0.83,
"body.pitch": 8,
"torso.pitch": 6,
"head.pitch": -5,
"leftShoulder.pitch": -5,
"leftShoulder.spread": 7,
"rightShoulder.pitch": -5,
"rightShoulder.spread": 10,
"leftElbow.bend": 15,
"rightElbow.bend": 15,
"leftHip.pitch": -10,
"rightHip.pitch": -9,
"leftKnee.bend": 108,
"leftKnee.rigBend": 126,
"rightKnee.bend": 104,
"rightKnee.rigBend": 126,
"leftFoot.pitch": -20,
"rightFoot.pitch": -20,
},
},
{
id: "hands-on-hips",
label: "叉腰",
controls: {
"head.pitch": -5,
"leftShoulder.pitch": 15,
"leftShoulder.spread": 31,
"leftShoulder.twist": -9,
"rightShoulder.pitch": 21,
"rightShoulder.spread": 31,
"rightShoulder.twist": 3,
"leftElbow.bend": 80,
"rightElbow.bend": 80,
"leftHip.spread": 10,
"rightHip.spread": 10,
},
},
{
id: "lean",
label: "倚靠",
controls: {
"body.roll": -5,
"torso.pitch": -3,
"torso.yaw": 5,
"torso.roll": -5,
"head.pitch": -10,
"head.yaw": 10,
"head.roll": -5,
"leftShoulder.pitch": -5,
"leftShoulder.spread": 13,
"rightShoulder.pitch": -10,
"rightShoulder.spread": 16,
"leftElbow.bend": 15,
"rightElbow.bend": 20,
"leftHip.spread": 5,
"rightHip.pitch": 20,
"rightHip.twist": 10,
"rightKnee.bend": 40,
},
},
{
id: "bow",
label: "鞠躬",
controls: {
"torso.pitch": 45,
"head.pitch": 20,
"leftShoulder.pitch": 13,
"leftShoulder.spread": -7,
"rightShoulder.pitch": 3,
"rightShoulder.spread": -5,
"rightShoulder.twist": 10,
"leftElbow.bend": 5,
"rightElbow.bend": 5,
},
},
{
id: "think",
label: "思考",
controls: {
"torso.pitch": 5,
"torso.yaw": -5,
"head.pitch": 10,
"head.yaw": -10,
"head.roll": 5,
"leftShoulder.pitch": 5,
"leftShoulder.spread": 7,
"rightShoulder.pitch": 87,
"rightShoulder.spread": -10,
"rightShoulder.twist": 90,
"leftElbow.bend": 67,
"rightElbow.bend": 122,
},
},
{
id: "fight",
label: "格斗",
controls: {
"body.pitch": 5,
"body.yaw": 15,
"torso.pitch": 8,
"torso.yaw": 10,
"head.pitch": -5,
"head.yaw": -15,
"leftShoulder.pitch": 60,
"leftShoulder.spread": 30,
"rightShoulder.pitch": 40,
"rightShoulder.spread": 25,
"leftElbow.bend": 110,
"rightElbow.bend": 100,
"leftHip.pitch": 10,
"leftHip.spread": 15,
"leftHip.twist": 10,
"rightHip.pitch": -5,
"rightHip.spread": 15,
"rightHip.twist": -10,
"leftKnee.bend": 25,
"rightKnee.bend": 30,
},
},
{
id: "kick",
label: "踢球",
controls: {
"body.pitch": -4,
"body.yaw": -7,
"body.roll": -1,
"torso.pitch": -5,
"torso.yaw": -10,
"torso.roll": 5,
"head.pitch": 5,
"head.yaw": -10,
"leftShoulder.pitch": -20,
"leftShoulder.spread": 30,
"rightShoulder.pitch": 20,
"rightShoulder.spread": 25,
"leftElbow.bend": 40,
"rightElbow.bend": 35,
"leftHip.pitch": -10,
"rightHip.pitch": 80,
"leftKnee.bend": 10,
"rightKnee.bend": 20,
},
},
{
id: "throw",
label: "投掷",
controls: {
"body.pitch": 5,
"body.yaw": -2,
"torso.pitch": 4,
"torso.yaw": 15,
"torso.roll": -5,
"head.pitch": -5,
"head.yaw": -15,
"leftShoulder.pitch": 7,
"leftShoulder.spread": 17,
"rightShoulder.pitch": 77,
"rightShoulder.twist": 90,
"leftElbow.bend": 88,
"rightElbow.bend": 96,
"leftHip.pitch": 15,
"leftHip.spread": 5,
"rightHip.pitch": -10,
"rightHip.spread": 5,
"leftKnee.bend": 26,
"rightKnee.bend": 36,
},
},
{
id: "push",
label: "推进",
controls: {
"body.pitch": -1,
"torso.pitch": 19,
"head.pitch": -5,
"leftShoulder.pitch": 100,
"leftShoulder.spread": 6,
"leftShoulder.twist": 5,
"rightShoulder.pitch": 101,
"rightShoulder.spread": 11,
"leftElbow.bend": 14,
"leftHip.pitch": 41,
"rightHip.pitch": -15,
"leftKnee.bend": 35,
"rightKnee.bend": 10,
},
},
{
id: "wave",
label: "招手",
controls: {
"torso.pitch": 2,
"torso.yaw": -10,
"head.pitch": -5,
"head.yaw": 15,
"head.roll": 5,
"leftShoulder.spread": 6,
"rightShoulder.pitch": 62,
"rightShoulder.spread": -10,
"rightShoulder.twist": 90,
"leftElbow.bend": 62,
"rightElbow.bend": 94,
},
},
{
id: "reach",
label: "伸手",
controls: {
"body.pitch": -5,
"torso.pitch": -16,
"torso.roll": 3,
"head.pitch": -25,
"leftShoulder.pitch": 115,
"leftShoulder.spread": -10,
"leftShoulder.twist": 10,
"rightShoulder.pitch": 133,
"rightShoulder.spread": -10,
"rightShoulder.twist": -10,
"leftElbow.bend": 59,
"rightElbow.bend": 53,
"leftHip.spread": 10,
"rightHip.spread": 10,
},
},
{
id: "cross-arms",
label: "抱臂",
controls: {
"torso.yaw": 2,
"head.pitch": -8,
"leftShoulder.pitch": 7,
"leftShoulder.spread": 22,
"leftShoulder.twist": 17,
"rightShoulder.pitch": 8,
"rightShoulder.spread": 22,
"rightShoulder.twist": 39,
"leftElbow.bend": 107,
"rightElbow.bend": 107,
"leftHip.spread": 5,
"rightHip.spread": 5,
},
},
{
id: "phone",
label: "看手机",
controls: {
"torso.pitch": 5,
"head.pitch": 17,
"leftShoulder.pitch": 55,
"leftShoulder.spread": -10,
"leftShoulder.twist": -4,
"rightShoulder.pitch": 24,
"rightShoulder.spread": -10,
"rightShoulder.twist": -13,
"leftElbow.bend": 89,
"rightElbow.bend": 43,
"leftHip.spread": 5,
"rightHip.spread": 5,
},
},
];

View File

@ -0,0 +1,174 @@
import {
isVideoCanvasDirectorAspectRatio,
type VideoCanvasDirectorAspectRatio,
} from "@/lib/video-canvas/config";
export type VideoCanvasDirectorCapture = {
assetId?: string;
cameraId: string;
cameraName: string;
dataUrl: string;
id: string;
index: number;
name: string;
storagePathname?: string;
};
export type VideoCanvasDirectorProject = Record<string, unknown> & {
cameras: Array<Record<string, unknown>>;
version: 1;
viewportAspectRatio?: VideoCanvasDirectorAspectRatio;
};
const MAX_PROJECT_JSON_BYTES = 3_000_000;
const MAX_CAMERAS = 64;
const MAX_CAPTURES_PER_CAMERA = 200;
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizeText(value: unknown, maxLength = 160) {
return String(value || "").trim().slice(0, maxLength);
}
function normalizeStoragePathname(value: unknown) {
const pathname = normalizeText(value, 1024).replace(/^\/+/, "");
if (!pathname || pathname.includes("..") || pathname.includes("\\")) return "";
return pathname;
}
export function isPortableDirectorCaptureUrl(value: unknown) {
const url = normalizeText(value, 4096);
if (!url) return false;
if (url.startsWith("/") && !url.startsWith("//")) return true;
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
function normalizeCapture(
value: unknown,
cameraId: string,
cameraName: string,
): VideoCanvasDirectorCapture | null {
if (!isRecord(value)) return null;
const id = normalizeText(value.id, 160);
const dataUrl = normalizeText(value.dataUrl, 4096);
if (!id || !isPortableDirectorCaptureUrl(dataUrl)) return null;
const parsedIndex = Number(value.index);
const index = Number.isFinite(parsedIndex)
? Math.max(1, Math.min(9999, Math.round(parsedIndex)))
: 1;
return {
assetId: normalizeText(value.assetId, 160) || undefined,
cameraId,
cameraName,
dataUrl,
id,
index,
name: normalizeText(value.name, 160) || `${cameraName}-shot-${String(index).padStart(2, "0")}`,
storagePathname: normalizeStoragePathname(value.storagePathname) || undefined,
};
}
export function normalizeVideoCanvasDirectorProject(
value: unknown,
): VideoCanvasDirectorProject | null {
if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.cameras)) {
return null;
}
let cloned: Record<string, unknown>;
try {
const serialized = JSON.stringify(value, (key, item) => {
if (key === "dataUrl" && typeof item === "string" && item.startsWith("data:")) {
return "";
}
return item;
});
if (!serialized || serialized.length > MAX_PROJECT_JSON_BYTES) return null;
cloned = JSON.parse(serialized) as Record<string, unknown>;
} catch {
return null;
}
const cameras = (Array.isArray(cloned.cameras) ? cloned.cameras : [])
.slice(0, MAX_CAMERAS)
.flatMap((candidate) => {
if (!isRecord(candidate)) return [];
const id = normalizeText(candidate.id, 160);
const name = normalizeText(candidate.name, 160);
if (!id || !name) return [];
const captures = (Array.isArray(candidate.captures) ? candidate.captures : [])
.slice(0, MAX_CAPTURES_PER_CAMERA)
.flatMap((capture) => {
const normalized = normalizeCapture(capture, id, name);
return normalized ? [normalized] : [];
});
return [{
...candidate,
captures,
lastCaptureUrl: captures[captures.length - 1]?.dataUrl || null,
}];
});
if (!isVideoCanvasDirectorAspectRatio(cloned.viewportAspectRatio)) {
delete cloned.viewportAspectRatio;
}
if (!Array.isArray(cloned.assets) || !Array.isArray(cloned.objects)) return null;
return {
...cloned,
cameras,
version: 1,
} as VideoCanvasDirectorProject;
}
export function collectVideoCanvasDirectorCaptures(
project: VideoCanvasDirectorProject | null | undefined,
) {
if (!project) return [];
return project.cameras.flatMap((camera) => {
const cameraId = normalizeText(camera.id, 160);
const cameraName = normalizeText(camera.name, 160);
return (Array.isArray(camera.captures) ? camera.captures : []).flatMap((capture) => {
const normalized = normalizeCapture(capture, cameraId, cameraName);
return normalized ? [normalized] : [];
});
});
}
export function mergeVideoCanvasDirectorCapture(
project: VideoCanvasDirectorProject,
capture: VideoCanvasDirectorCapture,
) {
return {
...project,
cameras: project.cameras.map((camera) => {
if (normalizeText(camera.id, 160) !== capture.cameraId) return camera;
const existing = Array.isArray(camera.captures) ? camera.captures : [];
const captures = existing.some(
(item) => isRecord(item) && normalizeText(item.id, 160) === capture.id,
)
? existing.map((item) =>
isRecord(item) && normalizeText(item.id, 160) === capture.id
? { ...item, ...capture }
: item,
)
: [...existing, capture];
return {
...camera,
captures,
lastCaptureUrl: capture.dataUrl,
};
}),
} as VideoCanvasDirectorProject;
}

View File

@ -0,0 +1,427 @@
export const VIDEO_CANVAS_HISTORY_HIDDEN_AT_KEY =
"videoCanvasHistoryHiddenAt";
export const VIDEO_CANVAS_HISTORY_MEDIA_TYPES = [
"image",
"video",
"audio",
] as const;
export type VideoCanvasHistoryMediaType =
(typeof VIDEO_CANVAS_HISTORY_MEDIA_TYPES)[number];
export const VIDEO_CANVAS_HISTORY_PICKER_MEDIA_MODES = [
"image",
"video",
"audio",
"av",
] as const;
export type VideoCanvasHistoryPickerMediaMode =
(typeof VIDEO_CANVAS_HISTORY_PICKER_MEDIA_MODES)[number];
export type VideoCanvasHistoryPickerSource = {
enabled: boolean;
id: string;
label: string;
mediaModes: VideoCanvasHistoryPickerMediaMode[];
usesLocalAssets: boolean;
};
export type VideoCanvasHistoryPickerConfig = {
maxSelection: number;
mediaShortSide: number;
pageSize: number;
placementGap: number;
sources: VideoCanvasHistoryPickerSource[];
};
export const VIDEO_CANVAS_HISTORY_PICKER_MEDIA_LABELS: Record<
VideoCanvasHistoryPickerMediaMode,
string
> = {
audio: "音频",
av: "音视频",
image: "图片",
video: "视频",
};
export const defaultVideoCanvasHistoryPickerConfig: VideoCanvasHistoryPickerConfig = {
maxSelection: 10,
mediaShortSide: 350,
pageSize: 15,
placementGap: 450,
sources: [
{
enabled: true,
id: "libtv",
label: "LibTV",
mediaModes: ["image", "video", "audio"],
usesLocalAssets: true,
},
{
enabled: true,
id: "lib-generator",
label: "Lib生成器",
mediaModes: ["image", "video"],
usesLocalAssets: false,
},
{
enabled: true,
id: "webui",
label: "WebUI",
mediaModes: ["image", "video"],
usesLocalAssets: false,
},
{
enabled: true,
id: "comfyui",
label: "ComfyUI",
mediaModes: ["image", "av"],
usesLocalAssets: false,
},
{
enabled: true,
id: "ai-app",
label: "AI应用",
mediaModes: ["image", "av"],
usesLocalAssets: false,
},
],
};
export const VIDEO_CANVAS_HISTORY_ZOOM_LEVELS = [
{ cardSize: 130, percent: 75 },
{ cardSize: 144, percent: 100 },
{ cardSize: 182, percent: 125 },
{ cardSize: 248, percent: 150 },
] as const;
export type VideoCanvasHistoryZoomPercent =
(typeof VIDEO_CANVAS_HISTORY_ZOOM_LEVELS)[number]["percent"];
export function isVideoCanvasHistoryMediaType(
value: unknown,
): value is VideoCanvasHistoryMediaType {
return VIDEO_CANVAS_HISTORY_MEDIA_TYPES.includes(
value as VideoCanvasHistoryMediaType,
);
}
export function isVideoCanvasHistoryPickerMediaMode(
value: unknown,
): value is VideoCanvasHistoryPickerMediaMode {
return VIDEO_CANVAS_HISTORY_PICKER_MEDIA_MODES.includes(
value as VideoCanvasHistoryPickerMediaMode,
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizeInteger(
value: unknown,
fallback: number,
minimum: number,
maximum: number,
) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(minimum, Math.min(maximum, Math.round(parsed)));
}
function normalizeSourceId(value: unknown, fallback: string) {
return (
String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48) || fallback
);
}
export function normalizeVideoCanvasHistoryPickerConfig(
value: unknown,
): VideoCanvasHistoryPickerConfig {
const fallback = defaultVideoCanvasHistoryPickerConfig;
const incoming = isRecord(value) ? value : {};
const incomingSources = Array.isArray(incoming.sources)
? incoming.sources
: [];
const seenIds = new Set<string>();
const sources = incomingSources
.map((item, index) => {
if (!isRecord(item)) return null;
const fallbackSource = fallback.sources[index] || {
enabled: true,
id: `source-${index + 1}`,
label: `来源 ${index + 1}`,
mediaModes: ["image"] as VideoCanvasHistoryPickerMediaMode[],
usesLocalAssets: false,
};
const id = normalizeSourceId(item.id, fallbackSource.id);
if (seenIds.has(id)) return null;
seenIds.add(id);
const mediaModes = Array.from(
new Set(
(Array.isArray(item.mediaModes) ? item.mediaModes : [])
.filter(isVideoCanvasHistoryPickerMediaMode)
.slice(0, VIDEO_CANVAS_HISTORY_PICKER_MEDIA_MODES.length),
),
);
return {
enabled:
typeof item.enabled === "boolean"
? item.enabled
: fallbackSource.enabled,
id,
label:
String(item.label || "").trim().slice(0, 40) ||
fallbackSource.label,
mediaModes:
mediaModes.length > 0 ? mediaModes : fallbackSource.mediaModes,
usesLocalAssets:
typeof item.usesLocalAssets === "boolean"
? item.usesLocalAssets
: fallbackSource.usesLocalAssets,
} satisfies VideoCanvasHistoryPickerSource;
})
.filter((item): item is VideoCanvasHistoryPickerSource => Boolean(item));
return {
maxSelection: normalizeInteger(
incoming.maxSelection,
fallback.maxSelection,
1,
50,
),
mediaShortSide: normalizeInteger(
incoming.mediaShortSide,
fallback.mediaShortSide,
120,
1000,
),
pageSize: normalizeInteger(incoming.pageSize, fallback.pageSize, 1, 50),
placementGap: normalizeInteger(
incoming.placementGap,
fallback.placementGap,
40,
2000,
),
sources: sources.length > 0 ? sources : fallback.sources,
};
}
export function getVideoCanvasHistoryPickerMediaTypes(
mode: VideoCanvasHistoryPickerMediaMode,
): VideoCanvasHistoryMediaType[] {
return mode === "av" ? ["video", "audio"] : [mode];
}
export function getVideoCanvasHistoryPickerTitle(
mode: VideoCanvasHistoryPickerMediaMode,
) {
return `选择${VIDEO_CANVAS_HISTORY_PICKER_MEDIA_LABELS[mode]}`;
}
export function paginateVideoCanvasHistoryItems<T>(
items: T[],
page: number,
pageSize: number,
) {
const normalizedPageSize = Math.max(1, Math.round(pageSize));
const pageCount = Math.max(1, Math.ceil(items.length / normalizedPageSize));
const normalizedPage = Math.max(1, Math.min(pageCount, Math.round(page)));
const start = (normalizedPage - 1) * normalizedPageSize;
return {
items: items.slice(start, start + normalizedPageSize),
page: normalizedPage,
pageCount,
};
}
export function getVideoCanvasHistoryMediaNodeDimensions({
height,
mediaShortSide,
mediaType,
width,
}: {
height: number | null | undefined;
mediaShortSide: number;
mediaType: VideoCanvasHistoryMediaType;
width: number | null | undefined;
}) {
if (mediaType === "video") {
return { height: mediaShortSide, width: 622 };
}
if (mediaType === "audio") {
return { height: 148, width: mediaShortSide };
}
const aspectRatio =
Number(width) > 0 && Number(height) > 0
? Number(width) / Number(height)
: 1;
return aspectRatio >= 1
? {
height: mediaShortSide,
width: Math.max(mediaShortSide, Math.round(mediaShortSide * aspectRatio)),
}
: {
height: Math.max(mediaShortSide, Math.round(mediaShortSide / aspectRatio)),
width: mediaShortSide,
};
}
export function getVideoCanvasHistoryMediaNodePosition({
center,
index,
placementGap,
}: {
center: { x: number; y: number };
index: number;
placementGap: number;
}) {
return {
x: Math.round(center.x + index * placementGap),
y: Math.round(center.y),
};
}
const VIDEO_CANVAS_HISTORY_NODE_TITLE_LABELS: Record<
VideoCanvasHistoryMediaType,
string
> = {
audio: "音频",
image: "图片",
video: "视频",
};
export type VideoCanvasHistoryMediaOrdinals = Record<
VideoCanvasHistoryMediaType,
number
>;
export function getInitialVideoCanvasHistoryMediaOrdinals(
nodes: Array<{
inputType?: string | null;
title?: string | null;
}>,
): VideoCanvasHistoryMediaOrdinals {
const ordinals: VideoCanvasHistoryMediaOrdinals = {
audio: 1,
image: 1,
video: 1,
};
for (const node of nodes) {
if (!isVideoCanvasHistoryMediaType(node.inputType)) continue;
const label = VIDEO_CANVAS_HISTORY_NODE_TITLE_LABELS[node.inputType];
const match = String(node.title || "")
.trim()
.match(new RegExp(`^${label}节点\\s+(\\d+)$`));
if (!match) continue;
ordinals[node.inputType] = Math.max(
ordinals[node.inputType],
Number.parseInt(match[1], 10) + 1,
);
}
return ordinals;
}
export function allocateVideoCanvasHistoryMediaNodeTitle({
mediaType,
nextOrdinal,
sourceTitle,
}: {
mediaType: VideoCanvasHistoryMediaType;
nextOrdinal: number;
sourceTitle: string;
}) {
const label = VIDEO_CANVAS_HISTORY_NODE_TITLE_LABELS[mediaType];
const trimmedTitle = sourceTitle.trim();
const numberedTitle = trimmedTitle.match(
new RegExp(`^${label}节点\\s+(\\d+)$`),
);
if (!numberedTitle && trimmedTitle) {
return { nextOrdinal, title: trimmedTitle };
}
const sourceOrdinal = numberedTitle
? Number.parseInt(numberedTitle[1], 10)
: 1;
const ordinal = Math.max(1, nextOrdinal, sourceOrdinal);
return {
nextOrdinal: ordinal + 1,
title: `${label}节点 ${ordinal}`,
};
}
export function isVideoCanvasHistoryZoomPercent(
value: unknown,
): value is VideoCanvasHistoryZoomPercent {
return VIDEO_CANVAS_HISTORY_ZOOM_LEVELS.some(
(level) => level.percent === Number(value),
);
}
export function getVideoCanvasHistoryZoomIndex(
percent: VideoCanvasHistoryZoomPercent,
) {
const index = VIDEO_CANVAS_HISTORY_ZOOM_LEVELS.findIndex(
(level) => level.percent === percent,
);
return index >= 0 ? index : 1;
}
export function normalizeVideoCanvasHistoryAssetIds(
value: unknown,
limit = 50,
) {
if (!Array.isArray(value)) return [];
return Array.from(
new Set(
value
.map((item) => String(item || "").trim())
.filter(Boolean)
.slice(0, Math.max(1, limit)),
),
);
}
export function formatVideoCanvasHistoryDate(
value: string | Date,
timeZone?: string,
) {
const date = value instanceof Date ? value : new Date(value);
if (!Number.isFinite(date.getTime())) return "日期未知";
const formatter = new Intl.DateTimeFormat("en-CA", {
day: "2-digit",
month: "2-digit",
timeZone,
year: "numeric",
});
const parts = Object.fromEntries(
formatter
.formatToParts(date)
.filter((part) => part.type !== "literal")
.map((part) => [part.type, part.value]),
);
return `${parts.year}-${parts.month}-${parts.day}`;
}
export function groupVideoCanvasHistoryItems<
T extends { createdAt: string | Date },
>(items: T[], timeZone?: string) {
const groups = new Map<string, T[]>();
for (const item of items) {
const date = formatVideoCanvasHistoryDate(item.createdAt, timeZone);
const group = groups.get(date);
if (group) group.push(item);
else groups.set(date, [item]);
}
return Array.from(groups, ([date, groupedItems]) => ({
date,
items: groupedItems,
}));
}

View File

@ -0,0 +1,64 @@
import { readFile, realpath } from "node:fs/promises";
import { resolve, sep } from "node:path";
const LOCAL_UPLOAD_URL_PREFIX = "/uploads/local/";
function isLoopbackHostname(hostname: string) {
const normalized = hostname.toLowerCase();
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
}
export function resolveTrustedLocalImageCandidate(
imageInput: string,
workspaceRoot = process.cwd(),
) {
const raw = String(imageInput || "").trim();
if (!raw) return null;
let pathname = "";
if (raw.startsWith("/")) {
pathname = new URL(raw, "http://localhost").pathname;
} else {
let url: URL;
try {
url = new URL(raw);
} catch {
return null;
}
if (!["http:", "https:"].includes(url.protocol) || !isLoopbackHostname(url.hostname)) {
return null;
}
pathname = url.pathname;
}
let decodedPathname: string;
try {
decodedPathname = decodeURIComponent(pathname);
} catch {
return null;
}
if (!decodedPathname.startsWith(LOCAL_UPLOAD_URL_PREFIX)) return null;
const uploadRoot = resolve(workspaceRoot, "public", "uploads", "local");
const relativePath = decodedPathname.slice(LOCAL_UPLOAD_URL_PREFIX.length);
const candidatePath = resolve(uploadRoot, relativePath);
if (!relativePath || !candidatePath.startsWith(`${uploadRoot}${sep}`)) return null;
return { candidatePath, uploadRoot };
}
export async function readTrustedLocalImage(
imageInput: string,
workspaceRoot = process.cwd(),
) {
const candidate = resolveTrustedLocalImageCandidate(imageInput, workspaceRoot);
if (!candidate) return null;
const [realUploadRoot, realCandidatePath] = await Promise.all([
realpath(candidate.uploadRoot),
realpath(candidate.candidatePath),
]);
if (!realCandidatePath.startsWith(`${realUploadRoot}${sep}`)) {
throw new Error("本地图片路径不允许访问");
}
return readFile(realCandidatePath);
}

View File

@ -0,0 +1,226 @@
import type { VideoCanvasModelCatalogEntry } from "@/lib/video-canvas/video-model-catalog";
export type VideoCanvasNodeCreationConfig = {
audio: {
advancedSettingsOpen: boolean;
};
image: {
count: number;
height: number;
modelLabel: string;
quality: string;
ratio: string;
size: string;
width: number;
};
upload: {
accept: string;
maxFileSizeMb: number;
maxFiles: number;
placementGap: number;
};
video: {
count: number;
durationSeconds: number;
generateAudio: boolean;
height: number;
modelCatalogId: string;
ratio: string;
resolution: string;
width: number;
};
};
export const defaultVideoCanvasNodeCreationConfig: VideoCanvasNodeCreationConfig = {
audio: {
advancedSettingsOpen: true,
},
image: {
count: 1,
height: 350,
modelLabel: "Lib Image",
quality: "标准画质",
ratio: "16:9",
size: "2K",
width: 622,
},
upload: {
accept: "image/*,video/*,audio/*",
maxFileSizeMb: 50,
maxFiles: 10,
placementGap: 450,
},
video: {
count: 1,
durationSeconds: 5,
generateAudio: true,
height: 350,
modelCatalogId: "seedance-1-5-pro",
ratio: "16:9",
resolution: "1080P",
width: 622,
},
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizeInteger(value: unknown, fallback: number, minimum: number, maximum: number) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(minimum, Math.min(maximum, Math.round(parsed)));
}
function normalizeLabel(value: unknown, fallback: string, maximumLength = 80) {
return String(value || "").trim().slice(0, maximumLength) || fallback;
}
export function normalizeVideoCanvasNodeCreationConfig(
value: unknown,
): VideoCanvasNodeCreationConfig {
const fallback = defaultVideoCanvasNodeCreationConfig;
const incoming = isRecord(value) ? value : {};
const incomingAudio = isRecord(incoming.audio) ? incoming.audio : {};
const incomingImage = isRecord(incoming.image) ? incoming.image : {};
const incomingUpload = isRecord(incoming.upload) ? incoming.upload : {};
const incomingVideo = isRecord(incoming.video) ? incoming.video : {};
return {
audio: {
advancedSettingsOpen:
typeof incomingAudio.advancedSettingsOpen === "boolean"
? incomingAudio.advancedSettingsOpen
: fallback.audio.advancedSettingsOpen,
},
image: {
count: normalizeInteger(incomingImage.count, fallback.image.count, 1, 4),
height: normalizeInteger(incomingImage.height, fallback.image.height, 240, 1200),
modelLabel: normalizeLabel(incomingImage.modelLabel, fallback.image.modelLabel),
quality: normalizeLabel(incomingImage.quality, fallback.image.quality, 40),
ratio: normalizeLabel(incomingImage.ratio, fallback.image.ratio, 30),
size: normalizeLabel(incomingImage.size, fallback.image.size, 30),
width: normalizeInteger(incomingImage.width, fallback.image.width, 240, 1600),
},
upload: {
accept: normalizeLabel(incomingUpload.accept, fallback.upload.accept, 240),
maxFileSizeMb: normalizeInteger(
incomingUpload.maxFileSizeMb,
fallback.upload.maxFileSizeMb,
1,
500,
),
maxFiles: normalizeInteger(
incomingUpload.maxFiles,
fallback.upload.maxFiles,
1,
50,
),
placementGap: normalizeInteger(
incomingUpload.placementGap,
fallback.upload.placementGap,
40,
2000,
),
},
video: {
count: normalizeInteger(incomingVideo.count, fallback.video.count, 1, 4),
durationSeconds: normalizeInteger(
incomingVideo.durationSeconds,
fallback.video.durationSeconds,
1,
60,
),
generateAudio:
typeof incomingVideo.generateAudio === "boolean"
? incomingVideo.generateAudio
: fallback.video.generateAudio,
height: normalizeInteger(incomingVideo.height, fallback.video.height, 240, 1200),
modelCatalogId: normalizeLabel(
incomingVideo.modelCatalogId,
fallback.video.modelCatalogId,
120,
),
ratio: normalizeLabel(incomingVideo.ratio, fallback.video.ratio, 30),
resolution: normalizeLabel(
incomingVideo.resolution,
fallback.video.resolution,
30,
).toUpperCase(),
width: normalizeInteger(incomingVideo.width, fallback.video.width, 240, 1600),
},
};
}
export function getInitialVideoCanvasNodeCreationOrdinal(
nodes: Array<{ title?: string | null }>,
) {
const highestSuffix = nodes.reduce((maximum, node) => {
const match = String(node.title || "").trim().match(/(?:^|\s)(\d+)$/);
return match ? Math.max(maximum, Number.parseInt(match[1], 10)) : maximum;
}, 0);
return Math.max(1, nodes.length, highestSuffix + 1);
}
export function resolveVideoCanvasCreationModel(
config: VideoCanvasNodeCreationConfig,
catalog: VideoCanvasModelCatalogEntry[],
) {
const selected =
catalog.find((entry) => entry.enabled && entry.id === config.video.modelCatalogId) ||
catalog.find((entry) => entry.enabled) ||
catalog[0];
return {
catalogId: selected?.id || config.video.modelCatalogId,
label: selected?.label || "自动模型",
modelRef: selected?.modelRef || "auto",
};
}
export function buildVideoCanvasImageCreationParams(
config: VideoCanvasNodeCreationConfig,
): Record<string, unknown> {
return {
count: config.image.count,
imageCount: config.image.count,
imageQuality: config.image.quality,
imageSize: config.image.size,
modelLabel: config.image.modelLabel,
ratio: config.image.ratio,
settings: {
count: config.image.count,
imageQuality: config.image.quality,
imageSize: config.image.size,
modelLabel: config.image.modelLabel,
ratio: config.image.ratio,
},
};
}
export function buildVideoCanvasVideoCreationParams(
config: VideoCanvasNodeCreationConfig,
catalog: VideoCanvasModelCatalogEntry[],
): Record<string, unknown> {
const model = resolveVideoCanvasCreationModel(config, catalog);
const audio = config.video.generateAudio ? "开启" : "关闭";
return {
audio: config.video.generateAudio,
count: config.video.count,
duration: config.video.durationSeconds,
mode: "text-to-video",
model: model.modelRef,
ratio: config.video.ratio,
resolution: config.video.resolution.toLowerCase(),
settings: {
audio,
count: `${config.video.count}个`,
duration: `${config.video.durationSeconds}s`,
mode: "text-to-video",
model: model.modelRef,
modelCatalogId: model.catalogId,
modelLabel: model.label,
ratio: config.video.ratio,
resolution: config.video.resolution,
},
};
}

View File

@ -0,0 +1,159 @@
export type VideoPromptMention = {
id: string;
index: number;
label: string;
mediaType: "audio" | "image" | "video";
offset: number;
sourceNodeId: string;
url: string;
};
export type VideoPromptToolMark = {
id: string;
label: string;
};
export type VideoPromptToolReference = Pick<
VideoPromptMention,
"id" | "index" | "label" | "mediaType" | "offset"
>;
const STRUCTURE_TOKEN_PATTERN = /\[\[(REF|MARK):([A-Za-z0-9_-]{1,100})\]\]/g;
function normalizeTokenId(value: unknown) {
return String(value || "")
.trim()
.replace(/[^A-Za-z0-9_-]+/g, "-")
.slice(0, 100);
}
export function normalizeVideoPromptMentions(value: unknown): VideoPromptMention[] {
if (!Array.isArray(value)) return [];
return value.slice(0, 20).flatMap((item, position) => {
if (!item || typeof item !== "object") return [];
const record = item as Record<string, unknown>;
const sourceNodeId = String(record.sourceNodeId || "").trim().slice(0, 120);
if (!sourceNodeId) return [];
const index = Math.max(1, Math.min(10, Math.round(Number(record.index) || position + 1)));
const mediaType = ["audio", "image", "video"].includes(String(record.mediaType))
? (String(record.mediaType) as VideoPromptMention["mediaType"])
: "image";
const id = normalizeTokenId(record.id) || `mention-${sourceNodeId}-${position + 1}`;
return [{
id,
index,
label: String(record.label || `图片 ${index}`).trim().slice(0, 100) || `图片 ${index}`,
mediaType,
offset: Math.max(0, Math.round(Number(record.offset) || 0)),
sourceNodeId,
url: String(record.url || "").trim().slice(0, 2048),
}];
});
}
function referenceToken(id: string) {
return `[[REF:${normalizeTokenId(id)}]]`;
}
function markToken(id: string) {
return `[[MARK:${normalizeTokenId(id)}]]`;
}
export function injectVideoPromptStructure(
prompt: string,
references: VideoPromptToolReference[],
marks: VideoPromptToolMark[],
) {
const normalizedPrompt = String(prompt || "");
const insertions = references
.map((reference, order) => ({
order,
offset: Math.max(0, Math.min(normalizedPrompt.length, Math.round(reference.offset || 0))),
token: referenceToken(reference.id),
}))
.sort((left, right) => left.offset - right.offset || left.order - right.order);
let cursor = 0;
let result = "";
for (const insertion of insertions) {
result += normalizedPrompt.slice(cursor, insertion.offset);
result += insertion.token;
cursor = insertion.offset;
}
result += normalizedPrompt.slice(cursor);
const markTokens = marks
.map((mark) => markToken(mark.id))
.filter((token) => token !== "[[MARK:]]");
return markTokens.length > 0 ? `${result} ${markTokens.join(" ")}`.trim() : result;
}
export function buildVideoPromptStructureInstructions(
references: VideoPromptToolReference[],
marks: VideoPromptToolMark[],
) {
const lines = [
...references.map(
(reference) =>
`${referenceToken(reference.id)} = @${reference.index}「${reference.label}」${reference.mediaType}素材`,
),
...marks.map((mark) => `${markToken(mark.id)} = 画面元素标记「${mark.label}」`),
];
if (lines.length === 0) return "";
return [
"结构化占位符协议:下列占位符代表编辑器中的不可编辑芯片。理解其语义,并在输出中逐字保留每个占位符一次;不要翻译、改写、删除或重复占位符。",
...lines,
].join("\n");
}
export function resolveVideoPromptStructure(
generatedText: string,
references: VideoPromptToolReference[],
marks: VideoPromptToolMark[],
options: { flatten: boolean },
) {
const referenceById = new Map(references.map((item) => [normalizeTokenId(item.id), item]));
const markById = new Map(marks.map((item) => [normalizeTokenId(item.id), item]));
const mentionOffsets: Record<string, number> = {};
const preservedMarkIds: string[] = [];
let cursor = 0;
let text = "";
for (const match of String(generatedText || "").matchAll(STRUCTURE_TOKEN_PATTERN)) {
const start = match.index ?? 0;
text += String(generatedText || "").slice(cursor, start);
const kind = match[1];
const id = normalizeTokenId(match[2]);
if (kind === "REF") {
const reference = referenceById.get(id);
if (reference) {
if (options.flatten) text += reference.label;
else mentionOffsets[reference.id] = text.length;
}
} else {
const mark = markById.get(id);
if (mark) {
if (options.flatten) text += mark.label;
else preservedMarkIds.push(mark.id);
}
}
cursor = start + match[0].length;
}
text += String(generatedText || "").slice(cursor);
const leadingWhitespace = text.length - text.trimStart().length;
if (leadingWhitespace > 0) {
for (const key of Object.keys(mentionOffsets)) {
mentionOffsets[key] = Math.max(0, mentionOffsets[key] - leadingWhitespace);
}
}
return {
mentionOffsets,
preservedMarkIds,
text: text.trim(),
};
}

View File

@ -0,0 +1,209 @@
import type { VideoCanvasSmartStoryboardConfig } from "@/lib/video-canvas/config";
export type VideoCanvasSmartStoryboardShot = {
description: string;
durationSeconds: number;
id: string;
};
export type VideoCanvasSmartStoryboard = {
enabled: boolean;
shots: VideoCanvasSmartStoryboardShot[];
totalDurationSeconds: number;
};
export type VideoCanvasSmartStoryboardValidation = {
emptyShotIndexes: number[];
status: "exact" | "over" | "under";
totalShotDurationSeconds: number;
valid: boolean;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function clampInteger(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number(value);
return Number.isFinite(parsed)
? Math.max(min, Math.min(max, Math.round(parsed)))
: fallback;
}
function normalizeDescription(value: unknown, maximum: number) {
return String(value || "").replace(/\u00a0/g, " ").trim().slice(0, maximum);
}
function interpolate(template: string, variables: Record<string, string | number>) {
return Object.entries(variables).reduce(
(result, [key, value]) =>
result.replaceAll(`{{${key}}}`, String(value)),
template,
);
}
export function supportsVideoCanvasSmartStoryboard(
config: VideoCanvasSmartStoryboardConfig,
modelDescriptor: string,
) {
if (!config.enabled) return false;
const normalizedModel = modelDescriptor.trim().toLowerCase();
return (
normalizedModel.length > 0 &&
config.modelPatterns.some((pattern) =>
normalizedModel.includes(pattern.trim().toLowerCase()),
)
);
}
export function normalizeVideoCanvasSmartStoryboard(
value: unknown,
options: {
config: VideoCanvasSmartStoryboardConfig;
fallbackPrompt?: string;
fallbackTotalDurationSeconds?: number;
},
): VideoCanvasSmartStoryboard {
const { config } = options;
const incoming = isRecord(value) ? value : {};
const fallbackTotalDurationSeconds = clampInteger(
options.fallbackTotalDurationSeconds,
config.minTotalDurationSeconds,
config.minTotalDurationSeconds,
config.maxTotalDurationSeconds,
);
const totalDurationSeconds = clampInteger(
incoming.totalDurationSeconds,
fallbackTotalDurationSeconds,
config.minTotalDurationSeconds,
config.maxTotalDurationSeconds,
);
const incomingShots = Array.isArray(incoming.shots)
? incoming.shots.slice(0, config.maxShots)
: [];
const shots = incomingShots.flatMap((item, index) => {
const shot = isRecord(item) ? item : {};
const description = normalizeDescription(
shot.description,
config.maxDescriptionLength,
);
const durationSeconds = clampInteger(
shot.durationSeconds,
index === 0 ? totalDurationSeconds : config.defaultShotDurationSeconds,
config.minShotDurationSeconds,
config.maxTotalDurationSeconds,
);
return [
{
description,
durationSeconds,
id:
String(shot.id || "").trim().slice(0, 100) ||
`shot-${index + 1}`,
},
];
});
return {
enabled: incoming.enabled === true,
shots:
shots.length > 0
? shots
: [
{
description: normalizeDescription(
options.fallbackPrompt,
config.maxDescriptionLength,
),
durationSeconds: totalDurationSeconds,
id: "shot-1",
},
],
totalDurationSeconds,
};
}
export function getVideoCanvasSmartStoryboard(
draftValues: Record<string, unknown> | undefined,
options: {
config: VideoCanvasSmartStoryboardConfig;
fallbackPrompt?: string;
fallbackTotalDurationSeconds?: number;
},
) {
const params = isRecord(draftValues?.params) ? draftValues.params : {};
return normalizeVideoCanvasSmartStoryboard(
draftValues?.smartStoryboard ?? params.smartStoryboard,
options,
);
}
export function setVideoCanvasSmartStoryboard(
draftValues: Record<string, unknown> | undefined,
storyboard: VideoCanvasSmartStoryboard,
) {
const params = isRecord(draftValues?.params) ? draftValues.params : {};
return {
...(draftValues || {}),
params: {
...params,
smartStoryboard: storyboard,
},
smartStoryboard: storyboard,
};
}
export function validateVideoCanvasSmartStoryboard(
storyboard: VideoCanvasSmartStoryboard,
): VideoCanvasSmartStoryboardValidation {
const emptyShotIndexes = storyboard.shots.flatMap((shot, index) =>
shot.description.trim() ? [] : [index],
);
const totalShotDurationSeconds = storyboard.shots.reduce(
(total, shot) => total + shot.durationSeconds,
0,
);
const status =
totalShotDurationSeconds === storyboard.totalDurationSeconds
? "exact"
: totalShotDurationSeconds > storyboard.totalDurationSeconds
? "over"
: "under";
return {
emptyShotIndexes,
status,
totalShotDurationSeconds,
valid:
!storyboard.enabled ||
(emptyShotIndexes.length === 0 && status === "exact"),
};
}
export function composeVideoCanvasSmartStoryboardPrompt(
prompt: string,
storyboard: VideoCanvasSmartStoryboard,
config: VideoCanvasSmartStoryboardConfig,
) {
const normalizedPrompt = prompt.trim();
if (!storyboard.enabled) return normalizedPrompt;
const validation = validateVideoCanvasSmartStoryboard(storyboard);
if (!validation.valid) return normalizedPrompt;
const shots = storyboard.shots
.map((shot, index) =>
interpolate(config.shotPromptTemplate, {
description: shot.description,
duration: shot.durationSeconds,
index: index + 1,
}).trim(),
)
.filter(Boolean)
.join("\n");
return interpolate(config.promptTemplate, {
prompt: normalizedPrompt,
shotCount: storyboard.shots.length,
shots,
totalDuration: storyboard.totalDurationSeconds,
}).trim();
}

View File

@ -0,0 +1,353 @@
import {
getVideoCanvasCompositionClipDuration,
getVideoCanvasCompositionDuration,
type VideoCanvasStoryboardCompositionClip,
type VideoCanvasStoryboardCompositionProject,
} from "@/lib/video-canvas/storyboard-composition";
type WebkitAudioWindow = Window &
typeof globalThis & {
webkitAudioContext?: typeof AudioContext;
};
type LoadedCompositionClip = {
objectUrl: string;
video: HTMLVideoElement;
};
export type VideoCanvasCompositionRenderResult = {
blob: Blob;
durationSeconds: number;
height: number;
width: number;
};
function getRecorderMimeType() {
const candidates = [
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/webm",
];
return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) || "";
}
function getCanvasVideoSourceUrl(mediaUrl: string) {
const parsedUrl = new URL(mediaUrl, window.location.origin);
return parsedUrl.origin === window.location.origin
? parsedUrl.toString()
: `/api/canvas/video-source?url=${encodeURIComponent(parsedUrl.toString())}`;
}
function waitForMediaEvent(
target: HTMLMediaElement,
eventName: "loadedmetadata" | "seeked",
) {
return new Promise<void>((resolve, reject) => {
const timeout = window.setTimeout(
() => reject(new Error(`视频 ${eventName} 超时`)),
20_000,
);
const cleanup = () => {
window.clearTimeout(timeout);
target.removeEventListener(eventName, handleSuccess);
target.removeEventListener("error", handleError);
};
const handleSuccess = () => {
cleanup();
resolve();
};
const handleError = () => {
cleanup();
reject(new Error("视频素材无法解码"));
};
target.addEventListener(eventName, handleSuccess, { once: true });
target.addEventListener("error", handleError, { once: true });
});
}
async function loadCompositionClip(
clip: VideoCanvasStoryboardCompositionClip,
): Promise<LoadedCompositionClip> {
const response = await fetch(getCanvasVideoSourceUrl(clip.mediaUrl), {
credentials: "include",
});
if (!response.ok) throw new Error(`视频素材加载失败:${clip.title}`);
const objectUrl = URL.createObjectURL(await response.blob());
const video = document.createElement("video");
video.playsInline = true;
video.preload = "auto";
video.src = objectUrl;
if (video.readyState < HTMLMediaElement.HAVE_METADATA) {
await waitForMediaEvent(video, "loadedmetadata");
}
return { objectUrl, video };
}
export function getVideoCanvasCompositionOutputSize(
sourceWidth: number,
sourceHeight: number,
maximumDimension = 1280,
) {
const width = Math.max(2, sourceWidth || 1280);
const height = Math.max(2, sourceHeight || 720);
const scale = Math.min(1, maximumDimension / Math.max(width, height));
return {
height: Math.max(2, Math.round((height * scale) / 2) * 2),
width: Math.max(2, Math.round((width * scale) / 2) * 2),
};
}
function drawVideoContained(
context: CanvasRenderingContext2D,
video: HTMLVideoElement,
width: number,
height: number,
) {
context.fillStyle = "#000";
context.fillRect(0, 0, width, height);
const sourceWidth = Math.max(1, video.videoWidth);
const sourceHeight = Math.max(1, video.videoHeight);
const scale = Math.min(width / sourceWidth, height / sourceHeight);
const drawWidth = sourceWidth * scale;
const drawHeight = sourceHeight * scale;
context.drawImage(
video,
(width - drawWidth) / 2,
(height - drawHeight) / 2,
drawWidth,
drawHeight,
);
}
function drawTransition(
context: CanvasRenderingContext2D,
previousFrame: HTMLCanvasElement,
clip: VideoCanvasStoryboardCompositionClip,
elapsedSeconds: number,
width: number,
height: number,
) {
if (!(clip.transition && elapsedSeconds < 0.5)) return;
const progress = Math.max(0, Math.min(1, elapsedSeconds / 0.5));
if (clip.transition === "fade") {
context.save();
context.globalAlpha = 1 - progress;
context.drawImage(previousFrame, 0, 0, width, height);
context.restore();
return;
}
const midpointProgress = progress < 0.5 ? progress * 2 : (1 - progress) * 2;
if (progress < 0.5) {
context.save();
context.globalAlpha = 1 - progress * 2;
context.drawImage(previousFrame, 0, 0, width, height);
context.restore();
}
context.save();
context.globalAlpha = midpointProgress;
context.fillStyle = clip.transition === "fade-white" ? "#fff" : "#000";
context.fillRect(0, 0, width, height);
context.restore();
}
function drawSubtitles(
context: CanvasRenderingContext2D,
project: VideoCanvasStoryboardCompositionProject,
timelineSeconds: number,
width: number,
height: number,
) {
const active = project.subtitles.filter(
(subtitle) =>
timelineSeconds >= subtitle.startSeconds &&
timelineSeconds <= subtitle.endSeconds &&
subtitle.text.trim(),
);
if (!active.length) return;
const fontSize = Math.max(18, Math.round(width / 38));
context.save();
context.font = `600 ${fontSize}px sans-serif`;
context.textAlign = "center";
context.textBaseline = "middle";
active.slice(0, 2).forEach((subtitle, index) => {
const text = subtitle.text.trim();
const metrics = context.measureText(text);
const boxWidth = Math.min(width - 48, metrics.width + 36);
const y = height - 54 - index * (fontSize + 20);
context.fillStyle = "rgba(0,0,0,0.62)";
context.fillRect((width - boxWidth) / 2, y - fontSize, boxWidth, fontSize + 18);
context.fillStyle = "#fff";
context.fillText(text, width / 2, y);
});
context.restore();
}
function nextAnimationFrame() {
return new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
}
export async function renderVideoCanvasStoryboardComposition(
project: VideoCanvasStoryboardCompositionProject,
onProgress?: (progress: number) => void,
): Promise<VideoCanvasCompositionRenderResult> {
if (!project.clips.length) throw new Error("请先添加视频片段");
if (typeof MediaRecorder === "undefined") {
throw new Error("当前浏览器不支持成片导出");
}
const loadedClips: LoadedCompositionClip[] = [];
const AudioContextCtor =
window.AudioContext || (window as WebkitAudioWindow).webkitAudioContext;
const audioContext = AudioContextCtor ? new AudioContextCtor() : null;
const audioDestination = audioContext?.createMediaStreamDestination() || null;
let outputStream: MediaStream | null = null;
let recorder: MediaRecorder | null = null;
try {
for (const clip of project.clips) {
loadedClips.push(await loadCompositionClip(clip));
}
const firstVideo = loadedClips[0].video;
const { width, height } = getVideoCanvasCompositionOutputSize(
firstVideo.videoWidth,
firstVideo.videoHeight,
);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("成片渲染画布创建失败");
const previousFrame = document.createElement("canvas");
previousFrame.width = width;
previousFrame.height = height;
const previousContext = previousFrame.getContext("2d");
if (!previousContext) throw new Error("转场画布创建失败");
if (audioContext && audioDestination) {
for (const loaded of loadedClips) {
audioContext.createMediaElementSource(loaded.video).connect(audioDestination);
}
await audioContext.resume();
}
const canvasStream = canvas.captureStream(30);
outputStream = new MediaStream([
...canvasStream.getVideoTracks(),
...(audioDestination?.stream.getAudioTracks() || []),
]);
const mimeType = getRecorderMimeType();
recorder = new MediaRecorder(
outputStream,
mimeType ? { mimeType, videoBitsPerSecond: 5_000_000 } : undefined,
);
const chunks: BlobPart[] = [];
recorder.addEventListener("dataavailable", (event) => {
if (event.data.size) chunks.push(event.data);
});
const completed = new Promise<Blob>((resolve, reject) => {
recorder?.addEventListener(
"error",
() => reject(new Error("成片编码失败")),
{ once: true },
);
recorder?.addEventListener(
"stop",
() =>
resolve(
new Blob(chunks, {
type: recorder?.mimeType || "video/webm",
}),
),
{ once: true },
);
});
const totalDuration = getVideoCanvasCompositionDuration(project);
let timelineOffset = 0;
context.fillStyle = "#000";
context.fillRect(0, 0, width, height);
previousContext.drawImage(canvas, 0, 0);
recorder.start(200);
for (let index = 0; index < project.clips.length; index += 1) {
const clip = project.clips[index];
const video = loadedClips[index].video;
const sourceDuration = Number.isFinite(video.duration)
? video.duration
: clip.durationSeconds;
const startSeconds = Math.min(
Math.max(0, clip.inPointSeconds),
Math.max(0, sourceDuration - 0.05),
);
const endSeconds = Math.min(
sourceDuration,
Math.max(startSeconds + 0.05, clip.outPointSeconds),
);
const timelineDuration = Math.max(
0.05,
(endSeconds - startSeconds) / clip.speed,
);
video.currentTime = startSeconds;
if (startSeconds > 0) await waitForMediaEvent(video, "seeked");
video.playbackRate = clip.speed;
await video.play();
const startedAt = performance.now();
while ((performance.now() - startedAt) / 1000 < timelineDuration) {
const elapsedSeconds = (performance.now() - startedAt) / 1000;
drawVideoContained(context, video, width, height);
drawTransition(
context,
previousFrame,
clip,
elapsedSeconds,
width,
height,
);
drawSubtitles(
context,
project,
timelineOffset + elapsedSeconds,
width,
height,
);
onProgress?.(
Math.min(0.99, (timelineOffset + elapsedSeconds) / totalDuration),
);
await nextAnimationFrame();
}
video.pause();
drawVideoContained(context, video, width, height);
drawSubtitles(
context,
project,
timelineOffset + timelineDuration,
width,
height,
);
previousContext.clearRect(0, 0, width, height);
previousContext.drawImage(canvas, 0, 0);
timelineOffset += getVideoCanvasCompositionClipDuration(clip);
}
recorder.stop();
const blob = await completed;
if (!blob.size) throw new Error("成片导出结果为空");
onProgress?.(1);
return { blob, durationSeconds: totalDuration, height, width };
} finally {
for (const loaded of loadedClips) {
loaded.video.pause();
loaded.video.removeAttribute("src");
loaded.video.load();
URL.revokeObjectURL(loaded.objectUrl);
}
if (recorder?.state !== "inactive") recorder?.stop();
outputStream?.getTracks().forEach((track) => track.stop());
if (audioContext?.state !== "closed") await audioContext?.close();
}
}

View File

@ -0,0 +1,223 @@
export type VideoCanvasStoryboardCompositionClip = {
durationSeconds: number;
id: string;
inPointSeconds: number;
mediaUrl: string;
nodeId: string;
outPointSeconds: number;
speed: number;
title: string;
transition: VideoCanvasStoryboardCompositionTransition | null;
};
export type VideoCanvasStoryboardCompositionSubtitle = {
endSeconds: number;
id: string;
startSeconds: number;
text: string;
};
export type VideoCanvasStoryboardCompositionTransition =
| "fade"
| "fade-black"
| "fade-white";
export type VideoCanvasStoryboardCompositionProject = {
clips: VideoCanvasStoryboardCompositionClip[];
subtitles: VideoCanvasStoryboardCompositionSubtitle[];
};
export const EMPTY_VIDEO_CANVAS_STORYBOARD_COMPOSITION: VideoCanvasStoryboardCompositionProject = {
clips: [],
subtitles: [],
};
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
const asString = (value: unknown) =>
typeof value === "string" ? value.trim() : "";
const asFiniteNumber = (value: unknown, fallback: number) => {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
};
export function normalizeVideoCanvasStoryboardComposition(
value: unknown,
): VideoCanvasStoryboardCompositionProject {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return structuredClone(EMPTY_VIDEO_CANVAS_STORYBOARD_COMPOSITION);
}
const source = value as Record<string, unknown>;
const clips = Array.isArray(source.clips)
? source.clips.flatMap((candidate, index) => {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
return [];
}
const clip = candidate as Record<string, unknown>;
const nodeId = asString(clip.nodeId);
const mediaUrl = asString(clip.mediaUrl);
if (!(nodeId && mediaUrl)) return [];
const durationSeconds = Math.max(
0.1,
asFiniteNumber(clip.durationSeconds, 5),
);
const inPointSeconds = clamp(
asFiniteNumber(clip.inPointSeconds, 0),
0,
Math.max(0, durationSeconds - 0.1),
);
const outPointSeconds = clamp(
asFiniteNumber(clip.outPointSeconds, durationSeconds),
inPointSeconds + 0.1,
durationSeconds,
);
const transition = asString(clip.transition);
return [{
durationSeconds,
id: asString(clip.id) || `${nodeId}-${index + 1}`,
inPointSeconds,
mediaUrl,
nodeId,
outPointSeconds,
speed: clamp(asFiniteNumber(clip.speed, 1), 0.25, 4),
title: asString(clip.title) || "视频片段",
transition:
transition === "fade" ||
transition === "fade-black" ||
transition === "fade-white"
? transition
: null,
} satisfies VideoCanvasStoryboardCompositionClip];
})
: [];
const subtitles = Array.isArray(source.subtitles)
? source.subtitles.flatMap((candidate, index) => {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
return [];
}
const subtitle = candidate as Record<string, unknown>;
const startSeconds = Math.max(
0,
asFiniteNumber(subtitle.startSeconds, 0),
);
const endSeconds = Math.max(
startSeconds + 0.1,
asFiniteNumber(subtitle.endSeconds, startSeconds + 2),
);
return [{
endSeconds,
id: asString(subtitle.id) || `subtitle-${index + 1}`,
startSeconds,
text: asString(subtitle.text) || "新建字幕",
} satisfies VideoCanvasStoryboardCompositionSubtitle];
})
: [];
return { clips, subtitles };
}
export function getVideoCanvasCompositionClipDuration(
clip: VideoCanvasStoryboardCompositionClip,
) {
return Math.max(0.1, clip.outPointSeconds - clip.inPointSeconds) / clip.speed;
}
export function getVideoCanvasCompositionDuration(
project: VideoCanvasStoryboardCompositionProject,
) {
return project.clips.reduce(
(total, clip) => total + getVideoCanvasCompositionClipDuration(clip),
0,
);
}
export function getVideoCanvasCompositionClipStart(
project: VideoCanvasStoryboardCompositionProject,
clipId: string,
) {
let start = 0;
for (const clip of project.clips) {
if (clip.id === clipId) return start;
start += getVideoCanvasCompositionClipDuration(clip);
}
return null;
}
export function createVideoCanvasCompositionClip({
durationSeconds,
existingIds,
mediaUrl,
nodeId,
title,
}: {
durationSeconds: number;
existingIds: string[];
mediaUrl: string;
nodeId: string;
title: string;
}): VideoCanvasStoryboardCompositionClip {
const normalizedDuration = Math.max(0.1, durationSeconds || 5);
const baseId = `${nodeId}-clip`;
let suffix = 1;
let id = `${baseId}-${suffix}`;
while (existingIds.includes(id)) {
suffix += 1;
id = `${baseId}-${suffix}`;
}
return {
durationSeconds: normalizedDuration,
id,
inPointSeconds: 0,
mediaUrl,
nodeId,
outPointSeconds: normalizedDuration,
speed: 1,
title: title.trim() || "视频片段",
transition: null,
};
}
export function splitVideoCanvasCompositionClip(
project: VideoCanvasStoryboardCompositionProject,
clipId: string,
playheadSeconds: number,
): VideoCanvasStoryboardCompositionProject {
const clipIndex = project.clips.findIndex((clip) => clip.id === clipId);
const clipStart = getVideoCanvasCompositionClipStart(project, clipId);
if (clipIndex < 0 || clipStart === null) return project;
const clip = project.clips[clipIndex];
const relativeTimelineSeconds = playheadSeconds - clipStart;
const sourceSplitSeconds =
clip.inPointSeconds + relativeTimelineSeconds * clip.speed;
if (
sourceSplitSeconds <= clip.inPointSeconds + 0.1 ||
sourceSplitSeconds >= clip.outPointSeconds - 0.1
) {
return project;
}
const suffix = project.clips.filter((item) => item.id.startsWith(`${clip.id}-split`)).length + 1;
const left = { ...clip, outPointSeconds: sourceSplitSeconds };
const right = {
...clip,
id: `${clip.id}-split-${suffix}`,
inPointSeconds: sourceSplitSeconds,
transition: null,
};
return {
...project,
clips: [
...project.clips.slice(0, clipIndex),
left,
right,
...project.clips.slice(clipIndex + 1),
],
};
}

View File

@ -0,0 +1,595 @@
export const VIDEO_CANVAS_VIEW_MODE_KEY = "videoCanvasViewMode";
export type VideoCanvasViewMode = "storyboard" | "workflow";
export type VideoCanvasStoryboardExpandedPanel = "image" | "video" | null;
export type VideoCanvasStoryboardVideoCategory = "clip" | "film";
export type VideoCanvasStoryboardVideoFilter =
| "all"
| VideoCanvasStoryboardVideoCategory;
export const VIDEO_CANVAS_STORYBOARD_VIDEO_FILTER_OPTIONS: Array<{
label: string;
value: VideoCanvasStoryboardVideoFilter;
}> = [
{ label: "全部", value: "all" },
{ label: "成片", value: "film" },
{ label: "片段", value: "clip" },
];
export type VideoCanvasStoryboardColumnLayout = {
image: 1 | 2;
text: 0 | 1;
video: 0 | 1 | 2;
};
export function getVideoCanvasStoryboardColumnLayout(
expandedPanel: VideoCanvasStoryboardExpandedPanel,
): VideoCanvasStoryboardColumnLayout {
if (expandedPanel === "image") {
return { image: 2, text: 1, video: 0 };
}
if (expandedPanel === "video") {
return { image: 1, text: 0, video: 2 };
}
return { image: 1, text: 1, video: 1 };
}
export type VideoCanvasKeyElementCategory =
| "character"
| "object"
| "other"
| "scene";
export type VideoCanvasKeyElementFilter =
| "all"
| VideoCanvasKeyElementCategory;
export const VIDEO_CANVAS_KEY_ELEMENT_CATEGORY_OPTIONS: Array<{
label: string;
value: VideoCanvasKeyElementCategory;
}> = [
{ label: "人物", value: "character" },
{ label: "场景", value: "scene" },
{ label: "物品", value: "object" },
{ label: "其他", value: "other" },
];
export function getVideoCanvasKeyElementCategoryLabel(
category: VideoCanvasKeyElementCategory,
) {
return (
VIDEO_CANVAS_KEY_ELEMENT_CATEGORY_OPTIONS.find(
(option) => option.value === category,
)?.label || "其他"
);
}
export type VideoCanvasStoryboardSourceNode = {
draftValues?: Record<string, unknown>;
id: string;
inputType?: "audio" | "image" | "text" | "video";
kind?: string;
subtitle?: string;
title?: string;
};
export type VideoCanvasStoryboardItem = {
aspectRatio: string;
contentType: string;
durationSeconds: number;
generationStatus: string;
id: string;
imageCount: number;
imageQuality: string;
imageSize: string;
keyElementCategory?: VideoCanvasKeyElementCategory;
mediaType: "audio" | "image" | "text" | "video";
mediaUrl: string;
modelName: string;
modelRef: string;
prompt: string;
previewUrl: string;
referenceImages: string[];
resolutionLabel: string;
styleLabel: string;
subtitle: string;
textContent: string;
title: string;
videoCategory?: VideoCanvasStoryboardVideoCategory;
};
export type VideoCanvasStoryboardCompositionItem = {
id: string;
title: string;
};
export type VideoCanvasStoryboardProjection = {
audio: VideoCanvasStoryboardItem[];
image: VideoCanvasStoryboardItem[];
keyElements: VideoCanvasStoryboardItem[];
text: VideoCanvasStoryboardItem[];
video: VideoCanvasStoryboardItem[];
};
function getString(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function getDraftString(
draftValues: Record<string, unknown> | undefined,
key: string,
) {
return getString(draftValues?.[key]);
}
export function isVideoCanvasStoryboardCompositionNode(
node: VideoCanvasStoryboardSourceNode,
) {
const action = getDraftString(node.draftValues, "action").toUpperCase();
const nodeType = getDraftString(node.draftValues, "nodeType").toLowerCase();
return action === "VIDEO_CLIP_RESOURCE" || nodeType === "video-clip";
}
export function projectVideoCanvasStoryboardCompositions(
nodes: VideoCanvasStoryboardSourceNode[],
): VideoCanvasStoryboardCompositionItem[] {
return nodes
.filter(isVideoCanvasStoryboardCompositionNode)
.map((node) => ({
id: node.id,
title: getString(node.title) || "视频合成",
}));
}
function getStoryboardMediaUrl(
node: VideoCanvasStoryboardSourceNode,
mediaType: VideoCanvasStoryboardItem["mediaType"],
) {
const draftValues = node.draftValues;
const direct = getDraftString(draftValues, "mediaUrl");
if (direct) return direct;
const urls = draftValues?.url;
const firstUrl =
Array.isArray(urls) && typeof urls[0] === "string"
? urls[0].trim()
: "";
if (firstUrl) return firstUrl;
if (mediaType === "video") return "";
if (mediaType === "audio") {
return getDraftString(draftValues, "sourceUrl");
}
return (
getDraftString(draftValues, "thumbnailUrl") ||
getDraftString(draftValues, "resultImage") ||
getDraftString(draftValues, "sourceUrl")
);
}
function getStoryboardPreviewUrl(
node: VideoCanvasStoryboardSourceNode,
mediaType: VideoCanvasStoryboardItem["mediaType"],
) {
const draftValues = node.draftValues;
const preview = getFirstDraftString(draftValues, [
"thumbnailUrl",
"posterUrl",
"previewImageUrl",
"coverUrl",
"poster",
"resultImage",
]);
if (preview) return preview;
return mediaType === "image" ? getStoryboardMediaUrl(node, mediaType) : "";
}
function getFirstDraftString(
draftValues: Record<string, unknown> | undefined,
keys: string[],
) {
for (const key of keys) {
const value = getDraftString(draftValues, key);
if (value) return value;
}
return "";
}
function getStoryboardTextContent(
draftValues: Record<string, unknown> | undefined,
) {
const content = draftValues?.content;
if (Array.isArray(content)) {
const firstText = content.find(
(value): value is string => typeof value === "string" && Boolean(value.trim()),
);
if (firstText) return firstText.trim();
}
return getFirstDraftString(draftValues, [
"textContent",
"resultText",
"outputText",
"markdown",
"script",
"text",
]);
}
function getStoryboardModelRef(
draftValues: Record<string, unknown> | undefined,
) {
const params = draftValues?.params;
const paramsModel =
params && typeof params === "object" && !Array.isArray(params)
? getString((params as Record<string, unknown>).model)
: "";
return paramsModel || getDraftString(draftValues, "model");
}
function getStoryboardParams(
draftValues: Record<string, unknown> | undefined,
) {
const params = draftValues?.params;
return params && typeof params === "object" && !Array.isArray(params)
? (params as Record<string, unknown>)
: {};
}
function getStoryboardParamString(
draftValues: Record<string, unknown> | undefined,
keys: string[],
) {
const params = getStoryboardParams(draftValues);
for (const key of keys) {
const value = getString(params[key]) || getDraftString(draftValues, key);
if (value) return value;
}
return "";
}
function getStoryboardReferenceImages(
draftValues: Record<string, unknown> | undefined,
) {
const params = getStoryboardParams(draftValues);
const candidates = [
draftValues?.referenceImage,
...(Array.isArray(draftValues?.referenceImages)
? draftValues.referenceImages
: []),
params.referenceImage,
...(Array.isArray(params.referenceImages) ? params.referenceImages : []),
];
return Array.from(
new Set(
candidates
.map((value) => getString(value))
.filter(Boolean),
),
).slice(0, 14);
}
function getStoryboardContentType(
mediaType: VideoCanvasStoryboardItem["mediaType"],
draftValues: Record<string, unknown> | undefined,
) {
const explicit = getFirstDraftString(draftValues, [
"contentType",
"mimeType",
"fileType",
]);
if (explicit.includes("/")) return explicit;
if (mediaType === "audio") return "audio/wav";
if (mediaType === "video") return "video/mp4";
if (mediaType === "text") return "text/plain";
return "image/jpeg";
}
function getStoryboardResolutionLabel(
draftValues: Record<string, unknown> | undefined,
) {
const explicit = getFirstDraftString(draftValues, [
"resolutionLabel",
"resolution",
"imageSize",
"aspectRatio",
"ratio",
]);
if (explicit) return explicit;
const width = Number(draftValues?.originalWidth || draftValues?.width || 0);
const height = Number(draftValues?.originalHeight || draftValues?.height || 0);
return Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0
? `${Math.round(width)} x ${Math.round(height)}`
: "";
}
function resolveStoryboardVideoCategory(
node: VideoCanvasStoryboardSourceNode,
): VideoCanvasStoryboardVideoCategory {
const explicitCategory = getFirstDraftString(node.draftValues, [
"storyboardVideoCategory",
"storyboardVideoType",
"videoAssetCategory",
"videoAssetKind",
]).toLowerCase();
if (/^(film|final|finished|master|成片)$/.test(explicitCategory)) {
return "film";
}
if (/^(clip|fragment|segment|shot|片段)$/.test(explicitCategory)) {
return "clip";
}
if (
node.draftValues?.isStoryboardFilm === true ||
node.draftValues?.isFinalVideo === true
) {
return "film";
}
const action = getDraftString(node.draftValues, "action").toUpperCase();
if (
action === "VIDEO_EDITOR_EXPORT" ||
action === "VIDEO_STORYBOARD_EXPORT" ||
action === "VIDEO_COMPOSITE_EXPORT" ||
action === "VIDEO_MERGE_EXPORT"
) {
return "film";
}
return "clip";
}
function resolveStoryboardMediaType(
node: VideoCanvasStoryboardSourceNode,
): VideoCanvasStoryboardItem["mediaType"] | null {
const draftValues = node.draftValues;
const explicitType =
getDraftString(draftValues, "mediaType") || node.inputType || "";
if (
explicitType === "audio" ||
explicitType === "image" ||
explicitType === "text" ||
explicitType === "video"
) {
return explicitType;
}
const kind = getString(node.kind).toLowerCase();
const action = getDraftString(draftValues, "action").toUpperCase();
const searchable = `${getString(node.title)} ${getString(node.subtitle)}`;
if (
kind === "text" ||
kind === "script" ||
kind === "video-story" ||
action.includes("TEXT") ||
/文本|提示词|剧本|脚本/.test(searchable)
) {
return "text";
}
if (kind === "audio" || action.includes("AUDIO") || /音频|音乐|配音/.test(searchable)) {
return "audio";
}
if (action.includes("VIDEO") || /视频|成片|成品/.test(searchable)) {
return "video";
}
if (
kind === "media" ||
kind.startsWith("material-") ||
action.includes("IMAGE") ||
/图片|图像|镜头|分镜|角色|场景|人物/.test(searchable)
) {
return "image";
}
return null;
}
function isKeyElement(
node: VideoCanvasStoryboardSourceNode,
mediaType: VideoCanvasStoryboardItem["mediaType"],
) {
if (mediaType !== "image") return false;
const draftValues = node.draftValues;
const explicitCategory = [
getDraftString(draftValues, "keyElementType"),
getDraftString(draftValues, "storyboardCategory"),
getDraftString(draftValues, "category"),
getDraftString(draftValues, "elementType"),
getDraftString(draftValues, "materialKind"),
].join(" ");
const action = getDraftString(draftValues, "action").toUpperCase();
const kind = getString(node.kind).toLowerCase();
if (
draftValues?.keyElement === true ||
/关键元素|角色|人物|场景|地点|物品|道具|其他|character|person|role|scene|location|object|item|prop|other/i.test(
explicitCategory,
) ||
kind.startsWith("material-") ||
action.startsWith("MATERIAL_")
) {
return true;
}
const title = getString(node.title);
if (!title || /^(上传|图片|图像|镜头|分镜|成品|结果|首帧|尾帧|关键帧)/.test(title)) {
return false;
}
return /角色|人物|场景|地点|道具/.test(`${title} ${getString(node.subtitle)}`);
}
function resolveKeyElementCategory(
node: VideoCanvasStoryboardSourceNode,
): VideoCanvasKeyElementCategory {
const draftValues = node.draftValues;
const explicitCategory = [
getDraftString(draftValues, "keyElementType"),
getDraftString(draftValues, "elementType"),
getDraftString(draftValues, "storyboardCategory"),
getDraftString(draftValues, "category"),
getDraftString(draftValues, "materialKind"),
getDraftString(draftValues, "roleType"),
]
.join(" ")
.toLowerCase();
const action = getDraftString(draftValues, "action").toLowerCase();
const kind = getString(node.kind).toLowerCase();
const searchable = `${getString(node.title)} ${getString(node.subtitle)}`;
if (/人物|角色|演员|character|person|people|actor|role/.test(explicitCategory)) {
return "character";
}
if (/场景|地点|环境|scene|location|environment/.test(explicitCategory)) {
return "scene";
}
if (/物品|道具|物件|object|item|prop/.test(explicitCategory)) {
return "object";
}
if (/其他|other/.test(explicitCategory)) return "other";
if (kind === "material-role" || action === "material_role") {
return "character";
}
if (/物品|道具|刀|剑|枪|杯|瓶|书/.test(searchable)) {
return "object";
}
if (/场景|地点|夜景|白昼|办公室|街市|宫殿/.test(searchable)) {
return "scene";
}
if (/角色|人物|男主|女主|少女|少年|男子|女士/.test(searchable)) {
return "character";
}
return "other";
}
export function resolveVideoCanvasViewMode(
canvasData: unknown,
): VideoCanvasViewMode {
if (!canvasData || typeof canvasData !== "object" || Array.isArray(canvasData)) {
return "workflow";
}
return (canvasData as Record<string, unknown>)[VIDEO_CANVAS_VIEW_MODE_KEY] ===
"storyboard"
? "storyboard"
: "workflow";
}
export function projectVideoCanvasStoryboard(
nodes: VideoCanvasStoryboardSourceNode[],
): VideoCanvasStoryboardProjection {
const projection: VideoCanvasStoryboardProjection = {
audio: [],
image: [],
keyElements: [],
text: [],
video: [],
};
for (const node of nodes) {
if (isVideoCanvasStoryboardCompositionNode(node)) continue;
const mediaType = resolveStoryboardMediaType(node);
if (!mediaType) continue;
const item: VideoCanvasStoryboardItem = {
aspectRatio:
getStoryboardParamString(node.draftValues, [
"aspectRatio",
"ratio",
]) || "16:9",
contentType: getStoryboardContentType(mediaType, node.draftValues),
durationSeconds: Math.max(
0,
Number(node.draftValues?.durationSeconds || 0) || 0,
),
generationStatus: getDraftString(
node.draftValues,
"generationStatus",
),
id: node.id,
imageCount: Math.max(
1,
Math.min(
4,
Math.round(
Number(
getStoryboardParams(node.draftValues).imageCount ||
node.draftValues?.imageCount ||
1,
) || 1,
),
),
),
imageQuality:
getStoryboardParamString(node.draftValues, [
"imageQuality",
"quality",
]) || "标准画质",
imageSize:
getStoryboardParamString(node.draftValues, [
"imageSize",
"resolution",
]) || "2K",
mediaType,
mediaUrl: getStoryboardMediaUrl(node, mediaType),
modelName: getFirstDraftString(node.draftValues, [
"modelName",
"modelDisplayName",
"displayName",
"providerName",
"model",
]),
modelRef: getStoryboardModelRef(node.draftValues),
prompt: getFirstDraftString(node.draftValues, [
"prompt",
"outputPrompt",
"positivePrompt",
"description",
]),
previewUrl: getStoryboardPreviewUrl(node, mediaType),
referenceImages: getStoryboardReferenceImages(node.draftValues),
resolutionLabel: getStoryboardResolutionLabel(node.draftValues),
styleLabel: getFirstDraftString(node.draftValues, [
"styleLabel",
"visualStyle",
"style",
]),
subtitle: getString(node.subtitle),
textContent: getStoryboardTextContent(node.draftValues),
title: getString(node.title) || "未命名节点",
videoCategory:
mediaType === "video"
? resolveStoryboardVideoCategory(node)
: undefined,
};
if (isKeyElement(node, mediaType)) {
projection.keyElements.push({
...item,
keyElementCategory: resolveKeyElementCategory(node),
});
continue;
}
projection[mediaType].push(item);
}
return projection;
}
export function filterVideoCanvasStoryboardVideos(
items: VideoCanvasStoryboardItem[],
filter: VideoCanvasStoryboardVideoFilter,
) {
if (filter === "all") return items;
return items.filter((item) => item.videoCategory === filter);
}
export function filterVideoCanvasKeyElements(
items: VideoCanvasStoryboardItem[],
filter: VideoCanvasKeyElementFilter,
) {
return filter === "all"
? items
: items.filter((item) => item.keyElementCategory === filter);
}

View File

@ -0,0 +1,90 @@
export type VideoCanvasStoryboardReferenceRequest = {
activation?: "locate" | "preview";
contentType?: string;
mediaType?: "audio" | "image" | "text" | "video";
mediaUrl?: string;
nodeId: string;
requestId: string;
textContent?: string;
title: string;
previewUrl?: string;
};
export function normalizeVideoCanvasStoryboardReference(
value: VideoCanvasStoryboardReferenceRequest,
): VideoCanvasStoryboardReferenceRequest | null {
const mediaUrl = String(value.mediaUrl || "").trim();
const nodeId = String(value.nodeId || "").trim();
const requestId = String(value.requestId || "").trim();
const textContent = String(value.textContent || "").trim();
const title = String(value.title || "").trim();
const previewUrl = String(value.previewUrl || "").trim();
if (!(nodeId && requestId && title)) return null;
const isTextReference =
value.mediaType === "text" ||
String(value.contentType || "").trim().toLowerCase().startsWith("text/");
if (isTextReference) {
if (!textContent) return null;
return {
activation: "locate",
contentType: "text/plain",
mediaType: "text",
mediaUrl: "",
nodeId,
requestId,
textContent,
title,
};
}
const activation =
value.activation === "locate" ||
value.mediaType === "audio" ||
value.mediaType === "video" ||
String(value.contentType || "").trim().toLowerCase().startsWith("audio/")
? "locate"
: "preview";
const isVideoReference =
value.mediaType === "video" ||
String(value.contentType || "").trim().toLowerCase().startsWith("video/");
if (
!mediaUrl &&
!(activation === "locate" && (textContent || isVideoReference))
) {
return null;
}
for (const candidateUrl of [mediaUrl, previewUrl].filter(Boolean)) {
try {
const parsedUrl = new URL(candidateUrl);
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
return null;
}
} catch {
return null;
}
}
const contentType = String(
value.contentType || (isVideoReference ? "video/mp4" : "image/jpeg"),
).trim();
const mediaType =
isVideoReference
? "video"
: value.mediaType === "audio" || contentType.startsWith("audio/")
? "audio"
: "image";
return {
activation,
contentType,
mediaType,
mediaUrl,
nodeId,
requestId,
...(textContent ? { textContent } : {}),
title,
...(previewUrl ? { previewUrl } : {}),
};
}

View File

@ -0,0 +1,324 @@
import type { VideoCanvasSubjectLibraryConfig } from "@/lib/video-canvas/config";
export const VIDEO_CANVAS_SUBJECT_SOURCE_TYPE = "video_canvas_subject";
export const VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE =
"video_canvas_subject_upload";
export const VIDEO_CANVAS_SUBJECT_VOICE_SOURCE_TYPE =
"video_canvas_subject_voice";
export type VideoCanvasSubjectMediaMode = "images" | "video";
export type VideoCanvasSubjectDraft = {
assetCount: number;
category: string;
description: string;
durationSeconds?: number | null;
mediaMode: VideoCanvasSubjectMediaMode;
name: string;
voiceId: string;
};
export type VideoCanvasSubjectRecord = {
description?: unknown;
displayName?: string | null;
id?: string;
metadata?: unknown;
originalUrl?: string | null;
previewUrl?: string | null;
thumbnailUrl?: string | null;
};
export type VideoCanvasAttachedSubject = {
category: string;
description: string;
id: string;
mediaMode: VideoCanvasSubjectMediaMode;
mediaUrls: string[];
name: string;
thumbnailUrl: string;
voiceId: string;
voiceName: string;
};
export type VideoCanvasSubjectProviderReference = {
category: string;
description: string;
name: string;
referenceImages: string[];
referenceVideo?: string;
subjectId: string;
voiceId?: string;
};
export function normalizeVideoCanvasSubjectText(value: unknown, maxLength: number) {
return String(value || "").trim().slice(0, Math.max(0, maxLength));
}
export function normalizeGeneratedSubjectDescription(
value: unknown,
maxLength: number,
) {
return normalizeVideoCanvasSubjectText(
String(value || "")
.replace(/^```(?:text|markdown|md)?\s*/i, "")
.replace(/\s*```$/i, "")
.replace(/^(?:主体描述|描述|description)\s*[::]\s*/i, "")
.replace(/^['"“”]+|['"“”]+$/g, ""),
maxLength,
);
}
export function resolveVideoCanvasSubjectCategory(
value: unknown,
config: VideoCanvasSubjectLibraryConfig,
) {
const category = String(value || "").trim();
if (config.labels.includes(category)) return category;
if (config.labels.includes(config.unselectedCategory)) {
return config.unselectedCategory;
}
return config.labels.includes("其他")
? "其他"
: config.labels.includes("无标签")
? "无标签"
: config.labels[0] || "其他";
}
export function validateVideoCanvasSubjectDraft(
draft: VideoCanvasSubjectDraft,
config: VideoCanvasSubjectLibraryConfig,
) {
if (!config.enabled) return "主体库已停用";
const name = String(draft.name || "").trim();
const description = String(draft.description || "").trim();
if (!name) return "请输入主体名称";
if (name.length > config.nameMaxLength) {
return `主体名称不能超过${config.nameMaxLength}字`;
}
if (!description) return "请输入主体描述";
if (description.length > config.descriptionMaxLength) {
return `主体描述不能超过${config.descriptionMaxLength}字`;
}
if (!config.labels.includes(draft.category)) return "请选择有效标签";
if (
!config.voices.some((voice) => voice.id === draft.voiceId) &&
!draft.voiceId.startsWith("custom:")
) {
return "请选择有效音色";
}
if (draft.mediaMode === "images") {
if (draft.assetCount < config.imageMinCount) {
return `请至少添加${config.imageMinCount}张角色图片`;
}
if (draft.assetCount > config.imageMaxCount) {
return `角色图片不能超过${config.imageMaxCount}张`;
}
return null;
}
if (draft.assetCount !== 1) return "请添加一个角色视频";
const duration = Number(draft.durationSeconds);
if (!Number.isFinite(duration)) return "无法读取视频时长";
if (
duration < config.videoMinDurationSeconds ||
duration > config.videoMaxDurationSeconds
) {
return `角色视频时长需为${config.videoMinDurationSeconds}-${config.videoMaxDurationSeconds}秒`;
}
return null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function readSubjectDescription(value: unknown) {
if (isRecord(value)) return String(value.description || "").trim();
return String(value || "").trim();
}
function readStringArray(value: unknown) {
return Array.isArray(value)
? Array.from(
new Set(value.map((item) => String(item || "").trim()).filter(Boolean)),
)
: [];
}
export function normalizeVideoCanvasAttachedSubject(
value: unknown,
): VideoCanvasAttachedSubject | null {
if (!isRecord(value)) return null;
const id = String(value.id || "").trim();
const name = String(value.name || value.displayName || "").trim();
const thumbnailUrl = String(
value.thumbnailUrl || value.previewUrl || value.originalUrl || "",
).trim();
if (!id || !name || !thumbnailUrl) return null;
const mediaUrls = readStringArray(value.mediaUrls);
const mediaMode: VideoCanvasSubjectMediaMode =
value.mediaMode === "video" ? "video" : "images";
return {
category: String(value.category || "其他").trim() || "其他",
description: readSubjectDescription(value.description),
id,
mediaMode,
mediaUrls: mediaUrls.length > 0 ? mediaUrls : [thumbnailUrl],
name,
thumbnailUrl,
voiceId: String(value.voiceId || "none").trim() || "none",
voiceName: String(value.voiceName || "无配音").trim() || "无配音",
};
}
export function toVideoCanvasAttachedSubject(
subject: VideoCanvasSubjectRecord,
): VideoCanvasAttachedSubject | null {
const metadata = isRecord(subject.metadata) ? subject.metadata : {};
return normalizeVideoCanvasAttachedSubject({
category: metadata.category,
description: readSubjectDescription(subject.description),
id: subject.id,
mediaMode: metadata.mediaMode,
mediaUrls: metadata.mediaUrls,
name: subject.displayName,
originalUrl: subject.originalUrl,
previewUrl: subject.previewUrl,
thumbnailUrl: subject.thumbnailUrl,
voiceId: metadata.voiceId,
voiceName: metadata.voiceName,
});
}
export function normalizeVideoCanvasAttachedSubjects(
value: unknown,
maxSubjects = 12,
) {
if (!Array.isArray(value)) return [];
const subjects: VideoCanvasAttachedSubject[] = [];
const seen = new Set<string>();
for (const item of value) {
const subject = normalizeVideoCanvasAttachedSubject(item);
if (!subject || seen.has(subject.id)) continue;
seen.add(subject.id);
subjects.push(subject);
if (subjects.length >= Math.max(1, Math.round(maxSubjects))) break;
}
return subjects;
}
export function getVideoCanvasAttachedSubjects(
draftValues: Record<string, unknown> | null | undefined,
maxSubjects = 12,
) {
if (!draftValues) return [];
const params = isRecord(draftValues.params) ? draftValues.params : {};
return normalizeVideoCanvasAttachedSubjects(
Array.isArray(draftValues.videoSubjects)
? draftValues.videoSubjects
: params.subjects,
maxSubjects,
);
}
export function addVideoCanvasAttachedSubject(
draftValues: Record<string, unknown> | null | undefined,
subject: VideoCanvasAttachedSubject,
maxSubjects = 4,
) {
const current = { ...(draftValues || {}) };
const params = isRecord(current.params) ? current.params : {};
const existing = getVideoCanvasAttachedSubjects(current, maxSubjects);
const subjects = normalizeVideoCanvasAttachedSubjects(
[...existing.filter((item) => item.id !== subject.id), subject],
maxSubjects,
);
return {
...current,
params: { ...params, subjects },
videoSubjects: subjects,
};
}
export function removeVideoCanvasAttachedSubject(
draftValues: Record<string, unknown> | null | undefined,
subjectId: string,
) {
const current = { ...(draftValues || {}) };
const params = isRecord(current.params) ? current.params : {};
const subjects = getVideoCanvasAttachedSubjects(current).filter(
(subject) => subject.id !== subjectId,
);
const { subjects: _removedSubjects, ...nextParams } = params;
const { videoSubjects: _removedVideoSubjects, ...nextDraftValues } = current;
return subjects.length > 0
? {
...nextDraftValues,
params: { ...nextParams, subjects },
videoSubjects: subjects,
}
: { ...nextDraftValues, params: nextParams };
}
export function supportsVideoCanvasSubjectUsage(
config: VideoCanvasSubjectLibraryConfig,
modelIdentity: unknown,
) {
if (!config.enabled || !config.videoUsage.enabled) return false;
const normalizedModel = String(modelIdentity || "").trim().toLowerCase();
if (!normalizedModel) return false;
return config.videoUsage.modelPatterns.some((pattern) =>
normalizedModel.includes(pattern.trim().toLowerCase()),
);
}
function renderSubjectPromptTemplate(
template: string,
subject: VideoCanvasAttachedSubject,
index: number,
) {
const values: Record<string, string> = {
category: subject.category,
description: subject.description,
index: String(index + 1),
name: subject.name,
voiceId: subject.voiceId,
voiceName: subject.voiceName,
};
return template.replace(/\{\{(category|description|index|name|voiceId|voiceName)\}\}/g, (
_match,
key: string,
) => values[key] || "");
}
export function composeVideoCanvasSubjectPrompt(
prompt: string,
subjects: VideoCanvasAttachedSubject[],
promptTemplate: string,
) {
return [
prompt.trim(),
...subjects.map((subject, index) =>
renderSubjectPromptTemplate(promptTemplate, subject, index).trim(),
),
]
.filter(Boolean)
.join(" ");
}
export function buildVideoCanvasSubjectProviderReferences(
subjects: VideoCanvasAttachedSubject[],
): VideoCanvasSubjectProviderReference[] {
return subjects.map((subject) => ({
category: subject.category,
description: subject.description,
name: subject.name,
referenceImages:
subject.mediaMode === "images" ? subject.mediaUrls.slice(0, 4) : [],
referenceVideo:
subject.mediaMode === "video" ? subject.mediaUrls[0] : undefined,
subjectId: subject.id,
voiceId: subject.voiceId === "none" ? undefined : subject.voiceId,
}));
}

View File

@ -0,0 +1,242 @@
export type SubtitleEraseMode = "smart" | "region";
export type SubtitleEraseRegion = {
height: number;
id: string;
width: number;
x: number;
y: number;
};
export type SubtitleEraseRegionHandle = "nw" | "ne" | "se" | "sw";
export type VideoCanvasSubtitleEraseConfig = {
blurRadius: number;
edgeThreshold: number;
enabled: boolean;
frameRate: number;
minBandHeightRatio: number;
model: string;
paddingRatio: number;
smartSearchEndRatio: number;
smartSearchStartRatio: number;
};
export const defaultVideoCanvasSubtitleEraseConfig: VideoCanvasSubtitleEraseConfig = {
blurRadius: 10,
edgeThreshold: 44,
enabled: true,
frameRate: 30,
minBandHeightRatio: 0.018,
model: "volcano-subtitle-eraser",
paddingRatio: 0.012,
smartSearchEndRatio: 0.96,
smartSearchStartRatio: 0.5,
};
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function finiteNumber(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number(value);
return Number.isFinite(parsed) ? clamp(parsed, min, max) : fallback;
}
export function normalizeVideoCanvasSubtitleEraseConfig(
value: unknown,
): VideoCanvasSubtitleEraseConfig {
const incoming = value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
const start = finiteNumber(
incoming.smartSearchStartRatio,
defaultVideoCanvasSubtitleEraseConfig.smartSearchStartRatio,
0,
0.94,
);
const end = finiteNumber(
incoming.smartSearchEndRatio,
defaultVideoCanvasSubtitleEraseConfig.smartSearchEndRatio,
start + 0.04,
1,
);
return {
blurRadius: finiteNumber(
incoming.blurRadius,
defaultVideoCanvasSubtitleEraseConfig.blurRadius,
0,
40,
),
edgeThreshold: finiteNumber(
incoming.edgeThreshold,
defaultVideoCanvasSubtitleEraseConfig.edgeThreshold,
1,
255,
),
enabled:
typeof incoming.enabled === "boolean"
? incoming.enabled
: defaultVideoCanvasSubtitleEraseConfig.enabled,
frameRate: Math.round(finiteNumber(
incoming.frameRate,
defaultVideoCanvasSubtitleEraseConfig.frameRate,
12,
60,
)),
minBandHeightRatio: finiteNumber(
incoming.minBandHeightRatio,
defaultVideoCanvasSubtitleEraseConfig.minBandHeightRatio,
0.005,
0.2,
),
model:
typeof incoming.model === "string" && incoming.model.trim()
? incoming.model.trim().slice(0, 100)
: defaultVideoCanvasSubtitleEraseConfig.model,
paddingRatio: finiteNumber(
incoming.paddingRatio,
defaultVideoCanvasSubtitleEraseConfig.paddingRatio,
0,
0.1,
),
smartSearchEndRatio: Number(Math.max(start + 0.04, end).toFixed(4)),
smartSearchStartRatio: start,
};
}
export function normalizeSubtitleEraseRegion(
region: SubtitleEraseRegion,
): SubtitleEraseRegion {
const x = clamp(Number(region.x) || 0, 0, 1);
const y = clamp(Number(region.y) || 0, 0, 1);
const normalizedWidth = clamp(Number(region.width) || 0, 0, 1 - x);
const normalizedHeight = clamp(Number(region.height) || 0, 0, 1 - y);
return {
height: Number(normalizedHeight.toFixed(6)),
id: region.id,
width: Number(normalizedWidth.toFixed(6)),
x: Number(x.toFixed(6)),
y: Number(y.toFixed(6)),
};
}
export function moveSubtitleEraseRegion(
region: SubtitleEraseRegion,
deltaX: number,
deltaY: number,
) {
const normalized = normalizeSubtitleEraseRegion(region);
return {
...normalized,
x: clamp(normalized.x + deltaX, 0, 1 - normalized.width),
y: clamp(normalized.y + deltaY, 0, 1 - normalized.height),
};
}
export function resizeSubtitleEraseRegion(
region: SubtitleEraseRegion,
handle: SubtitleEraseRegionHandle,
pointX: number,
pointY: number,
minimumSize = 0.015,
) {
const normalized = normalizeSubtitleEraseRegion(region);
let left = normalized.x;
let top = normalized.y;
let right = normalized.x + normalized.width;
let bottom = normalized.y + normalized.height;
const x = clamp(pointX, 0, 1);
const y = clamp(pointY, 0, 1);
if (handle.includes("w")) left = Math.min(x, right - minimumSize);
if (handle.includes("e")) right = Math.max(x, left + minimumSize);
if (handle.includes("n")) top = Math.min(y, bottom - minimumSize);
if (handle.includes("s")) bottom = Math.max(y, top + minimumSize);
return normalizeSubtitleEraseRegion({
height: bottom - top,
id: normalized.id,
width: right - left,
x: left,
y: top,
});
}
function pixelLuma(data: Uint8ClampedArray, index: number) {
return data[index] * 0.299 + data[index + 1] * 0.587 + data[index + 2] * 0.114;
}
export function detectSubtitleEraseRegions(
imageData: ImageData,
configValue: VideoCanvasSubtitleEraseConfig,
): SubtitleEraseRegion[] {
const config = normalizeVideoCanvasSubtitleEraseConfig(configValue);
const { data, height, width } = imageData;
if (width < 8 || height < 8) return [];
const startRow = Math.floor(height * config.smartSearchStartRatio);
const endRow = Math.max(startRow + 1, Math.ceil(height * config.smartSearchEndRatio));
const rowScores = Array.from({ length: endRow - startRow }, () => 0);
const stride = width > 900 ? 3 : 2;
for (let y = startRow; y < Math.min(height - 1, endRow); y += 1) {
let edges = 0;
let samples = 0;
for (let x = 1; x < width - 1; x += stride) {
const index = (y * width + x) * 4;
const horizontal = Math.abs(pixelLuma(data, index - 4) - pixelLuma(data, index + 4));
const vertical = Math.abs(pixelLuma(data, index) - pixelLuma(data, index + width * 4));
if (Math.max(horizontal, vertical) >= config.edgeThreshold) edges += 1;
samples += 1;
}
rowScores[y - startRow] = samples > 0 ? edges / samples : 0;
}
const smoothed = rowScores.map((_, index) => {
let total = 0;
let count = 0;
for (let offset = -2; offset <= 2; offset += 1) {
const score = rowScores[index + offset];
if (score === undefined) continue;
total += score;
count += 1;
}
return count > 0 ? total / count : 0;
});
const mean = smoothed.reduce((total, score) => total + score, 0) / smoothed.length;
const variance = smoothed.reduce((total, score) => total + (score - mean) ** 2, 0) / smoothed.length;
const activeThreshold = Math.max(0.025, mean + Math.sqrt(variance) * 0.55);
const minimumRows = Math.max(2, Math.round(height * config.minBandHeightRatio));
const groups: Array<{ end: number; peak: number; start: number }> = [];
let group: { end: number; peak: number; start: number } | null = null;
for (let index = 0; index < smoothed.length; index += 1) {
const score = smoothed[index];
if (score >= activeThreshold) {
if (!group) group = { end: index, peak: score, start: index };
group.end = index;
group.peak = Math.max(group.peak, score);
} else if (group) {
if (group.end - group.start + 1 >= minimumRows) groups.push(group);
group = null;
}
}
if (group && group.end - group.start + 1 >= minimumRows) groups.push(group);
return groups
.sort((left, right) => right.peak - left.peak)
.slice(0, 3)
.sort((left, right) => left.start - right.start)
.map((band, index) => {
const padding = Math.round(height * config.paddingRatio);
const top = clamp(startRow + band.start - padding, 0, height - 1);
const bottom = clamp(startRow + band.end + padding + 1, top + 1, height);
return {
height: (bottom - top) / height,
id: `smart-subtitle-${index + 1}`,
width: 0.94,
x: 0.03,
y: top / height,
};
});
}

View File

@ -0,0 +1,426 @@
export type VideoCanvasToolboxCategory = {
enabled: boolean;
id: string;
label: string;
};
export type VideoCanvasToolboxTemplate = {
categoryId: string;
coverUrl: string;
enabled: boolean;
id: string;
name: string;
prompt: string;
referenceUrl: string;
resultUrl: string;
};
export type VideoCanvasToolboxLayout = {
groupHeight: number;
groupWidth: number;
placementOffsetX: number;
placementOffsetY: number;
prompt: { height: number; width: number; x: number; y: number };
reference: { height: number; width: number; x: number; y: number };
result: { height: number; width: number; x: number; y: number };
};
export type VideoCanvasToolboxConfig = {
categories: VideoCanvasToolboxCategory[];
enabled: boolean;
helpText: string;
helpUrl: string;
layout: VideoCanvasToolboxLayout;
templates: VideoCanvasToolboxTemplate[];
};
const DEFAULT_RESULT_VIDEO_URL =
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/script-video-reference.mp4";
function sourceUrl(url: string) {
return url.split("?")[0] || url;
}
function template(
id: string,
categoryId: string,
name: string,
coverUrl: string,
prompt: string,
): VideoCanvasToolboxTemplate {
return {
categoryId,
coverUrl,
enabled: true,
id,
name,
prompt,
referenceUrl: sourceUrl(coverUrl),
resultUrl: DEFAULT_RESULT_VIDEO_URL,
};
}
const MY_TOOLBOX_TEMPLATES: VideoCanvasToolboxTemplate[] = [
template(
"left-arc-slide-primary",
"my-toolbox",
"【预设】左弧滑行",
"https://libtv-res.liblib.art/upload-images/be52904b1a974e01af26ccd4fe36b4c0/794154946370bcfd0574b5beb68c95f4d4d57c88.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"电影级人物环绕镜头。镜头从人物正前方开始,沿左侧平滑弧线移动到侧后方;两位人物持续转头追随镜头并保持眼神交流。保持人物身份、服装、木质室内环境和高级时装质感稳定,动作连贯,面部清晰,不发生肢体变形或背景跳变。",
),
template(
"phone-pop-product",
"my-toolbox",
"【预设】电商手机弹出效果",
"https://libtv-res.liblib.art/sd-gen-save-img/genius_playground/image/4205e8f1b9d5449c9c134a55ab23e475/3f86729b953d5e3f78cf314a20541ef7195786072400aa6af9d33a2dc2405cf6.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"手机产品从画面中心快速弹出并轻微旋转,在高光扫过后稳定定格。保持产品外形、品牌细节和材质一致,背景干净,镜头节奏利落。",
),
template(
"coffee-cup-entry",
"my-toolbox",
"【预设】咖啡杯出场",
"https://libtv-res.liblib.art/upload-images/beda8103d1a8414aa15aa0eb1db95a07/c8c9444584348de6209bee45d9f95a0c2c69932f.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"咖啡杯从桌面侧方平滑滑入,杯口蒸汽缓慢上升,柔和暖光勾勒杯身和液面,最终停在画面中心形成品牌静物镜头。",
),
template(
"orbit-360-showcase",
"my-toolbox",
"【预设】360旋转展示",
"https://libtv-res.liblib.art/upload-images/be52904b1a974e01af26ccd4fe36b4c0/498ebfb6bbf47d17fb8d5449b8eb8029df39cc9f.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"镜头以恒定半径围绕主体完成一圈稳定环绕,速度均匀,主体始终居中,完整展示各个角度并保持材质、结构和光照连续。",
),
template(
"robot-arm-pov-primary",
"my-toolbox",
"【预设】机械臂视角",
"https://libtv-res.liblib.art/upload-images/be52904b1a974e01af26ccd4fe36b4c0/4d251222f8ebdc2cf634cc589fc909c8e69b5185.jpg?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"第一人称镜头沿机械臂运动路径穿行,在金属结构和产品之间形成明显视差,运动精确流畅,保持工业材质和空间结构稳定。",
),
template(
"live-2d-character",
"my-toolbox",
"【预设】Live 2D",
"https://libtv-res.liblib.art/upload-images/be52904b1a974e01af26ccd4fe36b4c0/882cc4bc76db85989fa961fed616cb3ebe201a0c.jpg?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"将单张角色图转为轻量角色动画:自然眨眼、轻微微笑、呼吸和小幅身体摆动。严格保持五官、发型、服装和画风一致。",
),
template(
"pupil-zoom",
"my-toolbox",
"【预设】瞳孔拉近",
"https://libtv-res.liblib.art/upload-images/be52904b1a974e01af26ccd4fe36b4c0/59a02980eda985744952c15bcfd8697bffab14aa.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"镜头从人物近景快速推进到瞳孔极特写,在眼睛反光中显现下一场景并完成自然转场,保持眼部结构和人物身份稳定。",
),
template(
"bird-dissolve",
"my-toolbox",
"【预设】飞鸟解体",
"https://libtv-res.liblib.art/upload-images/beda8103d1a8414aa15aa0eb1db95a07/6676b12a15100018ca7648c85287e007ceb9f9f2.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"主体从边缘开始分解为成群飞鸟,鸟群沿运动方向散开后重新聚合为完整主体,轮廓转换清晰且背景连续。",
),
template(
"break-out-of-box",
"my-toolbox",
"【预设】破盒而出",
"https://libtv-res.liblib.art/sd-gen-save-img/genius_playground/image/beda8103d1a8414aa15aa0eb1db95a07/84979fa89cac130f874f49e7286a51e13fbccc95a19657ba13fcd7287fedb50a.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"产品冲破包装盒进入画面,碎片向外飞散并快速减速,主体在中心稳定定格,保持包装、产品和品牌细节一致。",
),
template(
"product-hero-entry",
"my-toolbox",
"【预设】商品震撼登场",
"https://libtv-res.liblib.art/upload-images/be52904b1a974e01af26ccd4fe36b4c0/b43d4ec927a2766382d82e95c4020a5e7c3b08aa.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"商品伴随冲击波和局部高光快速进入画面,镜头轻微推进后定格,突出产品轮廓、材质和主视觉信息。",
),
template(
"right-arc-slide",
"my-toolbox",
"【预设】右弧滑行",
"https://libtv-res.liblib.art/upload-images/be52904b1a974e01af26ccd4fe36b4c0/794154946370bcfd0574b5beb68c95f4d4d57c88.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"镜头从人物正前方沿右侧做平滑大弧线运动,人物目光持续追随镜头,保持身份、服装、场景和光线稳定。",
),
template(
"left-arc-slide-secondary",
"my-toolbox",
"【预设】左弧滑行",
"https://libtv-res.liblib.art/upload-images/be52904b1a974e01af26ccd4fe36b4c0/794154946370bcfd0574b5beb68c95f4d4d57c88.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"镜头从人物正前方沿左侧做平滑大弧线运动,人物自然转头保持眼神交流,背景空间和人物外观连续。",
),
template(
"inverted-space",
"my-toolbox",
"【预设】颠倒空间",
"https://libtv-res.liblib.art/upload-images/be52904b1a974e01af26ccd4fe36b4c0/8852de28b54eb90134a11fa526426babdd85b1d3.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"场景围绕主体逐步翻转至上下颠倒,主体保持重心和朝向稳定,空间旋转连续,避免结构断裂和画面跳变。",
),
template(
"anti-gravity-float",
"my-toolbox",
"【预设】反重力漂浮",
"https://libtv-res.liblib.art/sd-gen-save-img/genius_playground/image/beda8103d1a8414aa15aa0eb1db95a07/abc353b7b28311027d4bf4427531f3025cb1a5161f929b7d414e44ebb9c3d9b8.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"主体和周围小物件缓慢脱离地面悬浮,运动带有轻微惯性和旋转,光影及空间关系保持真实连续。",
),
template(
"particle-dissolve",
"my-toolbox",
"【预设】粒子融解",
"https://libtv-res.liblib.art/sd-gen-save-img/genius_playground/image/beda8103d1a8414aa15aa0eb1db95a07/95be6a97f91ab6c16081f260cbaaa1df3d823a5ec037b8cc6a970ef05cbe4001.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"主体由外向内融解为细小发光粒子,粒子顺风扩散并逐渐消失,保留主体轮廓演变和背景连续性。",
),
template(
"travel-zoom-in",
"my-toolbox",
"【预设】旅拍转场 zoom in",
"https://libtv-res.liblib.art/sd-gen-save-img/genius_playground/image/beda8103d1a8414aa15aa0eb1db95a07/71e6713e72ade5531b4eb131e1faf18b1cf27737e9a5fed18378a429503b8034.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"镜头快速推近前景遮挡物,在遮挡充满画面时切换到新地点并继续向前运动,形成连贯旅拍转场。",
),
template(
"travel-zoom-out",
"my-toolbox",
"【预设】旅拍转场 zoom out",
"https://libtv-res.liblib.art/sd-gen-save-img/genius_playground/image/beda8103d1a8414aa15aa0eb1db95a07/29c1f23b7d8bfa35267e3ddc11f094e63a1c6d5089f883e1b2c9134dd36ef10a.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"镜头从新场景局部快速拉远,在拉远过程中完成地点切换,速度曲线顺滑并保持主体位置衔接。",
),
template(
"travel-rotate-right",
"my-toolbox",
"【预设】旅拍转场 向右旋转",
"https://libtv-res.liblib.art/sd-gen-save-img/genius_playground/image/beda8103d1a8414aa15aa0eb1db95a07/b49297c4637a024c010501407619e90e5d2c08c9c6600fb2ce52878e1daf71f0.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"手持镜头快速向右旋转,利用运动模糊在旋转中点切换地点,并在新场景逐渐稳定。",
),
template(
"travel-rotate-left",
"my-toolbox",
"【预设】旅拍转场 向左旋转",
"https://libtv-res.liblib.art/sd-gen-save-img/genius_playground/image/beda8103d1a8414aa15aa0eb1db95a07/005550129798804a81e92fa8382cdb4f0e77615dc64491358ae166380dc8839e.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"手持镜头快速向左旋转,利用运动模糊自然切换地点,结束时回到稳定构图并保持主体连续。",
),
template(
"travel-growth",
"my-toolbox",
"【预设】旅拍转场 生长",
"https://libtv-res.liblib.art/sd-gen-save-img/genius_playground/image/beda8103d1a8414aa15aa0eb1db95a07/8ad3b1b28b7406aadcfbacf827b7b1ce6b5570ee1434c187091db52b1ff78dd8.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"植物从前景快速生长并覆盖画面,在叶片遮挡中切换到下一地点,随后叶片展开露出完整新场景。",
),
template(
"hero-perspective",
"my-toolbox",
"【预设】英雄视角",
"https://libtv-res.liblib.art/upload-images/be52904b1a974e01af26ccd4fe36b4c0/9cf12958ec85cc870011c017834fe894c13932ad.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"低机位广角镜头缓慢推近主体,强化前景透视和人物气势,保持面部、服装和环境结构稳定。",
),
template(
"ai-fashion-model",
"my-toolbox",
"【预设】AI模特服饰动态展示",
"https://libtv-res.liblib.art/upload-images/9c9ca63a3fd948e8abb9ab96ade97321/a676b7a777d24d0b2bbdc7509109546c9ae3d2d7.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"模特自然转身并展示服装正面、侧面和背面,镜头轻微环绕,严格保持服饰版型、纹理、颜色和人物身份一致。",
),
template(
"robot-arm-pov-secondary",
"my-toolbox",
"【预设】机械臂视角",
"https://libtv-res.liblib.art/upload-images/beda8103d1a8414aa15aa0eb1db95a07/bf72676a252b5397d06fd6b6734f3ede26a1d121.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"镜头模拟机械臂的精确移动轨迹,在主体周围做平滑推拉和转向,突出工业空间、材质细节与机械节奏。",
),
template(
"master-storyboard-dark",
"my-toolbox",
"【预设】大师分镜九宫格-经典暗调",
"https://libtv-res.liblib.art/sd-gen-save-img/genius_playground/image/9c9ca63a3fd948e8abb9ab96ade97321/082931e3f634ac1b7e90f79fa9d8e66b12a596d0fbe7d1d4ce9ff04acdba8203.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"根据同一角色和场景生成严格 3x3 电影分镜,覆盖远景、中景、近景、俯拍、仰拍和细节镜头,统一经典暗调光影并保持人物一致。",
),
template(
"interior-renovation-preview",
"my-toolbox",
"【预设】AI室内装修效果预览",
"https://libtv-res.liblib.art/sd-gen-save-img/genius_playground/image/9c9ca63a3fd948e8abb9ab96ade97321/fba8fa5f7e53e796c26216a91601bca675051c01b554dc0c8b0dfa25ea94d3da.png?x-oss-process=image/resize,w_280,m_lfit/format,webp/ignore-error,1",
"保持房间结构、门窗和透视不变,平滑展示装修前后材质、家具和灯光变化,形成真实连贯的室内改造预览。",
),
];
const CLASSIC_SCENE_TEMPLATES: VideoCanvasToolboxTemplate[] = [
template(
"king-of-comedy-gunfight",
"stephen-chow",
"喜剧之王-枪战",
"https://libtv-res.liblib.art/upload-images/70a305c50c704a778db114468830617b/aae0d0cafc4fd2e682e72b15d2334ddeebb6871c.webp",
"复刻室内喜剧枪战名场面的节奏与走位,使用快速反应镜头、夸张停顿和清晰动作衔接,保持人物身份及空间连续。",
),
template(
"king-of-comedy-mystery-tycoon",
"stephen-chow",
"喜剧之王-神秘富豪",
"https://libtv-res.liblib.art/upload-images/70a305c50c704a778db114468830617b/e09663b853f2273d628526168663cbef93a1d785.webp",
"以神秘富豪出场为核心,镜头先隐藏身份再逐步揭示人物,利用近景反应和喜剧停顿强化反差。",
),
template(
"god-of-cookery-contest",
"stephen-chow",
"食神-食神大赛",
"https://libtv-res.liblib.art/upload-images/70a305c50c704a778db114468830617b/372645626796f75c4dd9574111e61c9c0497054e.webp",
"复刻烹饪大赛的高强度节奏,组合食材特写、快速操作、评委反应和成品揭晓镜头,保持场景和人物连续。",
),
template(
"god-of-cookery-beef-ball",
"stephen-chow",
"食神-撒尿牛丸",
"https://libtv-res.liblib.art/upload-images/70a305c50c704a778db114468830617b/7f0a71b7fe580c0c04c3ddd1ad22608fde42b915.webp",
"围绕弹性牛丸的夸张展示设计喜剧镜头,使用高速动作、反应特写和定格强调,保持食物外观和场景一致。",
),
template(
"cj7-fix-fan",
"stephen-chow",
"七仔-修风扇",
"https://libtv-res.liblib.art/upload-images/70a305c50c704a778db114468830617b/b2249423467d4a9a18c03c4d4aff7927e696fcc3.webp",
"以角色修理风扇的连续动作为主线,结合道具细节、人物反应和轻喜剧停顿,保持角色造型及室内空间稳定。",
),
];
export const defaultVideoCanvasToolboxConfig: VideoCanvasToolboxConfig = {
categories: [
{ enabled: true, id: "my-toolbox", label: "我的工具箱" },
{ enabled: true, id: "stephen-chow", label: "周星驰经典名场面" },
],
enabled: true,
helpText: "使用工具箱模板加速创作,快速构建你的专属工具箱。",
helpUrl:
"https://liblibai.feishu.cn/wiki/Loxfw6XHziYRk0kKzdjcFfp9nhb#share-EGsydYnauomw6rxz7SAc313Bnfh",
layout: {
groupHeight: 658,
groupWidth: 1493,
placementOffsetX: -175,
placementOffsetY: -175,
prompt: { height: 200, width: 350, x: 525, y: 45 },
reference: { height: 525, width: 350, x: 40, y: 40 },
result: { height: 525, width: 350, x: 1104, y: 93 },
},
templates: [...MY_TOOLBOX_TEMPLATES, ...CLASSIC_SCENE_TEMPLATES],
};
export function getVideoCanvasToolboxGroupOrigin({
layout,
viewport,
viewportHeight,
viewportWidth,
}: {
layout: VideoCanvasToolboxLayout;
viewport: { x: number; y: number; zoom: number };
viewportHeight: number;
viewportWidth: number;
}) {
const zoom = Number.isFinite(viewport.zoom) && viewport.zoom > 0 ? viewport.zoom : 1;
return {
x: (viewportWidth / 2 + layout.placementOffsetX - viewport.x) / zoom,
y: (viewportHeight / 2 + layout.placementOffsetY - viewport.y) / zoom,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function boundedNumber(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
}
function normalizeLayoutItem(
value: unknown,
fallback: VideoCanvasToolboxLayout["reference"],
) {
const incoming = isRecord(value) ? value : {};
return {
height: boundedNumber(incoming.height, fallback.height, 40, 4000),
width: boundedNumber(incoming.width, fallback.width, 40, 4000),
x: boundedNumber(incoming.x, fallback.x, -10_000, 10_000),
y: boundedNumber(incoming.y, fallback.y, -10_000, 10_000),
};
}
export function normalizeVideoCanvasToolboxConfig(
value: unknown,
): VideoCanvasToolboxConfig {
if (!isRecord(value)) return defaultVideoCanvasToolboxConfig;
const incomingCategories = Array.isArray(value.categories) ? value.categories : [];
const categories = incomingCategories
.slice(0, 20)
.map((item, index) => {
const incoming = isRecord(item) ? item : {};
return {
enabled: incoming.enabled !== false,
id: String(incoming.id || `toolbox-category-${index + 1}`).trim().slice(0, 100),
label: String(incoming.label || `分类 ${index + 1}`).trim().slice(0, 100),
};
})
.filter((item) => item.id && item.label);
const resolvedCategories = categories.length
? categories
: defaultVideoCanvasToolboxConfig.categories;
const categoryIds = new Set(resolvedCategories.map((item) => item.id));
const firstCategoryId = resolvedCategories[0]?.id || "my-toolbox";
const incomingTemplates = Array.isArray(value.templates) ? value.templates : [];
const templates = incomingTemplates
.slice(0, 200)
.map((item, index) => {
const incoming = isRecord(item) ? item : {};
const coverUrl = String(incoming.coverUrl || "").trim().slice(0, 4000);
const categoryId = String(incoming.categoryId || firstCategoryId).trim();
return {
categoryId: categoryIds.has(categoryId) ? categoryId : firstCategoryId,
coverUrl,
enabled: incoming.enabled !== false,
id: String(incoming.id || `toolbox-template-${index + 1}`).trim().slice(0, 120),
name: String(incoming.name || `模板 ${index + 1}`).trim().slice(0, 200),
prompt: String(incoming.prompt || "").trim().slice(0, 20_000),
referenceUrl: String(incoming.referenceUrl || sourceUrl(coverUrl))
.trim()
.slice(0, 4000),
resultUrl: String(incoming.resultUrl || DEFAULT_RESULT_VIDEO_URL)
.trim()
.slice(0, 4000),
};
})
.filter((item) => item.id && item.name && item.coverUrl);
const incomingLayout = isRecord(value.layout) ? value.layout : {};
const fallbackLayout = defaultVideoCanvasToolboxConfig.layout;
return {
categories: resolvedCategories,
enabled: value.enabled !== false,
helpText:
String(value.helpText || "").trim().slice(0, 500) ||
defaultVideoCanvasToolboxConfig.helpText,
helpUrl:
String(value.helpUrl || "").trim().slice(0, 2000) ||
defaultVideoCanvasToolboxConfig.helpUrl,
layout: {
groupHeight: boundedNumber(
incomingLayout.groupHeight,
fallbackLayout.groupHeight,
100,
8000,
),
groupWidth: boundedNumber(
incomingLayout.groupWidth,
fallbackLayout.groupWidth,
100,
8000,
),
placementOffsetX: boundedNumber(
incomingLayout.placementOffsetX,
fallbackLayout.placementOffsetX,
-4000,
4000,
),
placementOffsetY: boundedNumber(
incomingLayout.placementOffsetY,
fallbackLayout.placementOffsetY,
-4000,
4000,
),
prompt: normalizeLayoutItem(incomingLayout.prompt, fallbackLayout.prompt),
reference: normalizeLayoutItem(incomingLayout.reference, fallbackLayout.reference),
result: normalizeLayoutItem(incomingLayout.result, fallbackLayout.result),
},
templates: templates.length ? templates : defaultVideoCanvasToolboxConfig.templates,
};
}

View File

@ -0,0 +1,61 @@
export type VideoCanvasTutorialSupportConfig = {
customerServiceUrl: string;
officialAccountQrImageUrl: string;
salesQrImageUrl: string;
tutorialUrl: string;
};
export type VideoCanvasTutorialSupportQrKind = "official" | "sales";
export const defaultVideoCanvasTutorialSupportConfig: VideoCanvasTutorialSupportConfig = {
customerServiceUrl: "",
officialAccountQrImageUrl: "",
salesQrImageUrl: "",
tutorialUrl: "https://resonate.feishu.cn/wiki/Loxfw6XHziYRk0kKzdjcFfp9nhb",
};
function normalizeUrl(value: unknown, fallback: string) {
const candidate = String(value || "").trim().slice(0, 2048);
if (!candidate) return fallback;
if (candidate.startsWith("/") && !candidate.startsWith("//")) return candidate;
try {
const url = new URL(candidate);
return url.protocol === "http:" || url.protocol === "https:"
? candidate
: fallback;
} catch {
return fallback;
}
}
export function normalizeVideoCanvasTutorialSupportConfig(
value: unknown,
): VideoCanvasTutorialSupportConfig {
const incoming =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
return {
customerServiceUrl: normalizeUrl(incoming.customerServiceUrl, ""),
officialAccountQrImageUrl: normalizeUrl(
incoming.officialAccountQrImageUrl,
"",
),
salesQrImageUrl: normalizeUrl(incoming.salesQrImageUrl, ""),
tutorialUrl: normalizeUrl(
incoming.tutorialUrl,
defaultVideoCanvasTutorialSupportConfig.tutorialUrl,
),
};
}
export function getVideoCanvasTutorialSupportQr(
config: VideoCanvasTutorialSupportConfig,
kind: VideoCanvasTutorialSupportQrKind,
) {
return kind === "sales"
? { alt: "联系销售", url: config.salesQrImageUrl }
: { alt: "关注公众号", url: config.officialAccountQrImageUrl };
}

View File

@ -0,0 +1,51 @@
import type { VideoCanvasNodeCreationConfig } from "@/lib/video-canvas/node-creation";
export const VIDEO_CANVAS_RESOURCE_UPLOAD_PURPOSE =
"video-canvas-add-resource";
export const VIDEO_CANVAS_RESOURCE_UPLOAD_SOURCE_TYPE =
"video_canvas_upload";
export type VideoCanvasUploadedResourceMediaType =
| "audio"
| "image"
| "video";
export type VideoCanvasUploadedResourceValidation =
| { mediaType: VideoCanvasUploadedResourceMediaType; valid: true }
| { error: string; valid: false };
export function getVideoCanvasUploadedResourceMediaType(
contentType: string,
): VideoCanvasUploadedResourceMediaType | null {
const normalized = contentType.trim().toLowerCase();
if (normalized.startsWith("image/")) return "image";
if (normalized.startsWith("video/")) return "video";
if (normalized.startsWith("audio/")) return "audio";
return null;
}
export function validateVideoCanvasUploadedResource(
file: { size: number; type: string },
config: VideoCanvasNodeCreationConfig["upload"],
): VideoCanvasUploadedResourceValidation {
const mediaType = getVideoCanvasUploadedResourceMediaType(file.type);
if (!mediaType) {
return { error: "仅支持图片、视频或音频文件", valid: false };
}
if (file.size > config.maxFileSizeMb * 1024 * 1024) {
return {
error: `单个文件不能超过 ${config.maxFileSizeMb}MB`,
valid: false,
};
}
return { mediaType, valid: true };
}
export function getVideoCanvasUploadedResourceUrl(payload: unknown) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return "";
}
const record = payload as Record<string, unknown>;
return String(record.url || record.originalUrl || "").trim();
}

View File

@ -0,0 +1,51 @@
export type VideoCanvasAdvancedSettings = {
autoValidateMaterial: boolean;
searchEnabled: boolean;
};
type VideoCanvasAdvancedSettingsDefaults = {
defaultAutoValidateMaterial: boolean;
defaultSearchEnabled: boolean;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function resolveVideoCanvasAdvancedSettings(
value: unknown,
defaults: VideoCanvasAdvancedSettingsDefaults,
): VideoCanvasAdvancedSettings {
const incoming = isRecord(value) ? value : {};
return {
autoValidateMaterial:
typeof incoming.autoValidateMaterial === "boolean"
? incoming.autoValidateMaterial
: typeof incoming.auto_validate_material === "boolean"
? incoming.auto_validate_material
: defaults.defaultAutoValidateMaterial,
searchEnabled:
typeof incoming.searchEnabled === "boolean"
? incoming.searchEnabled
: typeof incoming.search_enabled === "boolean"
? incoming.search_enabled
: typeof incoming.searchable === "boolean"
? incoming.searchable
: defaults.defaultSearchEnabled,
};
}
export function mergeVideoCanvasAdvancedSettings(
paramsValue: unknown,
updates: Partial<VideoCanvasAdvancedSettings>,
): Record<string, unknown> {
const params = isRecord(paramsValue) ? paramsValue : {};
const settings = isRecord(params.settings) ? params.settings : {};
return {
...params,
settings: {
...settings,
...updates,
},
};
}

View File

@ -0,0 +1,106 @@
export const DEFAULT_VIDEO_ANALYSIS_COST = 2;
export const VIDEO_STORY_NODE_HEIGHT = 400;
export const VIDEO_STORY_NODE_WIDTH = 800;
export const VIDEO_STORY_SOURCE_GAP = 94;
export type VideoAnalysisNodeBounds = {
height: number;
id?: string;
width: number;
x: number;
y: number;
};
type VideoAnalysisTimelineRow = {
duration: string;
endTime: string;
startTime: string;
};
export function normalizeVideoAnalysisTimeline<T extends VideoAnalysisTimelineRow>(
rows: T[],
totalDurationSeconds?: number | null,
) {
if (
rows.length === 0 ||
!Number.isFinite(totalDurationSeconds) ||
Number(totalDurationSeconds) <= 0
) {
return rows;
}
const total = Number(totalDurationSeconds);
const parsedTimeline = rows.map((row) => ({
start: Number.parseFloat(row.startTime),
end: Number.parseFloat(row.endTime),
}));
const timelineIsValid = parsedTimeline.every((item, index) => {
const previous = parsedTimeline[index - 1];
return (
Number.isFinite(item.start) &&
Number.isFinite(item.end) &&
item.start >= 0 &&
item.end > item.start &&
item.end <= total + 0.05 &&
(!previous || item.start >= previous.end - 0.05)
);
});
const coversDuration =
timelineIsValid &&
Math.abs((parsedTimeline[0]?.start || 0) - 0) <= 0.05 &&
Math.abs((parsedTimeline.at(-1)?.end || 0) - total) <= 0.05;
const formatTime = (number: number) => String(Number(number.toFixed(2)));
return rows.map((row, index) => {
const start = coversDuration
? Math.max(0, parsedTimeline[index]!.start)
: (total * index) / rows.length;
const end = coversDuration
? Math.min(total, parsedTimeline[index]!.end)
: (total * (index + 1)) / rows.length;
return {
...row,
startTime: formatTime(start),
endTime: formatTime(end),
duration: formatTime(Math.max(0, end - start)),
};
});
}
function overlapsWithMargin(
candidate: VideoAnalysisNodeBounds,
existing: VideoAnalysisNodeBounds,
margin: number,
) {
return !(
candidate.x + candidate.width + margin <= existing.x ||
existing.x + existing.width + margin <= candidate.x ||
candidate.y + candidate.height + margin <= existing.y ||
existing.y + existing.height + margin <= candidate.y
);
}
export function getVideoStoryPlacement(
source: VideoAnalysisNodeBounds,
existingNodes: VideoAnalysisNodeBounds[],
) {
const baseY = Math.round(source.y + source.height + VIDEO_STORY_SOURCE_GAP);
const horizontalOffsets = [0, 450, -450, 900, -900];
const occupied = existingNodes.filter((node) => node.id !== source.id);
for (let row = 0; row < 12; row += 1) {
for (const offset of horizontalOffsets) {
const candidate = {
height: VIDEO_STORY_NODE_HEIGHT,
width: VIDEO_STORY_NODE_WIDTH,
x: Math.round(source.x + offset),
y: baseY + row * (VIDEO_STORY_NODE_HEIGHT + VIDEO_STORY_SOURCE_GAP),
};
if (!occupied.some((node) => overlapsWithMargin(candidate, node, 24))) {
return { x: candidate.x, y: candidate.y };
}
}
}
return { x: Math.round(source.x), y: baseY };
}

View File

@ -0,0 +1,252 @@
export type VideoAudioSplitMode = "voice" | "background" | "audio-video";
export type VideoCanvasAudioSeparationConfig = {
backgroundGain: number;
enabled: boolean;
frameRate: number;
model: string;
voiceGain: number;
};
export const defaultVideoCanvasAudioSeparationConfig: VideoCanvasAudioSeparationConfig = {
backgroundGain: 1.2,
enabled: true,
frameRate: 30,
model: "center-channel-v1",
voiceGain: 1,
};
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function finiteNumber(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number(value);
return Number.isFinite(parsed) ? clamp(parsed, min, max) : fallback;
}
export function normalizeVideoCanvasAudioSeparationConfig(
value: unknown,
): VideoCanvasAudioSeparationConfig {
const incoming = value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
return {
backgroundGain: finiteNumber(
incoming.backgroundGain,
defaultVideoCanvasAudioSeparationConfig.backgroundGain,
0,
4,
),
enabled:
typeof incoming.enabled === "boolean"
? incoming.enabled
: defaultVideoCanvasAudioSeparationConfig.enabled,
frameRate: Math.round(finiteNumber(
incoming.frameRate,
defaultVideoCanvasAudioSeparationConfig.frameRate,
12,
60,
)),
model:
typeof incoming.model === "string" && incoming.model.trim()
? incoming.model.trim().slice(0, 100)
: defaultVideoCanvasAudioSeparationConfig.model,
voiceGain: finiteNumber(
incoming.voiceGain,
defaultVideoCanvasAudioSeparationConfig.voiceGain,
0,
4,
),
};
}
function clampSample(value: number) {
return Math.min(1, Math.max(-1, value));
}
export function separateVideoAudioChannels(
channels: readonly Float32Array[],
mode: VideoAudioSplitMode,
configValue: VideoCanvasAudioSeparationConfig,
) {
const config = normalizeVideoCanvasAudioSeparationConfig(configValue);
const left = channels[0];
if (!left) return [];
const right = channels[1] || left;
const length = Math.min(left.length, right.length);
const outputLeft = new Float32Array(length);
const outputRight = new Float32Array(length);
for (let index = 0; index < length; index += 1) {
const leftSample = left[index] || 0;
const rightSample = right[index] || 0;
if (mode === "audio-video") {
outputLeft[index] = leftSample;
outputRight[index] = rightSample;
continue;
}
if (mode === "voice") {
const center = clampSample(((leftSample + rightSample) / 2) * config.voiceGain);
outputLeft[index] = center;
outputRight[index] = center;
continue;
}
const side = clampSample(((leftSample - rightSample) / 2) * config.backgroundGain);
outputLeft[index] = side;
outputRight[index] = -side;
}
return [outputLeft, outputRight];
}
export function getVideoAudioSplitResultCopy(mode: VideoAudioSplitMode) {
if (mode === "voice") {
return { audioSuffix: "人声", audioSubtitle: "人声分离" };
}
if (mode === "background") {
return { audioSuffix: "背景音", audioSubtitle: "背景音分离" };
}
return { audioSuffix: "音轨", audioSubtitle: "音视频分离" };
}
const MP4_CONTAINER_BOXES = new Set([
"moov",
"trak",
"mdia",
"minf",
"stbl",
"edts",
"dinf",
"mvex",
]);
function readBoxType(view: DataView, offset: number) {
return String.fromCharCode(
view.getUint8(offset),
view.getUint8(offset + 1),
view.getUint8(offset + 2),
view.getUint8(offset + 3),
);
}
function boxContainsAudioHandler(view: DataView, start: number, end: number): boolean {
let offset = start;
while (offset + 8 <= end) {
const size32 = view.getUint32(offset);
const type = readBoxType(view, offset + 4);
let headerSize = 8;
let size = size32;
if (size32 === 1) {
if (offset + 16 > end) return false;
const high = view.getUint32(offset + 8);
const low = view.getUint32(offset + 12);
size = high * 2 ** 32 + low;
headerSize = 16;
} else if (size32 === 0) {
size = end - offset;
}
if (!Number.isSafeInteger(size) || size < headerSize || offset + size > end) return false;
const contentStart = offset + headerSize;
const boxEnd = offset + size;
if (type === "hdlr" && contentStart + 12 <= boxEnd) {
if (readBoxType(view, contentStart + 8) === "soun") return true;
}
if (
MP4_CONTAINER_BOXES.has(type) &&
boxContainsAudioHandler(view, contentStart, boxEnd)
) {
return true;
}
offset = boxEnd;
}
return false;
}
export function hasMp4AudioTrack(arrayBuffer: ArrayBuffer) {
if (arrayBuffer.byteLength < 8) return false;
return boxContainsAudioHandler(new DataView(arrayBuffer), 0, arrayBuffer.byteLength);
}
type EbmlVint = {
length: number;
unknown: boolean;
value: bigint;
};
function readEbmlVint(view: DataView, offset: number, stripMarker: boolean): EbmlVint | null {
if (offset >= view.byteLength) return null;
const first = view.getUint8(offset);
let marker = 0x80;
let length = 1;
while (length <= 8 && (first & marker) === 0) {
marker >>= 1;
length += 1;
}
if (length > 8 || offset + length > view.byteLength) return null;
let value = BigInt(stripMarker ? first & (marker - 1) : first);
for (let index = 1; index < length; index += 1) {
value = (value << 8n) | BigInt(view.getUint8(offset + index));
}
const unknownValue = (1n << BigInt(length * 7)) - 1n;
return {
length,
unknown: stripMarker && value === unknownValue,
value,
};
}
const EBML_SEGMENT_ID = 0x18538067n;
const EBML_TRACKS_ID = 0x1654ae6bn;
const EBML_TRACK_ENTRY_ID = 0xaen;
const EBML_TRACK_TYPE_ID = 0x83n;
function ebmlRangeContainsAudioTrack(
view: DataView,
start: number,
end: number,
insideTrackEntry = false,
): boolean {
let offset = start;
while (offset < end) {
const id = readEbmlVint(view, offset, false);
if (!id) return false;
const size = readEbmlVint(view, offset + id.length, true);
if (!size) return false;
const contentStart = offset + id.length + size.length;
const requestedEnd = size.unknown ? end : contentStart + Number(size.value);
if (!Number.isSafeInteger(requestedEnd) || requestedEnd < contentStart || requestedEnd > end) {
return false;
}
if (insideTrackEntry && id.value === EBML_TRACK_TYPE_ID) {
let trackType = 0;
for (let index = contentStart; index < requestedEnd; index += 1) {
trackType = trackType * 256 + view.getUint8(index);
}
if (trackType === 2) return true;
}
const isTrackEntry = id.value === EBML_TRACK_ENTRY_ID;
if (
(id.value === EBML_SEGMENT_ID ||
id.value === EBML_TRACKS_ID ||
isTrackEntry) &&
ebmlRangeContainsAudioTrack(view, contentStart, requestedEnd, insideTrackEntry || isTrackEntry)
) {
return true;
}
offset = requestedEnd;
}
return false;
}
export function hasWebmAudioTrack(arrayBuffer: ArrayBuffer) {
if (arrayBuffer.byteLength < 4) return false;
const view = new DataView(arrayBuffer);
return ebmlRangeContainsAudioTrack(view, 0, view.byteLength);
}
export function hasVideoAudioTrack(arrayBuffer: ArrayBuffer) {
return hasMp4AudioTrack(arrayBuffer) || hasWebmAudioTrack(arrayBuffer);
}

View File

@ -0,0 +1,38 @@
export type VideoClipRange = {
start: number;
end: number;
};
export const DEFAULT_VIDEO_CLIP_RANGE: VideoClipRange = {
start: 0,
end: 1 / 3,
};
export function normalizeVideoClipRange(value: unknown): VideoClipRange {
if (!value || typeof value !== "object") return DEFAULT_VIDEO_CLIP_RANGE;
const candidate = value as Partial<VideoClipRange>;
const start = Number(candidate.start);
const end = Number(candidate.end);
if (!Number.isFinite(start) || !Number.isFinite(end)) {
return DEFAULT_VIDEO_CLIP_RANGE;
}
const normalizedStart = Math.max(0, Math.min(start, 0.94));
const normalizedEnd = Math.max(
Math.min(normalizedStart + 0.04, 1),
Math.min(end, 1),
);
return {
start: Number(normalizedStart.toFixed(9)),
end: Number(normalizedEnd.toFixed(9)),
};
}
export function snapVideoClipRatio(ratio: number, durationSeconds: number) {
const duration = Math.max(0.1, Number(durationSeconds) || 0.1);
return Math.max(0, Math.min(1, Math.round(ratio * duration) / duration));
}
export function getVideoClipDuration(range: VideoClipRange, durationSeconds: number) {
const normalized = normalizeVideoClipRange(range);
return Math.max(0.05, (normalized.end - normalized.start) * Math.max(0.1, durationSeconds));
}

View File

@ -0,0 +1,91 @@
export type VideoCropRect = {
x: number;
y: number;
width: number;
height: number;
};
export type VideoCropHandle = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
export const DEFAULT_VIDEO_CROP_RECT: VideoCropRect = {
x: 0.1,
y: 0.1,
width: 0.8,
height: 0.8,
};
const MIN_VIDEO_CROP_SIZE = 0.05;
function clampUnit(value: number) {
return Math.min(1, Math.max(0, value));
}
function roundUnit(value: number) {
return Number(clampUnit(value).toFixed(6));
}
export function normalizeVideoCropRect(value: unknown): VideoCropRect {
if (!value || typeof value !== "object") return DEFAULT_VIDEO_CROP_RECT;
const candidate = value as Partial<VideoCropRect>;
const x = Number(candidate.x);
const y = Number(candidate.y);
const width = Number(candidate.width);
const height = Number(candidate.height);
if (![x, y, width, height].every(Number.isFinite)) return DEFAULT_VIDEO_CROP_RECT;
const normalizedX = clampUnit(x);
const normalizedY = clampUnit(y);
return {
x: roundUnit(Math.min(normalizedX, 1 - MIN_VIDEO_CROP_SIZE)),
y: roundUnit(Math.min(normalizedY, 1 - MIN_VIDEO_CROP_SIZE)),
width: roundUnit(Math.max(MIN_VIDEO_CROP_SIZE, Math.min(width, 1 - normalizedX))),
height: roundUnit(Math.max(MIN_VIDEO_CROP_SIZE, Math.min(height, 1 - normalizedY))),
};
}
export function moveVideoCropRect(rect: VideoCropRect, deltaX: number, deltaY: number) {
const normalized = normalizeVideoCropRect(rect);
return {
...normalized,
x: roundUnit(Math.max(0, Math.min(1 - normalized.width, normalized.x + deltaX))),
y: roundUnit(Math.max(0, Math.min(1 - normalized.height, normalized.y + deltaY))),
};
}
export function resizeVideoCropRect(
rect: VideoCropRect,
handle: VideoCropHandle,
pointX: number,
pointY: number,
) {
const normalized = normalizeVideoCropRect(rect);
let left = normalized.x;
let top = normalized.y;
let right = normalized.x + normalized.width;
let bottom = normalized.y + normalized.height;
const x = clampUnit(pointX);
const y = clampUnit(pointY);
if (handle.includes("w")) left = Math.min(x, right - MIN_VIDEO_CROP_SIZE);
if (handle.includes("e")) right = Math.max(x, left + MIN_VIDEO_CROP_SIZE);
if (handle.includes("n")) top = Math.min(y, bottom - MIN_VIDEO_CROP_SIZE);
if (handle.includes("s")) bottom = Math.max(y, top + MIN_VIDEO_CROP_SIZE);
return normalizeVideoCropRect({
x: left,
y: top,
width: right - left,
height: bottom - top,
});
}
export function getVideoCropOutputDimensions(
rect: VideoCropRect,
sourceWidth: number,
sourceHeight: number,
) {
const normalized = normalizeVideoCropRect(rect);
return {
width: Math.max(2, Math.round(Math.max(2, sourceWidth) * normalized.width)),
height: Math.max(2, Math.round(Math.max(2, sourceHeight) * normalized.height)),
};
}

View File

@ -0,0 +1,101 @@
export const VIDEO_WATERMARK_RULES_STORAGE_KEY = "video-canvas-watermark-rules-v1";
export type VideoCanvasVideoDownloadConfig = {
acknowledgementText: string;
applicationText: string;
responsibilityText: string;
rulesLinkLabel: string;
rulesTitle: string;
rulesUrl: string;
welcomeText: string;
};
export const defaultVideoCanvasVideoDownloadConfig: VideoCanvasVideoDownloadConfig = {
acknowledgementText:
'开启本功能开关并点击 "保存设置" 按钮,即视为您已充分知悉并同意本规则全部内容。后续您可通过 "头像 - AI水印设置" 路径调整本功能配置。',
applicationText:
'您申请我们向您提供没有添加显式标识(以下或称"水印")的AI生成合成内容之前,亦即您使用"去水印"功能之前,请您务必认真阅读并充分理解本规则。您知悉、同意并遵守以下内容的,方可使用本功能。',
responsibilityText:
"经您提出申请,{{siteName}}平台可向您提供未添加前述显式标识的AI生成合成内容。请注意:您在使用网络信息内容传播服务发布该等AI生成合成内容时,需主动作出声明,并通过传播{{siteName}}平台提供的标识功能完成标识操作。您确认已知晓并承诺:若您未依据法律法规要求在AI生成合成内容中添加显式标识,进而引发公众混淆或误认,由此产生的全部后果及法律责任均由您自行承担。更详细的说明见",
rulesLinkLabel: "{{siteName}}水印规范",
rulesTitle: "AI 生成内容水印管理规则",
rulesUrl: "https://resonate.feishu.cn/wiki/",
welcomeText: "欢迎您使用{{siteName}}的产品和服务!",
};
function normalizeDownloadCopy(value: unknown, fallback: string, maxLength: number) {
const normalized = typeof value === "string" ? value.trim().slice(0, maxLength) : "";
return normalized || fallback;
}
function normalizeRulesUrl(value: unknown, fallback: string) {
const candidate = typeof value === "string" ? value.trim().slice(0, 2048) : "";
if (candidate.startsWith("/") && !candidate.startsWith("//")) return candidate;
try {
const url = new URL(candidate);
return url.protocol === "http:" || url.protocol === "https:" ? candidate : fallback;
} catch {
return fallback;
}
}
export function normalizeVideoCanvasVideoDownloadConfig(
value: unknown,
): VideoCanvasVideoDownloadConfig {
const incoming = value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
const fallback = defaultVideoCanvasVideoDownloadConfig;
return {
acknowledgementText: normalizeDownloadCopy(
incoming.acknowledgementText,
fallback.acknowledgementText,
2_000,
),
applicationText: normalizeDownloadCopy(incoming.applicationText, fallback.applicationText, 4_000),
responsibilityText: normalizeDownloadCopy(
incoming.responsibilityText,
fallback.responsibilityText,
6_000,
),
rulesLinkLabel: normalizeDownloadCopy(incoming.rulesLinkLabel, fallback.rulesLinkLabel, 120),
rulesTitle: normalizeDownloadCopy(incoming.rulesTitle, fallback.rulesTitle, 120),
rulesUrl: normalizeRulesUrl(incoming.rulesUrl, fallback.rulesUrl),
welcomeText: normalizeDownloadCopy(incoming.welcomeText, fallback.welcomeText, 500),
};
}
export function resolveVideoDownloadRuleCopy(value: string, siteName: string) {
return value.replaceAll("{{siteName}}", siteName.trim() || "AI平台");
}
const VIDEO_EXTENSION_BY_CONTENT_TYPE: Record<string, string> = {
"video/mp4": "mp4",
"video/quicktime": "mov",
"video/webm": "webm",
};
export function sanitizeVideoDownloadTitle(value: string) {
return (value.trim() || "视频").replace(/[\\/:*?"<>|]+/g, "-").slice(0, 80);
}
export function getVideoDownloadExtension(source: string, contentType = "") {
const normalizedContentType = contentType.split(";", 1)[0]?.trim().toLowerCase();
if (normalizedContentType && VIDEO_EXTENSION_BY_CONTENT_TYPE[normalizedContentType]) {
return VIDEO_EXTENSION_BY_CONTENT_TYPE[normalizedContentType];
}
try {
const pathname = new URL(source, "https://canvas.local").pathname;
const extension = pathname.match(/\.([a-z0-9]{2,5})$/i)?.[1]?.toLowerCase();
if (extension && ["mp4", "mov", "m4v", "webm"].includes(extension)) return extension;
} catch {
// Fall through to the interoperable video default.
}
return "mp4";
}
export function getVideoDownloadFilename(title: string, source: string, contentType = "") {
return `${sanitizeVideoDownloadTitle(title)}.${getVideoDownloadExtension(source, contentType)}`;
}

View File

@ -0,0 +1,164 @@
import type { VideoCanvasEffectPreset } from "@/lib/video-canvas/config";
export type VideoCanvasAttachedEffect = Pick<
VideoCanvasEffectPreset,
| "author"
| "commercial"
| "id"
| "model"
| "prompt"
| "thumbnailUrl"
| "title"
>;
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function readString(record: Record<string, unknown>, key: string) {
return typeof record[key] === "string" ? record[key].trim() : "";
}
export function toVideoCanvasAttachedEffect(
preset: VideoCanvasEffectPreset,
): VideoCanvasAttachedEffect {
return {
author: preset.author,
commercial: preset.commercial,
id: preset.id,
model: preset.model,
prompt: preset.prompt,
thumbnailUrl: preset.thumbnailUrl,
title: preset.title,
};
}
export function getVideoCanvasAttachedEffect(
draftValues: Record<string, unknown> | null | undefined,
): VideoCanvasAttachedEffect | null {
if (!draftValues) return null;
const params = isRecord(draftValues.params) ? draftValues.params : {};
const paramsEffect = Array.isArray(params.effects) && isRecord(params.effects[0])
? params.effects[0]
: null;
const source = isRecord(draftValues.videoEffect)
? draftValues.videoEffect
: paramsEffect;
if (!source) return null;
const id = readString(source, "id");
const title = readString(source, "title");
const thumbnailUrl = readString(source, "thumbnailUrl");
if (!id || !title || !thumbnailUrl) return null;
return {
author: readString(source, "author"),
commercial: source.commercial !== false,
id,
model: readString(source, "model"),
prompt: readString(source, "prompt"),
thumbnailUrl,
title,
};
}
export function setVideoCanvasAttachedEffect(
draftValues: Record<string, unknown> | null | undefined,
preset: VideoCanvasEffectPreset | null,
) {
const current = { ...(draftValues || {}) };
const params = isRecord(current.params) ? current.params : {};
if (preset) {
const effect = toVideoCanvasAttachedEffect(preset);
return {
...current,
params: {
...params,
effects: [effect],
},
videoEffect: effect,
};
}
const { videoEffect: _removedEffect, ...nextDraftValues } = current;
const { effects: _removedEffects, ...nextParams } = params;
return {
...nextDraftValues,
params: nextParams,
};
}
function normalizeSearchText(value: unknown) {
return String(value || "")
.normalize("NFKC")
.toLocaleLowerCase()
.replace(/[\s\p{P}\p{S}]+/gu, "");
}
function getSearchFragments(query: string) {
const characters = Array.from(query);
const fragments = new Set<string>(characters);
for (let index = 0; index < characters.length - 1; index += 1) {
fragments.add(`${characters[index]}${characters[index + 1]}`);
}
return Array.from(fragments).filter(Boolean);
}
function getWeightedSearchKeywords(value: string) {
return String(value || "")
.split(/[,,;;\n]+/)
.map((item) => {
const match = item.trim().match(/^(.*?)(?::(\d{1,3}))?$/);
const keyword = normalizeSearchText(match?.[1]);
const weight = Math.max(1, Math.min(100, Number(match?.[2]) || 1));
return keyword ? { keyword, weight } : null;
})
.filter((item): item is { keyword: string; weight: number } => Boolean(item));
}
export function rankVideoCanvasEffectPresets(
presets: VideoCanvasEffectPreset[],
query: string,
) {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) return [...presets];
const fragments = getSearchFragments(normalizedQuery);
return presets
.map((preset, index) => {
const title = normalizeSearchText(preset.title);
const author = normalizeSearchText(preset.author);
const weightedKeywords = getWeightedSearchKeywords(preset.searchKeywords);
const keywords = weightedKeywords.map((item) => item.keyword).join("");
const prompt = normalizeSearchText(preset.prompt);
let score = 0;
if (title === normalizedQuery) score += 20_000;
else if (title.includes(normalizedQuery)) score += 10_000;
if (author.includes(normalizedQuery)) score += 6000;
if (keywords.includes(normalizedQuery)) score += 4000;
if (prompt.includes(normalizedQuery)) score += 2500;
for (const item of weightedKeywords) {
if (
item.keyword.includes(normalizedQuery) ||
normalizedQuery.includes(item.keyword)
) {
score += item.weight * 100;
}
}
for (const fragment of fragments) {
const weight = Array.from(fragment).length > 1 ? 80 : 12;
if (title.includes(fragment)) score += weight * 5;
if (keywords.includes(fragment)) score += weight * 3;
if (prompt.includes(fragment)) score += weight;
if (author.includes(fragment)) score += weight;
}
return { index, preset, score };
})
.sort((left, right) => right.score - left.score || left.index - right.index)
.map(({ preset }) => preset);
}

View File

@ -0,0 +1,26 @@
export type VideoFrameExtractionKind = "first" | "last" | "current";
const LAST_FRAME_EPSILON_SECONDS = 0.05;
function finiteNonNegative(value: unknown, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(0, parsed) : fallback;
}
export function getVideoFrameExtractionTarget(
kind: VideoFrameExtractionKind,
currentTimeSeconds: number,
durationSeconds: number,
) {
const duration = finiteNonNegative(durationSeconds);
const lastFrameTime = Math.max(0, duration - LAST_FRAME_EPSILON_SECONDS);
if (kind === "first") return 0;
if (kind === "last") return lastFrameTime;
return Math.min(lastFrameTime, finiteNonNegative(currentTimeSeconds));
}
export function getVideoFrameExtractionCopy(kind: VideoFrameExtractionKind) {
if (kind === "first") return { alt: "视频首帧", title: "首帧" };
if (kind === "last") return { alt: "视频尾帧", title: "尾帧" };
return { alt: "视频截图", title: "截图" };
}

View File

@ -0,0 +1,347 @@
export type VideoCanvasGenerationParametersConfig = {
countOptions: number[];
defaultCount: number;
defaultDurationSeconds: number;
defaultGenerateAudio: boolean;
defaultRatio: string;
defaultResolution: string;
durationStepSeconds: number;
maxDurationSeconds: number;
minDurationSeconds: number;
parameterPricingEnabled: boolean;
pointsPerSecondByResolution: Record<string, number>;
ratioOptions: string[];
resolutionOptions: string[];
};
export type VideoCanvasGenerationParameterSettings = {
audio: "开启" | "关闭";
count: string;
duration: string;
ratio: string;
resolution: string;
};
export type VideoCanvasGenerationCount = 1 | 2 | 4;
const SUPPORTED_RATIO_OPTIONS = [
"Auto",
"16:9",
"4:3",
"1:1",
"3:4",
"9:16",
"21:9",
] as const;
const SUPPORTED_RESOLUTION_OPTIONS = ["480P", "720P", "1080P", "4K"] as const;
const SUPPORTED_COUNT_OPTIONS: readonly VideoCanvasGenerationCount[] = [1, 2, 4];
export const defaultVideoCanvasGenerationParametersConfig: VideoCanvasGenerationParametersConfig = {
countOptions: [1, 2, 4],
defaultCount: 1,
defaultDurationSeconds: 5,
defaultGenerateAudio: true,
defaultRatio: "16:9",
defaultResolution: "720P",
durationStepSeconds: 1,
maxDurationSeconds: 15,
minDurationSeconds: 4,
parameterPricingEnabled: true,
pointsPerSecondByResolution: {
"480P": 13,
"720P": 27,
"1080P": 68,
"4K": 140,
},
ratioOptions: [...SUPPORTED_RATIO_OPTIONS],
resolutionOptions: [...SUPPORTED_RESOLUTION_OPTIONS],
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizeInteger(value: unknown, fallback: number, min: number, max: number) {
const incoming = Number(value);
if (!Number.isFinite(incoming)) return fallback;
return Math.min(max, Math.max(min, Math.round(incoming)));
}
function normalizeRatioLabel(value: unknown) {
const normalized = String(value || "").trim();
if (normalized.toLowerCase() === "adaptive") return "Auto";
return SUPPORTED_RATIO_OPTIONS.find(
(option) => option.toLowerCase() === normalized.toLowerCase(),
);
}
function normalizeResolutionLabel(value: unknown) {
const normalized = String(value || "")
.trim()
.replace(/\s+/g, "")
.toUpperCase();
return SUPPORTED_RESOLUTION_OPTIONS.find((option) => option.toUpperCase() === normalized);
}
function normalizeOptionList<T extends string>(
value: unknown,
supported: readonly T[],
fallback: readonly T[],
) {
if (!Array.isArray(value)) return [...fallback];
const supportedByKey = new Map(supported.map((item) => [item.toLowerCase(), item]));
const normalized = Array.from(
new Set(
value.flatMap((item) => {
const match = supportedByKey.get(String(item || "").trim().toLowerCase());
return match ? [match] : [];
}),
),
);
return normalized.length > 0 ? normalized : [...fallback];
}
function parseDuration(value: unknown, fallback: number) {
const matched = String(value || "").match(/\d+/);
const parsed = matched ? Number(matched[0]) : Number(value);
return Number.isFinite(parsed) ? Math.round(parsed) : fallback;
}
export function normalizeVideoCanvasGenerationRequestCount(
value: unknown,
fallback: VideoCanvasGenerationCount = 1,
): VideoCanvasGenerationCount | null {
const count = Number(value ?? fallback);
return SUPPORTED_COUNT_OPTIONS.includes(count as VideoCanvasGenerationCount)
? (count as VideoCanvasGenerationCount)
: null;
}
export function normalizeVideoCanvasGenerationParametersConfig(
value: unknown,
): VideoCanvasGenerationParametersConfig {
const fallback = defaultVideoCanvasGenerationParametersConfig;
const incoming = isRecord(value) ? value : {};
const ratioOptions: string[] = normalizeOptionList(
Array.isArray(incoming.ratioOptions)
? incoming.ratioOptions.map((item) => normalizeRatioLabel(item) || item)
: incoming.ratioOptions,
SUPPORTED_RATIO_OPTIONS,
fallback.ratioOptions as (typeof SUPPORTED_RATIO_OPTIONS)[number][],
);
const resolutionOptions: string[] = normalizeOptionList(
incoming.resolutionOptions,
SUPPORTED_RESOLUTION_OPTIONS,
fallback.resolutionOptions as (typeof SUPPORTED_RESOLUTION_OPTIONS)[number][],
);
const minDurationSeconds = normalizeInteger(
incoming.minDurationSeconds,
fallback.minDurationSeconds,
1,
60,
);
const maxDurationSeconds = normalizeInteger(
incoming.maxDurationSeconds,
fallback.maxDurationSeconds,
minDurationSeconds,
180,
);
const durationStepSeconds = normalizeInteger(
incoming.durationStepSeconds,
fallback.durationStepSeconds,
1,
Math.max(1, maxDurationSeconds - minDurationSeconds),
);
const countOptions = Array.isArray(incoming.countOptions)
? Array.from(
new Set(
incoming.countOptions
.map((item) => normalizeInteger(item, 0, 1, 4))
.filter((item): item is (typeof SUPPORTED_COUNT_OPTIONS)[number] =>
SUPPORTED_COUNT_OPTIONS.includes(
item as (typeof SUPPORTED_COUNT_OPTIONS)[number],
),
),
),
).sort((left, right) => left - right)
: fallback.countOptions;
const safeCountOptions = countOptions.length > 0 ? countOptions : fallback.countOptions;
const defaultRatioCandidate = normalizeRatioLabel(incoming.defaultRatio);
const defaultResolutionCandidate = normalizeResolutionLabel(incoming.defaultResolution);
const pointsInput = isRecord(incoming.pointsPerSecondByResolution)
? incoming.pointsPerSecondByResolution
: {};
const pointsPerSecondByResolution = Object.fromEntries(
resolutionOptions.map((resolution) => {
const incomingEntry = Object.entries(pointsInput).find(
([key]) => normalizeResolutionLabel(key) === resolution,
)?.[1];
const fallbackRate = fallback.pointsPerSecondByResolution[resolution] || 0;
const rate = Number(incomingEntry);
return [
resolution,
Number.isFinite(rate)
? Math.min(
100_000,
Math.max(0, Math.round((rate + Number.EPSILON) * 100) / 100),
)
: fallbackRate,
];
}),
);
return {
countOptions: safeCountOptions,
defaultCount: safeCountOptions.includes(Number(incoming.defaultCount))
? Number(incoming.defaultCount)
: safeCountOptions.includes(fallback.defaultCount)
? fallback.defaultCount
: safeCountOptions[0],
defaultDurationSeconds: normalizeInteger(
incoming.defaultDurationSeconds,
fallback.defaultDurationSeconds,
minDurationSeconds,
maxDurationSeconds,
),
defaultGenerateAudio:
typeof incoming.defaultGenerateAudio === "boolean"
? incoming.defaultGenerateAudio
: fallback.defaultGenerateAudio,
defaultRatio:
defaultRatioCandidate && ratioOptions.includes(defaultRatioCandidate)
? defaultRatioCandidate
: ratioOptions.includes(fallback.defaultRatio)
? fallback.defaultRatio
: ratioOptions[0],
defaultResolution:
defaultResolutionCandidate && resolutionOptions.includes(defaultResolutionCandidate)
? defaultResolutionCandidate
: resolutionOptions.includes(fallback.defaultResolution)
? fallback.defaultResolution
: resolutionOptions[0],
durationStepSeconds,
maxDurationSeconds,
minDurationSeconds,
parameterPricingEnabled:
typeof incoming.parameterPricingEnabled === "boolean"
? incoming.parameterPricingEnabled
: fallback.parameterPricingEnabled,
pointsPerSecondByResolution,
ratioOptions,
resolutionOptions,
};
}
function normalizeDurationForConfig(
value: unknown,
config: VideoCanvasGenerationParametersConfig,
) {
const parsed = parseDuration(value, config.defaultDurationSeconds);
const clamped = Math.min(config.maxDurationSeconds, Math.max(config.minDurationSeconds, parsed));
const stepped =
config.minDurationSeconds +
Math.round((clamped - config.minDurationSeconds) / config.durationStepSeconds) *
config.durationStepSeconds;
return Math.min(config.maxDurationSeconds, Math.max(config.minDurationSeconds, stepped));
}
export function getVideoCanvasGenerationCount(value: unknown, fallback = 1) {
const matched = String(value || "").match(/\d+/);
const parsed = matched ? Number(matched[0]) : Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback;
}
export function resolveVideoCanvasGenerationParameterSettings(
paramsValue: unknown,
configValue: VideoCanvasGenerationParametersConfig,
): VideoCanvasGenerationParameterSettings {
const config = normalizeVideoCanvasGenerationParametersConfig(configValue);
const params = isRecord(paramsValue) ? paramsValue : {};
const settings = isRecord(params.settings) ? params.settings : {};
const ratioCandidate = normalizeRatioLabel(params.ratio ?? settings.ratio);
const resolutionCandidate = normalizeResolutionLabel(
params.resolution ?? settings.resolution,
);
const audioValue = params.audio ?? settings.audio;
const audio =
typeof audioValue === "boolean"
? audioValue
? "开启"
: "关闭"
: String(audioValue || "").trim() === "关闭"
? "关闭"
: String(audioValue || "").trim() === "开启"
? "开启"
: config.defaultGenerateAudio
? "开启"
: "关闭";
const requestedCount = getVideoCanvasGenerationCount(
params.count ?? settings.count,
config.defaultCount,
);
const count = config.countOptions.includes(requestedCount)
? requestedCount
: config.defaultCount;
return {
audio,
count: `${count}个`,
duration: `${normalizeDurationForConfig(params.duration ?? settings.duration, config)}s`,
ratio:
ratioCandidate && config.ratioOptions.includes(ratioCandidate)
? ratioCandidate
: config.defaultRatio,
resolution:
resolutionCandidate && config.resolutionOptions.includes(resolutionCandidate)
? resolutionCandidate
: config.defaultResolution,
};
}
export function mergeVideoCanvasGenerationParameterSettings(
paramsValue: unknown,
updates: Partial<VideoCanvasGenerationParameterSettings>,
configValue: VideoCanvasGenerationParametersConfig,
): Record<string, unknown> {
const config = normalizeVideoCanvasGenerationParametersConfig(configValue);
const params = isRecord(paramsValue) ? paramsValue : {};
const settings = isRecord(params.settings) ? params.settings : {};
const current = resolveVideoCanvasGenerationParameterSettings(params, config);
const next = resolveVideoCanvasGenerationParameterSettings(
{ settings: { ...current, ...updates } },
config,
);
return {
...params,
audio: next.audio === "开启",
count: getVideoCanvasGenerationCount(next.count, config.defaultCount),
duration: parseDuration(next.duration, config.defaultDurationSeconds),
ratio: next.ratio === "Auto" ? "adaptive" : next.ratio,
resolution: next.resolution.toLowerCase(),
settings: {
...settings,
...next,
},
};
}
export function estimateVideoCanvasGenerationParameterPoints(
settings: Pick<
VideoCanvasGenerationParameterSettings,
"count" | "duration" | "resolution"
>,
configValue: VideoCanvasGenerationParametersConfig,
) {
const config = normalizeVideoCanvasGenerationParametersConfig(configValue);
if (!config.parameterPricingEnabled) return 0;
const resolution = normalizeResolutionLabel(settings.resolution) || config.defaultResolution;
const pointsPerSecond = Number(config.pointsPerSecondByResolution[resolution] || 0);
if (!Number.isFinite(pointsPerSecond) || pointsPerSecond <= 0) return 0;
const duration = normalizeDurationForConfig(settings.duration, config);
const requestedCount = getVideoCanvasGenerationCount(settings.count, config.defaultCount);
const count = config.countOptions.includes(requestedCount)
? requestedCount
: config.defaultCount;
return Math.max(0, Math.round(pointsPerSecond * duration * count));
}

View File

@ -0,0 +1,105 @@
export type VideoHdResolution = "1080P" | "2K" | "4K";
export type VideoHdInterpolationMode = "none" | "high-quality";
export type VideoHdResolutionConfig = {
width: number;
height: number;
baseCost: number;
interpolationExtraCost: number;
};
export type VideoCanvasVideoHdConfig = {
enabled: boolean;
model: string;
setupCost: number;
frameRates: number[];
slowMultipliers: number[];
resolutions: Record<VideoHdResolution, VideoHdResolutionConfig>;
};
export const defaultVideoCanvasVideoHdConfig: VideoCanvasVideoHdConfig = {
enabled: true,
model: "Topazlabs",
setupCost: 6,
frameRates: [24, 30, 60, 90, 120],
slowMultipliers: [1, 2, 3, 5],
resolutions: {
"1080P": { width: 1920, height: 1080, baseCost: 24, interpolationExtraCost: 12 },
"2K": { width: 2560, height: 1440, baseCost: 42, interpolationExtraCost: 24 },
"4K": { width: 3840, height: 2160, baseCost: 90, interpolationExtraCost: 48 },
},
};
function positiveInteger(value: unknown, fallback: number) {
const parsed = Math.round(Number(value));
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function nonnegativeNumber(value: unknown, fallback: number) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
}
function numberList(value: unknown, fallback: number[]) {
if (!Array.isArray(value)) return fallback;
const normalized = Array.from(
new Set(value.map((item) => positiveInteger(item, 0)).filter((item) => item > 0)),
).sort((a, b) => a - b);
return normalized.length > 0 ? normalized : fallback;
}
export function normalizeVideoCanvasVideoHdConfig(value: unknown): VideoCanvasVideoHdConfig {
const candidate = value && typeof value === "object" ? value as Partial<VideoCanvasVideoHdConfig> : {};
const candidateResolutions =
candidate.resolutions && typeof candidate.resolutions === "object"
? candidate.resolutions as Partial<Record<VideoHdResolution, Partial<VideoHdResolutionConfig>>>
: {};
const resolutions = Object.fromEntries(
(Object.keys(defaultVideoCanvasVideoHdConfig.resolutions) as VideoHdResolution[]).map((key) => {
const fallback = defaultVideoCanvasVideoHdConfig.resolutions[key];
const item = candidateResolutions[key] || {};
return [key, {
width: positiveInteger(item.width, fallback.width),
height: positiveInteger(item.height, fallback.height),
baseCost: nonnegativeNumber(item.baseCost, fallback.baseCost),
interpolationExtraCost: nonnegativeNumber(
item.interpolationExtraCost,
fallback.interpolationExtraCost,
),
}];
}),
) as Record<VideoHdResolution, VideoHdResolutionConfig>;
return {
enabled: typeof candidate.enabled === "boolean" ? candidate.enabled : true,
model: String(candidate.model || defaultVideoCanvasVideoHdConfig.model).trim() || "Topazlabs",
setupCost: nonnegativeNumber(candidate.setupCost, defaultVideoCanvasVideoHdConfig.setupCost),
frameRates: numberList(candidate.frameRates, defaultVideoCanvasVideoHdConfig.frameRates),
slowMultipliers: numberList(
candidate.slowMultipliers,
defaultVideoCanvasVideoHdConfig.slowMultipliers,
),
resolutions,
};
}
export function getVideoHdCost(
config: VideoCanvasVideoHdConfig,
resolution: VideoHdResolution,
interpolationMode: VideoHdInterpolationMode,
slowMultiplier: number,
) {
const definition = config.resolutions[resolution];
const singleDurationCost = definition.baseCost +
(interpolationMode === "high-quality" ? definition.interpolationExtraCost : 0);
const multiplier = Math.max(1, positiveInteger(slowMultiplier, 1));
return Math.round(config.setupCost + (singleDurationCost - config.setupCost) * multiplier);
}
export function formatVideoHdFrameRate(frameRate: number) {
if (frameRate === 24 || frameRate === 30) return `标准(${frameRate}fps)`;
if (frameRate === 60) return "流畅(60fps)";
if (frameRate === 90) return "超流畅(90fps)";
if (frameRate === 120) return "极致流畅(120fps)";
return `${frameRate}fps`;
}

View File

@ -0,0 +1,23 @@
export const DEFAULT_VIDEO_MARK_GENERATION_PROMPT =
"重点遵循已标记元素:{{labels}}";
export function composeVideoMarkGenerationPrompt({
labels,
template,
}: {
labels: string[];
template?: string;
}) {
const normalizedLabels = Array.from(
new Set(labels.map((label) => String(label || "").trim()).filter(Boolean)),
);
if (normalizedLabels.length === 0) return "";
const source =
template === undefined ? DEFAULT_VIDEO_MARK_GENERATION_PROMPT : template.trim();
if (!source) return "";
return source
.replaceAll("{{labels}}", normalizedLabels.map((label) => `@${label}`).join("、"))
.replaceAll("{{plainLabels}}", normalizedLabels.join("、"));
}

View File

@ -0,0 +1,505 @@
export const VIDEO_CANVAS_MODEL_ICON_KEYS = [
"seedance",
"happy-horse",
"kling",
"wan",
"hailuo",
"vidu",
"pixverse",
"omnihuman",
"generic",
] as const;
export type VideoCanvasModelIconKey =
(typeof VIDEO_CANVAS_MODEL_ICON_KEYS)[number];
export type VideoCanvasModelCatalogEntry = {
badge: string;
description: string;
discountRate: number;
durationLabel: string;
enabled: boolean;
icon: VideoCanvasModelIconKey;
id: string;
label: string;
membershipOnly: boolean;
modelRef: string;
pointsPerSecondByResolution: Record<string, number>;
shortLabel: string;
};
type CatalogSeed = Omit<
VideoCanvasModelCatalogEntry,
| "badge"
| "discountRate"
| "enabled"
| "membershipOnly"
| "modelRef"
| "pointsPerSecondByResolution"
> &
Partial<
Pick<
VideoCanvasModelCatalogEntry,
| "badge"
| "discountRate"
| "enabled"
| "membershipOnly"
| "modelRef"
| "pointsPerSecondByResolution"
>
>;
function createCatalogEntry(seed: CatalogSeed): VideoCanvasModelCatalogEntry {
return {
badge: seed.badge || "",
description: seed.description,
discountRate: seed.discountRate ?? 1,
durationLabel: seed.durationLabel,
enabled: seed.enabled !== false,
icon: seed.icon,
id: seed.id,
label: seed.label,
membershipOnly: seed.membershipOnly === true,
modelRef: seed.modelRef || "auto",
pointsPerSecondByResolution: seed.pointsPerSecondByResolution || {},
shortLabel: seed.shortLabel,
};
}
export const defaultVideoCanvasModelCatalog: VideoCanvasModelCatalogEntry[] = [
createCatalogEntry({
id: "seedance-2-vip",
label: "Seedance 2.0 VIP",
shortLabel: "2.0",
durationLabel: "2min",
description: "最强视频模型,会员专属通道,15s音画同步",
badge: "720P限时9折",
membershipOnly: true,
icon: "seedance",
discountRate: 0.9,
pointsPerSecondByResolution: { "720P": 27 },
}),
createCatalogEntry({
id: "seedance-2-fast-vip",
label: "Seedance 2.0 Fast VIP",
shortLabel: "2.0 Fast",
durationLabel: "2min",
description: "最强视频模型快速版,会员专属通道,15s音画同步",
badge: "720P限时9折",
membershipOnly: true,
icon: "seedance",
discountRate: 0.9,
pointsPerSecondByResolution: { "720P": 22 },
}),
createCatalogEntry({
id: "seedance-2-mini",
label: "Seedance 2.0 Mini",
shortLabel: "2.0 Mini",
durationLabel: "2min",
description: "最强视频模型mini版,高性价比生成,15s音画同步",
membershipOnly: true,
icon: "seedance",
}),
createCatalogEntry({
id: "happy-horse-1-1",
label: "Happy Horse 1.1",
shortLabel: "Happy Horse 1.1",
durationLabel: "3min",
description: "阿里最新视频模型,一致性与视听质量更可控",
badge: "限时4折",
membershipOnly: true,
icon: "happy-horse",
discountRate: 0.4,
}),
createCatalogEntry({
id: "happy-horse-1-0",
label: "Happy Horse 1.0",
shortLabel: "Happy Horse 1.0",
durationLabel: "3min",
description: "阿里视频模型,支持多参生成",
membershipOnly: true,
icon: "happy-horse",
}),
createCatalogEntry({
id: "kling-o3",
label: "Kling O3",
shortLabel: "Kling O3",
durationLabel: "3min",
description: "视频编辑模型、参考一致性、音画同出、多镜头",
membershipOnly: true,
icon: "kling",
pointsPerSecondByResolution: { "720P": 11 },
}),
createCatalogEntry({
id: "kling-3",
label: "Kling 3.0",
shortLabel: "Kling 3.0",
durationLabel: "3min",
description: "视频生成模型,高质感、支持多镜头",
membershipOnly: true,
icon: "kling",
}),
createCatalogEntry({
id: "wan-2-7",
label: "Wan 2.7",
shortLabel: "Wan 2.7",
durationLabel: "3min",
description: "全能参考,支持修改视频画面、剧情、环境",
icon: "wan",
}),
createCatalogEntry({
id: "kling-o1",
label: "Kling O1",
shortLabel: "Kling O1",
durationLabel: "3min",
description: "可灵一代编辑模型、支持多模态输入",
icon: "kling",
}),
createCatalogEntry({
id: "kling-2-6",
label: "Kling 2.6",
shortLabel: "Kling 2.6",
durationLabel: "2min",
description: "视频生成模型、直出音画同步",
icon: "kling",
}),
createCatalogEntry({
id: "kling-3-motion-transfer",
label: "Kling3.0 动作迁移",
shortLabel: "Kling3.0 动作迁移",
durationLabel: "8min",
description: "动作控制模型,需输入1张图片、1条视频",
icon: "kling",
}),
createCatalogEntry({
id: "mj-video",
label: "MJ Video",
shortLabel: "MJ Video",
durationLabel: "2min",
description: "图生视频效果稳定,画面表现力强",
icon: "generic",
}),
createCatalogEntry({
id: "hailuo-02",
label: "Hailuo 02",
shortLabel: "Hailuo 02",
durationLabel: "2min",
description: "画质稳定,适合打造运动特效场景",
icon: "hailuo",
}),
createCatalogEntry({
id: "vidu-q2",
label: "Vidu Q2",
shortLabel: "Vidu Q2",
durationLabel: "3min",
description: "多图主体参考,精确控制效果佳",
icon: "vidu",
}),
createCatalogEntry({
id: "vidu-q2-pro",
label: "Vidu Q2 Pro",
shortLabel: "Vidu Q2 Pro",
durationLabel: "",
description: "Vidu Q2 Pro",
icon: "vidu",
}),
createCatalogEntry({
id: "vidu-q2-turbo",
label: "Vidu Q2 Turbo",
shortLabel: "Vidu Q2 Turbo",
durationLabel: "",
description: "Vidu Q2 Turbo",
icon: "vidu",
}),
createCatalogEntry({
id: "kling-2-5",
label: "Kling 2.5",
shortLabel: "Kling 2.5",
durationLabel: "2min",
description: "速度快、效果稳定、性价比高",
icon: "kling",
}),
createCatalogEntry({
id: "kling-2-1",
label: "Kling 2.1",
shortLabel: "Kling 2.1",
durationLabel: "3min",
description: "支持首尾帧,图生视频效果更出色",
icon: "kling",
}),
createCatalogEntry({
id: "pixverse-v5-5",
label: "Pixverse V5.5",
shortLabel: "Pixverse V5.5",
durationLabel: "3min",
description: "支持特效、玩法丰富",
icon: "pixverse",
}),
createCatalogEntry({
id: "pixverse-v5",
label: "Pixverse V5",
shortLabel: "Pixverse V5",
durationLabel: "3min",
description: "支持特效、玩法丰富",
icon: "pixverse",
}),
createCatalogEntry({
id: "kling-3-turbo",
label: "Kling 3.0 Turbo",
shortLabel: "Kling 3.0 Turbo",
durationLabel: "3min",
description: "视频生成模型,高质感、支持多镜头",
icon: "kling",
}),
createCatalogEntry({
id: "wan-2-6",
label: "Wan 2.6",
shortLabel: "Wan 2.6",
durationLabel: "3min",
description: "音画同步,支持多机位镜头,最长可生15秒视频",
icon: "wan",
}),
createCatalogEntry({
id: "hailuo-2-3-fast",
label: "Hailuo 2.3 Fast",
shortLabel: "Hailuo 2.3 Fast",
durationLabel: "1min",
description: "善于表达动作、表情、镜头,更快速",
icon: "hailuo",
}),
createCatalogEntry({
id: "hailuo-2-3",
label: "Hailuo 2.3",
shortLabel: "Hailuo 2.3",
durationLabel: "2min",
description: "善于表达动作、表情、镜头,更高质感",
icon: "hailuo",
}),
createCatalogEntry({
id: "seedance-1-5-pro",
label: "Seedance1.5 Pro",
shortLabel: "Seedance1.5 Pro",
durationLabel: "2min",
description: "音画同步,支持多机位镜头,最长可生12秒视频",
icon: "seedance",
}),
createCatalogEntry({
id: "seedance-1-pro",
label: "Seedance 1.0 Pro",
shortLabel: "1.0 Pro",
durationLabel: "2min",
description: "高精度提示词理解,40秒生成1080P视频",
membershipOnly: true,
icon: "seedance",
}),
createCatalogEntry({
id: "seedance-1-lite",
label: "Seedance 1.0 Lite",
shortLabel: "1.0 Lite",
durationLabel: "1min",
description: "轻量快速,一键进行日常视频生成",
membershipOnly: true,
icon: "seedance",
}),
createCatalogEntry({
id: "vidu-q3-pro",
label: "Vidu Q3 Pro",
shortLabel: "Vidu Q3 Pro",
durationLabel: "2min",
description: "支持主体参考,精确控制效果佳",
membershipOnly: true,
icon: "vidu",
}),
createCatalogEntry({
id: "omnihuman-1-5",
label: "OmniHuman 1.5",
shortLabel: "OmniHuman 1.5",
durationLabel: "3min",
description: "多模态数字人视频生成",
membershipOnly: true,
icon: "omnihuman",
}),
createCatalogEntry({
id: "wan-2-2",
label: "Wan 2.2",
shortLabel: "Wan 2.2",
durationLabel: "3min",
description: "支持特效、玩法千变万化",
icon: "wan",
}),
createCatalogEntry({
id: "wan-2-5",
label: "Wan 2.5",
shortLabel: "Wan 2.5",
durationLabel: "3min",
description: "支持特效、直出音画同步",
icon: "wan",
}),
];
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizeText(value: unknown, fallback: string, maxLength: number) {
return String(value ?? fallback).trim().slice(0, maxLength) || fallback;
}
function normalizeDiscountRate(value: unknown, fallback: number) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return fallback;
return Math.min(1, Math.max(0.01, Math.round(numeric * 100) / 100));
}
function normalizePricingMap(value: unknown) {
if (!isRecord(value)) return {};
return Object.fromEntries(
Object.entries(value)
.map(([resolution, points]) => [resolution.trim().slice(0, 24), Number(points)] as const)
.filter(
([resolution, points]) =>
Boolean(resolution) && Number.isFinite(points) && points >= 0,
)
.slice(0, 12)
.map(([resolution, points]) => [
resolution,
Math.min(100_000, Math.round(points * 100) / 100),
]),
);
}
export function normalizeVideoCanvasModelCatalog(
value: unknown,
): VideoCanvasModelCatalogEntry[] {
if (!Array.isArray(value) || value.length === 0) {
return defaultVideoCanvasModelCatalog.map((entry) => ({
...entry,
pointsPerSecondByResolution: { ...entry.pointsPerSecondByResolution },
}));
}
const usedIds = new Set<string>();
const normalized = value.flatMap((item, index) => {
if (!isRecord(item)) return [];
const fallback =
defaultVideoCanvasModelCatalog.find(
(entry) => entry.id === item.id || entry.label === item.label,
) || defaultVideoCanvasModelCatalog[index];
const label = normalizeText(item.label, fallback?.label || "", 120);
if (!label) return [];
const rawId = normalizeText(item.id, fallback?.id || `video-model-${index + 1}`, 120)
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "") || `video-model-${index + 1}`;
let id = rawId;
let suffix = 2;
while (usedIds.has(id)) {
id = `${rawId}-${suffix}`;
suffix += 1;
}
usedIds.add(id);
const icon = VIDEO_CANVAS_MODEL_ICON_KEYS.includes(
item.icon as VideoCanvasModelIconKey,
)
? (item.icon as VideoCanvasModelIconKey)
: fallback?.icon || "generic";
return [
{
badge: normalizeText(item.badge, fallback?.badge || "", 60),
description: normalizeText(
item.description,
fallback?.description || label,
240,
),
discountRate: normalizeDiscountRate(
item.discountRate,
fallback?.discountRate ?? 1,
),
durationLabel: normalizeText(
item.durationLabel,
fallback?.durationLabel || "",
24,
),
enabled: item.enabled !== false,
icon,
id,
label,
membershipOnly:
typeof item.membershipOnly === "boolean"
? item.membershipOnly
: fallback?.membershipOnly === true,
modelRef: normalizeText(item.modelRef, fallback?.modelRef || "auto", 160),
pointsPerSecondByResolution: normalizePricingMap(
item.pointsPerSecondByResolution ?? fallback?.pointsPerSecondByResolution,
),
shortLabel: normalizeText(item.shortLabel, fallback?.shortLabel || label, 80),
},
];
});
return normalized.length > 0
? normalized.slice(0, 80)
: normalizeVideoCanvasModelCatalog(undefined);
}
export function getVideoCanvasModelCatalogEntry(
catalog: VideoCanvasModelCatalogEntry[],
selection: { label?: string; modelRef?: string },
) {
const enabledCatalog = catalog.filter((entry) => entry.enabled);
const label = String(selection.label || "").trim();
if (label) {
const labelMatch = enabledCatalog.find((entry) => entry.label === label);
if (labelMatch) return labelMatch;
}
const modelRef = String(selection.modelRef || "").trim();
if (modelRef && modelRef !== "auto") {
const modelMatch = enabledCatalog.find((entry) => entry.modelRef === modelRef);
if (modelMatch) return modelMatch;
}
return enabledCatalog[0] || catalog[0];
}
export function resolveVideoCanvasCatalogModelRef(
entry: VideoCanvasModelCatalogEntry,
fallbackModelRef: string,
) {
const configured = String(entry.modelRef || "").trim();
if (configured && configured !== "auto") return configured;
const fallback = String(fallbackModelRef || "").trim();
return fallback || "auto";
}
export function estimateVideoCanvasModelCatalogPoints(
entry: VideoCanvasModelCatalogEntry | undefined,
settings: {
count: number;
durationSeconds: number;
fallbackPointsPerSecond: number;
resolution: string;
},
) {
const configuredRate = Number(
entry?.pointsPerSecondByResolution[settings.resolution],
);
const fallbackRate = Number(settings.fallbackPointsPerSecond);
const pointsPerSecond = Number.isFinite(configuredRate)
? configuredRate
: Number.isFinite(fallbackRate)
? fallbackRate
: 0;
const originalCost = Math.max(
0,
Math.round(
Math.max(0, pointsPerSecond) *
Math.max(1, Math.round(settings.durationSeconds)) *
Math.max(1, Math.round(settings.count)),
),
);
const discountRate = entry?.discountRate ?? 1;
const cost = Math.max(0, Math.round(originalCost * discountRate));
return {
cost,
originalCost: cost === originalCost ? undefined : originalCost,
};
}

View File

@ -0,0 +1,63 @@
export type VideoCanvasMotionMention = {
custom: boolean;
id: string;
index: number;
label: string;
motionId: string;
offset: number;
previewUrl: string;
prompt: string;
};
function normalizeMotionMentionId(value: unknown) {
return String(value || "")
.trim()
.replace(/[^A-Za-z0-9_-]+/g, "-")
.slice(0, 120);
}
export function normalizeVideoCanvasMotionMentions(
value: unknown,
): VideoCanvasMotionMention[] {
if (!Array.isArray(value)) return [];
return value.slice(0, 100).flatMap((item, position) => {
if (!item || typeof item !== "object") return [];
const record = item as Record<string, unknown>;
const label = String(record.label || "").trim().slice(0, 100);
if (!label) return [];
const motionId =
normalizeMotionMentionId(record.motionId) || `motion-${position + 1}`;
return [{
custom: record.custom === true,
id:
normalizeMotionMentionId(record.id) ||
`motion-mention-${motionId}-${position + 1}`,
index: Math.max(
1,
Math.min(100, Math.round(Number(record.index) || position + 1)),
),
label,
motionId,
offset: Math.max(0, Math.round(Number(record.offset) || 0)),
previewUrl: String(record.previewUrl || "").trim().slice(0, 4096),
prompt: String(record.prompt || label).trim().slice(0, 2000) || label,
}];
});
}
export function composeVideoCanvasMotionPrompt(
prompt: string,
motionMentions: VideoCanvasMotionMention[],
) {
return [
String(prompt || "").trim(),
...motionMentions.map((mention) => {
const instruction = mention.prompt.trim() || mention.label;
return `运镜要求「${mention.label}」:${instruction}`;
}),
]
.filter(Boolean)
.join(" ");
}

View File

@ -0,0 +1,772 @@
import type { VideoClipRange } from "@/lib/video-canvas/video-clip";
import { getVideoClipDuration, normalizeVideoClipRange } from "@/lib/video-canvas/video-clip";
import type { VideoCropRect } from "@/lib/video-canvas/video-crop";
import { normalizeVideoCropRect } from "@/lib/video-canvas/video-crop";
import {
getVideoFrameExtractionTarget,
type VideoFrameExtractionKind,
} from "@/lib/video-canvas/video-frame-extraction";
import {
hasVideoAudioTrack,
normalizeVideoCanvasAudioSeparationConfig,
type VideoAudioSplitMode,
type VideoCanvasAudioSeparationConfig,
} from "@/lib/video-canvas/video-audio-separation";
import {
detectSubtitleEraseRegions,
normalizeSubtitleEraseRegion,
normalizeVideoCanvasSubtitleEraseConfig,
type SubtitleEraseMode,
type SubtitleEraseRegion,
type VideoCanvasSubtitleEraseConfig,
} from "@/lib/video-canvas/subtitle-erase";
type CapturableVideoElement = HTMLVideoElement & {
captureStream?: () => MediaStream;
mozCaptureStream?: () => MediaStream;
};
function waitForMediaEvent(
target: HTMLMediaElement,
eventName: "loadeddata" | "loadedmetadata" | "seeked",
) {
return new Promise<void>((resolve, reject) => {
const timeout = window.setTimeout(() => reject(new Error(`Video ${eventName} timeout`)), 15_000);
const cleanup = () => {
window.clearTimeout(timeout);
target.removeEventListener(eventName, handleSuccess);
target.removeEventListener("error", handleError);
};
const handleSuccess = () => {
cleanup();
resolve();
};
const handleError = () => {
cleanup();
reject(new Error("Video source could not be decoded"));
};
target.addEventListener(eventName, handleSuccess, { once: true });
target.addEventListener("error", handleError, { once: true });
});
}
function canvasToPngBlob(canvas: HTMLCanvasElement) {
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error("视频帧编码失败"));
}, "image/png");
});
}
function getRecorderMimeType() {
const candidates = [
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/webm",
];
return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) || "";
}
function getAudioRecorderMimeType() {
const candidates = [
"audio/webm;codecs=opus",
"audio/webm",
"audio/ogg;codecs=opus",
];
return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) || "";
}
function getCanvasVideoSourceUrl(mediaUrl: string) {
const parsedUrl = new URL(mediaUrl, window.location.origin);
return parsedUrl.origin === window.location.origin
? parsedUrl.toString()
: `/api/canvas/video-source?url=${encodeURIComponent(parsedUrl.toString())}`;
}
async function fetchCanvasVideoBlob(mediaUrl: string) {
const response = await fetch(getCanvasVideoSourceUrl(mediaUrl), { credentials: "include" });
if (!response.ok) throw new Error("视频源加载失败");
return response.blob();
}
export async function inspectCanvasVideoAudioTrack(mediaUrl: string) {
try {
return hasVideoAudioTrack(await (await fetchCanvasVideoBlob(mediaUrl)).arrayBuffer());
} catch {
return false;
}
}
export async function loadCanvasVideoClipFrames(mediaUrl: string, frameCount = 8) {
const response = await fetch(getCanvasVideoSourceUrl(mediaUrl), { credentials: "include" });
if (!response.ok) throw new Error("视频预览加载失败");
const objectUrl = URL.createObjectURL(await response.blob());
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.preload = "auto";
video.src = objectUrl;
try {
if (video.readyState < HTMLMediaElement.HAVE_METADATA) {
await waitForMediaEvent(video, "loadedmetadata");
}
const canvas = document.createElement("canvas");
const width = Math.max(160, Math.min(480, video.videoWidth || 320));
const height = Math.max(90, Math.round(width * ((video.videoHeight || 180) / (video.videoWidth || 320))));
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) return [];
const frames: string[] = [];
for (let index = 0; index < Math.max(1, frameCount); index += 1) {
const target = Math.min(
Math.max(0, video.duration - 0.05),
(video.duration * (index + 0.5)) / Math.max(1, frameCount),
);
video.currentTime = target;
await waitForMediaEvent(video, "seeked");
context.drawImage(video, 0, 0, width, height);
frames.push(canvas.toDataURL("image/jpeg", 0.72));
}
return frames;
} finally {
video.removeAttribute("src");
video.load();
URL.revokeObjectURL(objectUrl);
}
}
export async function extractCanvasVideoFrame(
mediaUrl: string,
kind: VideoFrameExtractionKind,
currentTimeSeconds: number,
displayDurationSeconds: number,
) {
const sourceBlob = await fetchCanvasVideoBlob(mediaUrl);
const sourceObjectUrl = URL.createObjectURL(sourceBlob);
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.preload = "auto";
video.src = sourceObjectUrl;
try {
if (video.readyState < HTMLMediaElement.HAVE_METADATA) {
await waitForMediaEvent(video, "loadedmetadata");
}
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
await waitForMediaEvent(video, "loadeddata");
}
const durationSeconds = Number.isFinite(video.duration) && video.duration > 0
? video.duration
: displayDurationSeconds;
const targetTimeSeconds = getVideoFrameExtractionTarget(
kind,
currentTimeSeconds,
durationSeconds,
);
if (Math.abs(video.currentTime - targetTimeSeconds) > 0.001) {
video.currentTime = targetTimeSeconds;
await waitForMediaEvent(video, "seeked");
}
const width = Math.max(1, Math.round(video.videoWidth));
const height = Math.max(1, Math.round(video.videoHeight));
if (width <= 1 || height <= 1) throw new Error("视频帧尺寸无效");
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("视频帧画布创建失败");
context.drawImage(video, 0, 0, width, height);
return {
blob: await canvasToPngBlob(canvas),
height,
targetTimeSeconds,
width,
};
} finally {
video.pause();
video.removeAttribute("src");
video.load();
URL.revokeObjectURL(sourceObjectUrl);
}
}
export async function processCanvasVideoClip(
mediaUrl: string,
range: VideoClipRange,
displayDurationSeconds: number,
) {
const response = await fetch(getCanvasVideoSourceUrl(mediaUrl), { credentials: "include" });
if (!response.ok) throw new Error("视频源加载失败");
const sourceObjectUrl = URL.createObjectURL(await response.blob());
const video = document.createElement("video") as CapturableVideoElement;
video.muted = true;
video.playsInline = true;
video.preload = "auto";
video.src = sourceObjectUrl;
try {
if (video.readyState < HTMLMediaElement.HAVE_METADATA) {
await waitForMediaEvent(video, "loadedmetadata");
}
const normalizedRange = normalizeVideoClipRange(range);
const requestedDuration = getVideoClipDuration(normalizedRange, displayDurationSeconds);
const startSeconds = Math.min(
Math.max(0, normalizedRange.start * displayDurationSeconds),
Math.max(0, video.duration - 0.05),
);
const endSeconds = Math.min(video.duration, startSeconds + requestedDuration);
video.currentTime = startSeconds;
if (Math.abs(video.currentTime - startSeconds) > 0.01 || startSeconds > 0) {
await waitForMediaEvent(video, "seeked");
}
const captureStream = video.captureStream || video.mozCaptureStream;
if (!captureStream || typeof MediaRecorder === "undefined") {
throw new Error("当前浏览器不支持视频剪辑");
}
const stream = captureStream.call(video);
const mimeType = getRecorderMimeType();
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
const chunks: BlobPart[] = [];
recorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) chunks.push(event.data);
});
const completed = new Promise<Blob>((resolve, reject) => {
const timeout = window.setTimeout(() => {
if (recorder.state !== "inactive") recorder.stop();
}, Math.ceil((endSeconds - startSeconds + 2) * 1_000));
recorder.addEventListener("error", () => {
window.clearTimeout(timeout);
reject(new Error("视频剪辑失败"));
}, { once: true });
recorder.addEventListener("stop", () => {
window.clearTimeout(timeout);
resolve(new Blob(chunks, { type: recorder.mimeType || "video/webm" }));
}, { once: true });
});
const stopAtSelectionEnd = () => {
if (video.currentTime >= endSeconds - 0.02 && recorder.state !== "inactive") {
recorder.stop();
}
};
video.addEventListener("timeupdate", stopAtSelectionEnd);
recorder.start(100);
await video.play();
const blob = await completed;
video.pause();
video.removeEventListener("timeupdate", stopAtSelectionEnd);
stream.getTracks().forEach((track) => track.stop());
if (blob.size === 0) throw new Error("视频剪辑结果为空");
return { blob, durationSeconds: Math.max(0.05, endSeconds - startSeconds) };
} finally {
video.pause();
video.removeAttribute("src");
video.load();
URL.revokeObjectURL(sourceObjectUrl);
}
}
export async function processCanvasVideoCrop(
mediaUrl: string,
cropRect: VideoCropRect,
durationSeconds: number,
outputWidth: number,
outputHeight: number,
) {
const response = await fetch(getCanvasVideoSourceUrl(mediaUrl), { credentials: "include" });
if (!response.ok) throw new Error("视频源加载失败");
const sourceObjectUrl = URL.createObjectURL(await response.blob());
const video = document.createElement("video") as CapturableVideoElement;
video.muted = true;
video.playsInline = true;
video.preload = "auto";
video.src = sourceObjectUrl;
try {
if (video.readyState < HTMLMediaElement.HAVE_METADATA) {
await waitForMediaEvent(video, "loadedmetadata");
}
const captureStream = video.captureStream || video.mozCaptureStream;
if (!captureStream || typeof MediaRecorder === "undefined") {
throw new Error("当前浏览器不支持视频裁剪");
}
const normalizedRect = normalizeVideoCropRect(cropRect);
const canvas = document.createElement("canvas");
canvas.width = Math.max(2, Math.round(outputWidth));
canvas.height = Math.max(2, Math.round(outputHeight));
const context = canvas.getContext("2d");
if (!context) throw new Error("视频裁剪画布创建失败");
const canvasStream = canvas.captureStream(30);
const sourceStream = captureStream.call(video);
const outputStream = new MediaStream([
...canvasStream.getVideoTracks(),
...sourceStream.getAudioTracks(),
]);
const mimeType = getRecorderMimeType();
const recorder = new MediaRecorder(outputStream, mimeType ? { mimeType } : undefined);
const chunks: BlobPart[] = [];
recorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) chunks.push(event.data);
});
let animationFrame = 0;
const drawFrame = () => {
context.drawImage(
video,
video.videoWidth * normalizedRect.x,
video.videoHeight * normalizedRect.y,
video.videoWidth * normalizedRect.width,
video.videoHeight * normalizedRect.height,
0,
0,
canvas.width,
canvas.height,
);
animationFrame = window.requestAnimationFrame(drawFrame);
};
const stopAt = Math.min(video.duration, Math.max(0.1, durationSeconds));
const completed = new Promise<Blob>((resolve, reject) => {
const timeout = window.setTimeout(() => {
if (recorder.state !== "inactive") recorder.stop();
}, Math.ceil((stopAt + 2) * 1_000));
recorder.addEventListener("error", () => {
window.clearTimeout(timeout);
reject(new Error("视频裁剪失败"));
}, { once: true });
recorder.addEventListener("stop", () => {
window.clearTimeout(timeout);
resolve(new Blob(chunks, { type: recorder.mimeType || "video/webm" }));
}, { once: true });
});
const stopAtDuration = () => {
if (video.currentTime >= stopAt - 0.02 && recorder.state !== "inactive") recorder.stop();
};
video.addEventListener("timeupdate", stopAtDuration);
recorder.start(100);
drawFrame();
await video.play();
const blob = await completed;
video.pause();
window.cancelAnimationFrame(animationFrame);
video.removeEventListener("timeupdate", stopAtDuration);
outputStream.getTracks().forEach((track) => track.stop());
sourceStream.getTracks().forEach((track) => track.stop());
if (blob.size === 0) throw new Error("视频裁剪结果为空");
return { blob, durationSeconds: stopAt };
} finally {
video.pause();
video.removeAttribute("src");
video.load();
URL.revokeObjectURL(sourceObjectUrl);
}
}
export async function processCanvasVideoSubtitleErase(
mediaUrl: string,
mode: SubtitleEraseMode,
requestedRegions: SubtitleEraseRegion[],
durationSeconds: number,
configValue: VideoCanvasSubtitleEraseConfig,
) {
const config = normalizeVideoCanvasSubtitleEraseConfig(configValue);
if (!config.enabled) throw new Error("智能去字幕功能已在后台关闭");
const response = await fetch(getCanvasVideoSourceUrl(mediaUrl), { credentials: "include" });
if (!response.ok) throw new Error("视频源加载失败");
const sourceObjectUrl = URL.createObjectURL(await response.blob());
const video = document.createElement("video") as CapturableVideoElement;
video.muted = true;
video.playsInline = true;
video.preload = "auto";
video.src = sourceObjectUrl;
try {
if (video.readyState < HTMLMediaElement.HAVE_METADATA) {
await waitForMediaEvent(video, "loadedmetadata");
}
const captureStream = video.captureStream || video.mozCaptureStream;
if (!captureStream || typeof MediaRecorder === "undefined") {
throw new Error("当前浏览器不支持视频去字幕");
}
const width = Math.max(2, Math.round(video.videoWidth));
const height = Math.max(2, Math.round(video.videoHeight));
let regions = requestedRegions.map(normalizeSubtitleEraseRegion);
if (mode === "smart") {
const sampleWidth = Math.min(width, 960);
const sampleHeight = Math.max(2, Math.round(sampleWidth * height / width));
const sampleCanvas = document.createElement("canvas");
sampleCanvas.width = sampleWidth;
sampleCanvas.height = sampleHeight;
const sampleContext = sampleCanvas.getContext("2d", { willReadFrequently: true });
if (!sampleContext) throw new Error("字幕检测画布创建失败");
const sampleAt = Math.min(
Math.max(0, video.duration - 0.05),
Math.max(0, Math.min(video.duration * 0.6, durationSeconds * 0.6)),
);
if (sampleAt > 0) {
video.currentTime = sampleAt;
await waitForMediaEvent(video, "seeked");
}
sampleContext.drawImage(video, 0, 0, sampleWidth, sampleHeight);
regions = detectSubtitleEraseRegions(
sampleContext.getImageData(0, 0, sampleWidth, sampleHeight),
config,
);
video.currentTime = 0;
if (sampleAt > 0) await waitForMediaEvent(video, "seeked");
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("视频去字幕画布创建失败");
const canvasStream = canvas.captureStream(config.frameRate);
const sourceStream = captureStream.call(video);
const outputStream = new MediaStream([
...canvasStream.getVideoTracks(),
...sourceStream.getAudioTracks(),
]);
const mimeType = getRecorderMimeType();
const recorder = new MediaRecorder(outputStream, mimeType ? { mimeType } : undefined);
const chunks: BlobPart[] = [];
recorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) chunks.push(event.data);
});
let animationFrame = 0;
const drawFrame = () => {
context.filter = "none";
context.drawImage(video, 0, 0, width, height);
for (const rawRegion of regions) {
const region = normalizeSubtitleEraseRegion(rawRegion);
const paddingX = Math.round(width * config.paddingRatio);
const paddingY = Math.round(height * config.paddingRatio);
const destinationX = Math.max(0, Math.floor(region.x * width) - paddingX);
const destinationY = Math.max(0, Math.floor(region.y * height) - paddingY);
const destinationWidth = Math.min(
width - destinationX,
Math.ceil(region.width * width) + paddingX * 2,
);
const destinationHeight = Math.min(
height - destinationY,
Math.ceil(region.height * height) + paddingY * 2,
);
if (destinationWidth < 1 || destinationHeight < 1) continue;
const sourceY = destinationY >= destinationHeight
? destinationY - destinationHeight
: Math.min(height - destinationHeight, destinationY + destinationHeight);
context.save();
context.beginPath();
context.rect(destinationX, destinationY, destinationWidth, destinationHeight);
context.clip();
context.filter = config.blurRadius > 0 ? `blur(${config.blurRadius}px)` : "none";
context.drawImage(
video,
destinationX,
sourceY,
destinationWidth,
destinationHeight,
destinationX,
destinationY,
destinationWidth,
destinationHeight,
);
context.restore();
}
animationFrame = window.requestAnimationFrame(drawFrame);
};
const stopAt = Math.min(video.duration, Math.max(0.1, durationSeconds));
const completed = new Promise<Blob>((resolve, reject) => {
const timeout = window.setTimeout(() => {
if (recorder.state !== "inactive") recorder.stop();
}, Math.ceil((stopAt + 3) * 1_000));
recorder.addEventListener("error", () => {
window.clearTimeout(timeout);
reject(new Error("视频去字幕失败"));
}, { once: true });
recorder.addEventListener("stop", () => {
window.clearTimeout(timeout);
resolve(new Blob(chunks, { type: recorder.mimeType || "video/webm" }));
}, { once: true });
});
const stopAtDuration = () => {
if (video.currentTime >= stopAt - 0.02 && recorder.state !== "inactive") recorder.stop();
};
video.addEventListener("timeupdate", stopAtDuration);
recorder.start(100);
drawFrame();
await video.play();
const blob = await completed;
video.pause();
window.cancelAnimationFrame(animationFrame);
video.removeEventListener("timeupdate", stopAtDuration);
outputStream.getTracks().forEach((track) => track.stop());
sourceStream.getTracks().forEach((track) => track.stop());
if (blob.size === 0) throw new Error("视频去字幕结果为空");
return { blob, durationSeconds: stopAt, height, regions, width };
} finally {
video.pause();
video.removeAttribute("src");
video.load();
URL.revokeObjectURL(sourceObjectUrl);
}
}
export async function processCanvasVideoAudioSplit(
mediaUrl: string,
mode: VideoAudioSplitMode,
durationSeconds: number,
configValue: VideoCanvasAudioSeparationConfig,
) {
const config = normalizeVideoCanvasAudioSeparationConfig(configValue);
if (!config.enabled) throw new Error("视频音频分离功能已在后台关闭");
const sourceBlob = await fetchCanvasVideoBlob(mediaUrl);
const sourceObjectUrl = URL.createObjectURL(sourceBlob);
const video = document.createElement("video") as CapturableVideoElement;
video.muted = false;
video.playsInline = true;
video.preload = "auto";
video.src = sourceObjectUrl;
try {
if (video.readyState < HTMLMediaElement.HAVE_METADATA) {
await waitForMediaEvent(video, "loadedmetadata");
}
const AudioContextCtor = window.AudioContext ||
(window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (typeof MediaRecorder === "undefined" || !AudioContextCtor) {
throw new Error("当前浏览器不支持视频音频分离");
}
const width = Math.max(2, Math.round(video.videoWidth));
const height = Math.max(2, Math.round(video.videoHeight));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("无声视频画布创建失败");
const outputStream = canvas.captureStream(config.frameRate);
const audioContext = new AudioContextCtor();
const sampleRate = audioContext.sampleRate;
const audioSource = audioContext.createMediaElementSource(video);
const audioDestination = audioContext.createMediaStreamDestination();
if (mode === "audio-video") {
audioSource.connect(audioDestination);
} else {
const splitter = audioContext.createChannelSplitter(2);
const merger = audioContext.createChannelMerger(2);
const signalBus = audioContext.createGain();
const leftGain = audioContext.createGain();
const rightGain = audioContext.createGain();
audioSource.connect(splitter);
if (mode === "voice") {
leftGain.gain.value = config.voiceGain / 2;
rightGain.gain.value = config.voiceGain / 2;
splitter.connect(leftGain, 0);
splitter.connect(rightGain, 1);
leftGain.connect(signalBus);
rightGain.connect(signalBus);
signalBus.connect(merger, 0, 0);
signalBus.connect(merger, 0, 1);
} else {
leftGain.gain.value = config.backgroundGain / 2;
rightGain.gain.value = -config.backgroundGain / 2;
splitter.connect(leftGain, 0);
splitter.connect(rightGain, 1);
leftGain.connect(signalBus);
rightGain.connect(signalBus);
const invertedSide = audioContext.createGain();
invertedSide.gain.value = -1;
signalBus.connect(merger, 0, 0);
signalBus.connect(invertedSide);
invertedSide.connect(merger, 0, 1);
}
merger.connect(audioDestination);
}
const mimeType = getRecorderMimeType();
const recorder = new MediaRecorder(outputStream, mimeType ? { mimeType } : undefined);
const audioMimeType = getAudioRecorderMimeType();
const audioRecorder = new MediaRecorder(
audioDestination.stream,
audioMimeType ? { mimeType: audioMimeType } : undefined,
);
const chunks: BlobPart[] = [];
const audioChunks: BlobPart[] = [];
recorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) chunks.push(event.data);
});
audioRecorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) audioChunks.push(event.data);
});
let animationFrame = 0;
const drawFrame = () => {
context.drawImage(video, 0, 0, width, height);
animationFrame = window.requestAnimationFrame(drawFrame);
};
const stopAt = Math.min(video.duration, Math.max(0.1, durationSeconds));
const completed = new Promise<{ audioBlob: Blob; silentVideoBlob: Blob }>((resolve, reject) => {
const timeout = window.setTimeout(() => {
if (recorder.state !== "inactive") recorder.stop();
if (audioRecorder.state !== "inactive") audioRecorder.stop();
}, Math.ceil((stopAt + 3) * 1_000));
let audioStopped = false;
let videoStopped = false;
const finish = () => {
if (!audioStopped || !videoStopped) return;
window.clearTimeout(timeout);
resolve({
audioBlob: new Blob(audioChunks, { type: audioRecorder.mimeType || "audio/webm" }),
silentVideoBlob: new Blob(chunks, { type: recorder.mimeType || "video/webm" }),
});
};
const handleError = () => {
window.clearTimeout(timeout);
reject(new Error("视频音频分离失败"));
};
recorder.addEventListener("error", handleError, { once: true });
audioRecorder.addEventListener("error", handleError, { once: true });
recorder.addEventListener("stop", () => {
videoStopped = true;
finish();
}, { once: true });
audioRecorder.addEventListener("stop", () => {
audioStopped = true;
finish();
}, { once: true });
});
const stopAtDuration = () => {
if (video.currentTime < stopAt - 0.02) return;
if (recorder.state !== "inactive") recorder.stop();
if (audioRecorder.state !== "inactive") audioRecorder.stop();
};
video.addEventListener("timeupdate", stopAtDuration);
recorder.start(100);
audioRecorder.start(100);
drawFrame();
await audioContext.resume();
await video.play();
const { audioBlob, silentVideoBlob } = await completed;
video.pause();
window.cancelAnimationFrame(animationFrame);
video.removeEventListener("timeupdate", stopAtDuration);
outputStream.getTracks().forEach((track) => track.stop());
audioDestination.stream.getTracks().forEach((track) => track.stop());
await audioContext.close().catch(() => undefined);
if (audioBlob.size === 0) throw new Error("分离音频结果为空");
if (silentVideoBlob.size === 0) throw new Error("无声视频结果为空");
return {
audioBlob,
durationSeconds: stopAt,
height,
sampleRate,
silentVideoBlob,
width,
};
} finally {
video.pause();
video.removeAttribute("src");
video.load();
URL.revokeObjectURL(sourceObjectUrl);
}
}
export async function processCanvasVideoUpscale(
mediaUrl: string,
durationSeconds: number,
outputWidth: number,
outputHeight: number,
frameRate: number,
slowMultiplier: number,
) {
const response = await fetch(getCanvasVideoSourceUrl(mediaUrl), { credentials: "include" });
if (!response.ok) throw new Error("视频源加载失败");
const sourceObjectUrl = URL.createObjectURL(await response.blob());
const video = document.createElement("video") as CapturableVideoElement;
video.muted = true;
video.playsInline = true;
video.preload = "auto";
video.src = sourceObjectUrl;
try {
if (video.readyState < HTMLMediaElement.HAVE_METADATA) {
await waitForMediaEvent(video, "loadedmetadata");
}
const captureStream = video.captureStream || video.mozCaptureStream;
if (!captureStream || typeof MediaRecorder === "undefined") {
throw new Error("当前浏览器不支持视频高清处理");
}
const canvas = document.createElement("canvas");
canvas.width = Math.max(2, Math.round(outputWidth));
canvas.height = Math.max(2, Math.round(outputHeight));
const context = canvas.getContext("2d");
if (!context) throw new Error("视频高清画布创建失败");
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
const normalizedFrameRate = Math.max(1, Math.min(120, Math.round(frameRate)));
const normalizedSlowMultiplier = Math.max(1, Math.min(5, Number(slowMultiplier) || 1));
const canvasStream = canvas.captureStream(normalizedFrameRate);
const sourceStream = captureStream.call(video);
const outputStream = new MediaStream([
...canvasStream.getVideoTracks(),
...sourceStream.getAudioTracks(),
]);
const mimeType = getRecorderMimeType();
const recorder = new MediaRecorder(outputStream, mimeType ? { mimeType } : undefined);
const chunks: BlobPart[] = [];
recorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) chunks.push(event.data);
});
let animationFrame = 0;
const drawFrame = () => {
context.drawImage(video, 0, 0, canvas.width, canvas.height);
animationFrame = window.requestAnimationFrame(drawFrame);
};
const sourceStopAt = Math.min(video.duration, Math.max(0.1, durationSeconds));
const outputDuration = sourceStopAt * normalizedSlowMultiplier;
const completed = new Promise<Blob>((resolve, reject) => {
const timeout = window.setTimeout(() => {
if (recorder.state !== "inactive") recorder.stop();
}, Math.ceil((outputDuration + 2) * 1_000));
recorder.addEventListener("error", () => {
window.clearTimeout(timeout);
reject(new Error("视频高清处理失败"));
}, { once: true });
recorder.addEventListener("stop", () => {
window.clearTimeout(timeout);
resolve(new Blob(chunks, { type: recorder.mimeType || "video/webm" }));
}, { once: true });
});
const stopAtDuration = () => {
if (video.currentTime >= sourceStopAt - 0.02 && recorder.state !== "inactive") recorder.stop();
};
video.addEventListener("timeupdate", stopAtDuration);
video.playbackRate = 1 / normalizedSlowMultiplier;
recorder.start(100);
drawFrame();
await video.play();
const blob = await completed;
video.pause();
window.cancelAnimationFrame(animationFrame);
video.removeEventListener("timeupdate", stopAtDuration);
outputStream.getTracks().forEach((track) => track.stop());
sourceStream.getTracks().forEach((track) => track.stop());
if (blob.size === 0) throw new Error("视频高清结果为空");
return { blob, durationSeconds: outputDuration };
} finally {
video.pause();
video.removeAttribute("src");
video.load();
URL.revokeObjectURL(sourceObjectUrl);
}
}

View File

@ -0,0 +1,135 @@
import type { VideoCanvasRolePreset } from "@/lib/video-canvas/config";
export const VIDEO_CANVAS_ROLE_MATERIALS = [
{ key: "full", label: "全身正面" },
{ key: "face", label: "近景" },
{ key: "expression", label: "九宫格" },
{ key: "threeView", label: "设定图" },
] as const;
export type VideoCanvasRoleMaterialKind =
(typeof VIDEO_CANVAS_ROLE_MATERIALS)[number]["key"];
export type VideoCanvasAttachedRole = {
assetId: string;
compliant: boolean;
id: string;
imageUrl: string;
materialKind: VideoCanvasRoleMaterialKind;
materialLabel: string;
name: string;
prompt: string;
tags: string;
};
export function composeVideoCanvasRolePrompt(
prompt: string,
roles: Array<Pick<VideoCanvasAttachedRole, "assetId" | "name" | "prompt">>,
) {
return [
prompt.trim(),
...roles.map((role) =>
[
`角色引用「${role.name}」${role.assetId ? `(合规资产 ${role.assetId})` : ""}。`,
role.prompt,
]
.filter(Boolean)
.join(" "),
),
]
.filter(Boolean)
.join(" ");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function readString(record: Record<string, unknown>, key: string) {
return typeof record[key] === "string" ? record[key].trim() : "";
}
function isRoleMaterialKind(value: string): value is VideoCanvasRoleMaterialKind {
return VIDEO_CANVAS_ROLE_MATERIALS.some((item) => item.key === value);
}
export function toVideoCanvasAttachedRole(
preset: VideoCanvasRolePreset,
materialKind: VideoCanvasRoleMaterialKind,
): VideoCanvasAttachedRole {
const material = VIDEO_CANVAS_ROLE_MATERIALS.find(
(item) => item.key === materialKind,
) || VIDEO_CANVAS_ROLE_MATERIALS[0];
return {
assetId: preset.assetId,
compliant: preset.compliant,
id: preset.id,
imageUrl: preset.images[material.key],
materialKind: material.key,
materialLabel: material.label,
name: preset.name,
prompt: preset.prompt,
tags: preset.tags,
};
}
export function getVideoCanvasAttachedRole(
draftValues: Record<string, unknown> | null | undefined,
): VideoCanvasAttachedRole | null {
if (!draftValues) return null;
const params = isRecord(draftValues.params) ? draftValues.params : {};
const paramsRole = Array.isArray(params.roles) && isRecord(params.roles[0])
? params.roles[0]
: null;
const source = isRecord(draftValues.videoRole) ? draftValues.videoRole : paramsRole;
if (!source) return null;
const id = readString(source, "id");
const imageUrl = readString(source, "imageUrl");
const materialKindValue = readString(source, "materialKind");
const name = readString(source, "name");
if (!id || !imageUrl || !name || !isRoleMaterialKind(materialKindValue)) return null;
const fallbackLabel = VIDEO_CANVAS_ROLE_MATERIALS.find(
(item) => item.key === materialKindValue,
)?.label || "角色素材";
return {
assetId: readString(source, "assetId"),
compliant: source.compliant !== false,
id,
imageUrl,
materialKind: materialKindValue,
materialLabel: readString(source, "materialLabel") || fallbackLabel,
name,
prompt: readString(source, "prompt"),
tags: readString(source, "tags"),
};
}
export function setVideoCanvasAttachedRole(
draftValues: Record<string, unknown> | null | undefined,
preset: VideoCanvasRolePreset | null,
materialKind: VideoCanvasRoleMaterialKind = "full",
) {
const current = { ...(draftValues || {}) };
const params = isRecord(current.params) ? current.params : {};
if (preset) {
const role = toVideoCanvasAttachedRole(preset, materialKind);
return {
...current,
params: {
...params,
roles: [role],
},
videoRole: role,
};
}
const { videoRole: _removedRole, ...nextDraftValues } = current;
const { roles: _removedRoles, ...nextParams } = params;
return {
...nextDraftValues,
params: nextParams,
};
}

2
next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -46,6 +46,8 @@
"db:pull": "drizzle-kit pull",
"db:check": "drizzle-kit check",
"db:up": "drizzle-kit up",
"db:local:video-canvas": "node scripts/local-video-canvas/start-db.mjs",
"db:sync:video-canvas": "pnpm exec tsx scripts/local-video-canvas/sync-config.ts",
"db:snapshot:system-settings": "pnpm exec tsx scripts/system-settings/create-snapshot.ts",
"db:restore:system-settings-snapshot": "pnpm exec tsx scripts/system-settings/restore-snapshot.ts",
"poc:nanobanana": "pnpm exec tsx scripts/nanobanana-browser-poc.ts",
@ -115,6 +117,7 @@
"diff-match-patch": "^1.0.5",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.45.2",
"elkjs": "0.9.3",
"embla-carousel-react": "^8.6.0",
"exceljs": "^4.4.0",
"fast-deep-equal": "^3.1.3",

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More