Merge verified video canvas updates with production hotfix history
This commit is contained in:
commit
be8bdb4dab
7
.gitignore
vendored
7
.gitignore
vendored
@ -105,3 +105,10 @@ public/vendor/zoo-modeling-app/
|
||||
|
||||
# Drizzle migrations must be versioned (override the global *.sql ignore above)
|
||||
!lib/db/migrations/*.sql
|
||||
|
||||
# Private smart edit runtime and media/model caches
|
||||
.venv-smart-edit/
|
||||
.smart-edit-models/
|
||||
.cache/smart-edit/
|
||||
|
||||
services/video-render/dist/
|
||||
|
||||
@ -1,385 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
createMediaAssetRelation,
|
||||
getMediaAssetsByUserAndUrls,
|
||||
getPointsSystemConfig,
|
||||
getSystemSetting,
|
||||
upsertMediaAsset,
|
||||
} from "@/lib/db/queries";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
|
||||
const logger = createLogger("angle-edit-api");
|
||||
|
||||
// 角度编辑API配置类型
|
||||
type AngleEditConfig = {
|
||||
enabled?: boolean;
|
||||
falApiKey?: string;
|
||||
loraScale?: number;
|
||||
inferenceSteps?: number;
|
||||
guidanceScale?: number;
|
||||
maxImageSize?: number;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
// 设置API路由最大执行时间为3分钟(180秒)
|
||||
import { executeAngleEditPOST } from "@/lib/canvas/operations/angle-edit";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 180;
|
||||
|
||||
/**
|
||||
* 压缩图片生成缩略图
|
||||
*/
|
||||
async function createThumbnail(buffer: Buffer): Promise<Buffer> {
|
||||
try {
|
||||
const sharp = await import("sharp");
|
||||
|
||||
const thumbnailBuffer = await sharp.default(buffer)
|
||||
.resize(500, null, {
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.jpeg({ quality: 80 })
|
||||
.toBuffer();
|
||||
|
||||
logger.debug("thumbnail_created", {
|
||||
originalBytes: buffer.length,
|
||||
thumbnailBytes: thumbnailBuffer.length,
|
||||
});
|
||||
return thumbnailBuffer;
|
||||
} catch (error) {
|
||||
logger.warn("thumbnail_creation_failed", error);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 base64 图片上传到 S3
|
||||
*/
|
||||
async function uploadBase64ToS3(base64Data: string, prefix: string = "angle-edit"): Promise<{ thumbnail: string; original: string }> {
|
||||
try {
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
const timestamp = Date.now();
|
||||
const randomStr = Math.random().toString(36).slice(2, 8);
|
||||
const thumbnailFilename = `${prefix}-thumb-${timestamp}-${randomStr}.png`;
|
||||
const originalFilename = `${prefix}-orig-${timestamp}-${randomStr}.png`;
|
||||
|
||||
logger.debug("source_image_prepared", { originalBytes: buffer.length });
|
||||
|
||||
const thumbnailBuffer = await createThumbnail(buffer);
|
||||
|
||||
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
|
||||
thumbnailBuffer.byteOffset,
|
||||
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
const originalArrayBuffer = buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
|
||||
logger.debug("storage_upload_started", {
|
||||
hasOriginal: originalArrayBuffer.byteLength > 0,
|
||||
hasThumbnail: thumbnailArrayBuffer.byteLength > 0,
|
||||
prefix,
|
||||
});
|
||||
const [thumbnailResult, originalResult] = await Promise.all([
|
||||
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
|
||||
uploadFile(originalFilename, originalArrayBuffer),
|
||||
]);
|
||||
|
||||
logger.info("storage_upload_completed", {
|
||||
originalUrl: originalResult.url,
|
||||
thumbnailUrl: thumbnailResult.url,
|
||||
});
|
||||
|
||||
return {
|
||||
thumbnail: thumbnailResult.url,
|
||||
original: originalResult.url,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("storage_upload_failed", error);
|
||||
throw new Error(`图片上传失败: ${error instanceof Error ? error.message : "未知错误"}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 1. 验证用户登录
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
logger.info("request_received", { authenticated: true });
|
||||
|
||||
// 2. 从数据库获取配置
|
||||
const angleEditConfig = await getSystemSetting({ key: "angle_edit_config" });
|
||||
const config = (angleEditConfig?.value as AngleEditConfig) || {};
|
||||
|
||||
// 验证功能是否启用
|
||||
if (!config.enabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "角度编辑功能未启用,请联系管理员在系统设置中开启" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证 API Key
|
||||
if (!config.falApiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "fal.ai API Key 未配置,请联系管理员配置" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("config_loaded", {
|
||||
enabled: Boolean(config.enabled),
|
||||
hasApiKey: Boolean(config.falApiKey),
|
||||
loraScale: config.loraScale || 1,
|
||||
});
|
||||
|
||||
// 3. 解析请求
|
||||
const body = await request.json();
|
||||
const {
|
||||
image_url,
|
||||
image_base64,
|
||||
// 数值参数 (官方 API 格式)
|
||||
horizontal_angle, // 0-360 度
|
||||
vertical_angle, // -30 到 90 度
|
||||
zoom, // 0-10
|
||||
lora_scale,
|
||||
} = body;
|
||||
|
||||
if (!image_url && !image_base64) {
|
||||
return NextResponse.json({ error: "缺少图片数据" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 使用请求中的参数或配置中的默认值
|
||||
const finalLoraScale = lora_scale ?? config.loraScale ?? 1;
|
||||
const finalHorizontalAngle = horizontal_angle ?? 0;
|
||||
const finalVerticalAngle = vertical_angle ?? 0;
|
||||
const finalZoom = zoom ?? 5;
|
||||
|
||||
logger.info("request_parameters_normalized", {
|
||||
hasBase64Input: Boolean(image_base64),
|
||||
hasUrlInput: Boolean(image_url),
|
||||
horizontalAngle: finalHorizontalAngle,
|
||||
loraScale: finalLoraScale,
|
||||
verticalAngle: finalVerticalAngle,
|
||||
zoom: finalZoom,
|
||||
});
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "angle-edit"),
|
||||
reason: "image_angle_edit",
|
||||
modelId: "qwen-image-edit-2511-multiple-angles",
|
||||
note: "多角度编辑消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
// 4. 准备图片输入
|
||||
let imageInput: string;
|
||||
if (image_url) {
|
||||
imageInput = image_url;
|
||||
} else {
|
||||
const mimeType = image_base64.startsWith("/9j/") ? "image/jpeg" : "image/png";
|
||||
imageInput = `data:${mimeType};base64,${image_base64}`;
|
||||
}
|
||||
|
||||
// 5. 动态导入 fal.ai SDK 并调用官方多角度 API
|
||||
logger.info("provider_call_started", {
|
||||
modelId: "fal-ai/qwen-image-edit-2511-multiple-angles",
|
||||
});
|
||||
|
||||
const fal = await import("@fal-ai/serverless-client");
|
||||
|
||||
fal.config({
|
||||
credentials: config.falApiKey,
|
||||
});
|
||||
|
||||
// 使用官方多角度 API 端点
|
||||
// 文档: https://fal.ai/models/fal-ai/qwen-image-edit-2511-multiple-angles
|
||||
const result = await fal.subscribe("fal-ai/qwen-image-edit-2511-multiple-angles", {
|
||||
input: {
|
||||
image_urls: [imageInput], // 官方使用 image_urls (数组)
|
||||
horizontal_angle: finalHorizontalAngle, // 水平旋转角度 0-360
|
||||
vertical_angle: finalVerticalAngle, // 垂直角度 -30 到 90
|
||||
zoom: finalZoom, // 缩放 0-10
|
||||
lora_scale: finalLoraScale, // LoRA 强度
|
||||
num_inference_steps: config.inferenceSteps || 28,
|
||||
guidance_scale: config.guidanceScale || 4.5, // 官方默认 4.5
|
||||
enable_safety_checker: true,
|
||||
output_format: "png",
|
||||
},
|
||||
logs: true,
|
||||
onQueueUpdate: (update) => {
|
||||
if (update.status === "IN_PROGRESS") {
|
||||
logger.debug("provider_progress", { status: update.status });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 6. 处理结果
|
||||
const apiResult = result as { images?: Array<{ url?: string }>; prompt?: string };
|
||||
const outputImage = apiResult.images?.[0];
|
||||
if (!outputImage?.url) {
|
||||
throw new Error("API 未返回有效图片");
|
||||
}
|
||||
|
||||
const outputImageUrl = outputImage.url;
|
||||
logger.info("provider_call_completed", {
|
||||
hasGeneratedPrompt: Boolean(apiResult.prompt),
|
||||
imageCount: apiResult.images?.length ?? 0,
|
||||
outputImageUrl,
|
||||
});
|
||||
|
||||
// 7. 下载图片并上传到我们的 S3
|
||||
const imageResponse = await fetch(outputImageUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error(`下载输出图片失败: ${imageResponse.status}`);
|
||||
}
|
||||
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
||||
const imageBase64Result = imageBuffer.toString("base64");
|
||||
|
||||
const s3Result = await uploadBase64ToS3(imageBase64Result, "angle-edit");
|
||||
logger.info("result_image_persisted", {
|
||||
originalUrl: s3Result.original,
|
||||
thumbnailUrl: s3Result.thumbnail,
|
||||
});
|
||||
|
||||
// 8. 保存到数据库
|
||||
const processingTime = (Date.now() - startTime) / 1000;
|
||||
|
||||
const [savedImage] = await db
|
||||
.insert(generatedImage)
|
||||
.values({
|
||||
userId: session.user.id,
|
||||
thumbnailUrl: s3Result.thumbnail,
|
||||
originalUrl: s3Result.original,
|
||||
prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`,
|
||||
modelId: "qwen-image-edit-2511-multiple-angles",
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume({
|
||||
note: `多角度编辑消耗积分(${savedImage.id})`,
|
||||
});
|
||||
} catch (pointsError) {
|
||||
logger.error("points_consume_failed", pointsError, {
|
||||
imageId: savedImage.id,
|
||||
userId: session.user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceAssets =
|
||||
typeof image_url === "string" && image_url.trim()
|
||||
? await getMediaAssetsByUserAndUrls({
|
||||
userId: session.user.id,
|
||||
urls: [image_url],
|
||||
})
|
||||
: [];
|
||||
const media = await upsertMediaAsset({
|
||||
userId: session.user.id,
|
||||
mediaType: "image",
|
||||
sourceType: "edited",
|
||||
status: "ready",
|
||||
originalUrl: s3Result.original,
|
||||
previewUrl: s3Result.original,
|
||||
thumbnailUrl: s3Result.thumbnail,
|
||||
displayName: `角度编辑 ${finalHorizontalAngle}/${finalVerticalAngle}`.slice(0, 80),
|
||||
prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`,
|
||||
modelId: "qwen-image-edit-2511-multiple-angles",
|
||||
sourceTable: "GeneratedImage",
|
||||
sourceRowId: savedImage.id,
|
||||
primaryParentAssetId: sourceAssets[0]?.id ?? null,
|
||||
metadata: {
|
||||
horizontalAngle: finalHorizontalAngle,
|
||||
verticalAngle: finalVerticalAngle,
|
||||
zoom: finalZoom,
|
||||
},
|
||||
});
|
||||
if (media) {
|
||||
for (const sourceAsset of sourceAssets) {
|
||||
await createMediaAssetRelation({
|
||||
userId: session.user.id,
|
||||
fromAssetId: sourceAsset.id,
|
||||
toAssetId: media.id,
|
||||
relationType: "edited_from",
|
||||
metadata: {
|
||||
horizontalAngle: finalHorizontalAngle,
|
||||
verticalAngle: finalVerticalAngle,
|
||||
zoom: finalZoom,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (assetError) {
|
||||
logger.warn("media_asset_registration_failed", assetError);
|
||||
}
|
||||
|
||||
logger.info("request_completed", {
|
||||
processingSeconds: Number(processingTime.toFixed(2)),
|
||||
});
|
||||
|
||||
// 9. 返回结果
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
image_url: s3Result.original,
|
||||
thumbnail_url: s3Result.thumbnail,
|
||||
image_base64: imageBase64Result,
|
||||
processing_time: processingTime,
|
||||
angles: {
|
||||
horizontal_angle: finalHorizontalAngle,
|
||||
vertical_angle: finalVerticalAngle,
|
||||
zoom: finalZoom,
|
||||
},
|
||||
prompt: apiResult.prompt,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error("request_failed", error);
|
||||
|
||||
// 输出详细的错误信息
|
||||
if (error && typeof error === "object" && "status" in error) {
|
||||
const apiError = error as { status?: number; body?: unknown; message?: string };
|
||||
logger.warn("provider_error_summary", {
|
||||
bodyType: typeof apiError.body,
|
||||
hasBody: apiError.body != null,
|
||||
message: apiError.message,
|
||||
status: apiError.status,
|
||||
});
|
||||
|
||||
if (apiError.status === 403) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "API 访问被拒绝 (403 Forbidden)。请检查 API Key 是否正确,以及是否有权限访问该模型。",
|
||||
details: apiError.body,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "角度编辑处理失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
return executeAngleEditPOST(request, session.user.id);
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
|
||||
"worldlabs.ai",
|
||||
"s3.1080p.hk",
|
||||
"img.yydd.ai",
|
||||
"scqny.jiansun.vip",
|
||||
"blob.vercel-storage.com",
|
||||
"aliyuncs.com",
|
||||
"amazonaws.com",
|
||||
|
||||
@ -102,7 +102,7 @@ export async function GET(
|
||||
// 更新画布(支持 upsert:如果不存在则创建)
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
@ -116,29 +116,33 @@ export async function PUT(
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const { title, description, thumbnail, canvasData } = body;
|
||||
const baseCanvasRevision = body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
|
||||
const baseCanvasRevision =
|
||||
body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
|
||||
const workspaceType =
|
||||
body && Object.prototype.hasOwnProperty.call(body, "workspaceType")
|
||||
? resolveWorkspaceType(body.workspaceType)
|
||||
: undefined;
|
||||
|
||||
// 先检查画布是否存在
|
||||
const [existingProject] = await db
|
||||
.select()
|
||||
.from(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.id, id),
|
||||
eq(canvasProject.userId, session.user.id)
|
||||
return await db.transaction(async (tx) => {
|
||||
const [existingProject] = await tx
|
||||
.select()
|
||||
.from(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.id, id),
|
||||
eq(canvasProject.userId, session.user.id),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
if (existingProject) {
|
||||
.for("update");
|
||||
if (
|
||||
existingProject &&
|
||||
canvasData !== undefined &&
|
||||
baseCanvasRevision &&
|
||||
isStaleCanvasSaveBaseRevision({
|
||||
@ -149,101 +153,64 @@ export async function PUT(
|
||||
) {
|
||||
return NextResponse.json(
|
||||
buildStaleCanvasSaveConflictPayload(existingProject.updatedAt),
|
||||
{ status: 409 }
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!existingProject) {
|
||||
// The parent must exist before node/asset foreign keys are synchronized.
|
||||
const [created] = await tx
|
||||
.insert(canvasProject)
|
||||
.values({
|
||||
id,
|
||||
userId: session.user.id,
|
||||
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
|
||||
workspaceType: workspaceType || "canvas",
|
||||
canvasData: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})
|
||||
.onConflictDoNothing({ target: canvasProject.id })
|
||||
.returning();
|
||||
if (!created)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "画布已被创建,请重新加载后保存" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
const syncedCanvasData =
|
||||
canvasData !== undefined
|
||||
? await syncCanvasNodesForProject({
|
||||
projectId: id,
|
||||
userId: session.user.id,
|
||||
canvasData,
|
||||
})
|
||||
? await syncCanvasNodesForProject(
|
||||
{ projectId: id, userId: session.user.id, canvasData },
|
||||
tx,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// 更新现有画布
|
||||
const updateData: any = { updatedAt: new Date() };
|
||||
const updateData: any = { updatedAt: new Date(Math.max(Date.now(), (existingProject?.updatedAt.getTime() || 0) + 1)) };
|
||||
if (title !== undefined) updateData.title = title;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (thumbnail !== undefined) updateData.thumbnail = thumbnail;
|
||||
if (workspaceType !== undefined) updateData.workspaceType = workspaceType;
|
||||
if (syncedCanvasData !== undefined) updateData.canvasData = syncedCanvasData;
|
||||
|
||||
const [updatedProject] = await db
|
||||
if (syncedCanvasData !== undefined)
|
||||
updateData.canvasData = syncedCanvasData;
|
||||
const [saved] = await tx
|
||||
.update(canvasProject)
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.id, id),
|
||||
eq(canvasProject.userId, session.user.id)
|
||||
)
|
||||
eq(canvasProject.userId, session.user.id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ success: true, data: updatedProject });
|
||||
} else {
|
||||
const syncedCanvasData =
|
||||
canvasData !== undefined
|
||||
? await syncCanvasNodesForProject({
|
||||
projectId: id,
|
||||
userId: session.user.id,
|
||||
canvasData,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const nextWorkspaceType = workspaceType || "canvas";
|
||||
const nextCanvasData = syncedCanvasData || {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
};
|
||||
const nextTitle = title || getDefaultWorkspaceTitle(nextWorkspaceType);
|
||||
const insertData: any = {
|
||||
id, // 使用指定的 ID
|
||||
userId: session.user.id,
|
||||
title: nextTitle,
|
||||
workspaceType: nextWorkspaceType,
|
||||
canvasData: nextCanvasData,
|
||||
};
|
||||
const conflictUpdateData: any = {
|
||||
title: nextTitle,
|
||||
workspaceType: nextWorkspaceType,
|
||||
canvasData: nextCanvasData,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (description !== undefined) {
|
||||
insertData.description = description;
|
||||
conflictUpdateData.description = description;
|
||||
}
|
||||
if (thumbnail !== undefined) {
|
||||
insertData.thumbnail = thumbnail;
|
||||
conflictUpdateData.thumbnail = thumbnail;
|
||||
}
|
||||
|
||||
// 创建新画布(使用指定 ID),并兼容同一用户并发创建同一画布的场景。
|
||||
const [newProject] = await db
|
||||
.insert(canvasProject)
|
||||
.values(insertData)
|
||||
.onConflictDoUpdate({
|
||||
target: canvasProject.id,
|
||||
set: conflictUpdateData,
|
||||
where: eq(canvasProject.userId, session.user.id),
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!newProject) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '画布ID已被占用' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: newProject });
|
||||
}
|
||||
return NextResponse.json({ success: true, data: saved });
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('更新/创建画布失败:', error);
|
||||
return NextResponse.json({ success: false, error: '更新画布失败' }, { status: 500 });
|
||||
console.error("更新/创建画布失败:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "更新画布失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
24
app/api/canvas/audio/models/route.ts
Normal file
24
app/api/canvas/audio/models/route.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { listCanvasAudioModels } from "@/lib/video-canvas/audio-generation";
|
||||
import { audioContract } from "@/lib/video-canvas/audio-contract";
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
try {
|
||||
const models = await listCanvasAudioModels();
|
||||
return NextResponse.json(
|
||||
{
|
||||
models: models.map(({ model }) => ({
|
||||
id: model.id,
|
||||
modelId: model.modelId,
|
||||
name: model.displayName,
|
||||
...audioContract(model.modelId),
|
||||
})),
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "音频模型读取失败,请稍后重试" }, { status: 503 });
|
||||
}
|
||||
}
|
||||
45
app/api/canvas/director/upload-model/route.ts
Normal file
45
app/api/canvas/director/upload-model/route.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import {
|
||||
DIRECTOR_MAX_GLB_BYTES,
|
||||
validateDirectorGlb,
|
||||
} from "@/lib/video-canvas/director-glb";
|
||||
export const runtime = "nodejs";
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id)
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
try {
|
||||
const form = await request.formData();
|
||||
const file = form.get("file");
|
||||
if (
|
||||
!(file instanceof File) ||
|
||||
!/\.glb$/i.test(file.name) ||
|
||||
file.size > DIRECTOR_MAX_GLB_BYTES
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: "请上传 25 MiB 以内的 GLB 模型" },
|
||||
{ status: 400 },
|
||||
);
|
||||
const buffer = await file.arrayBuffer();
|
||||
try {
|
||||
validateDirectorGlb(buffer);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "无效 GLB" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const result = await uploadFile(
|
||||
`director-${crypto.randomUUID()}.glb`,
|
||||
buffer,
|
||||
);
|
||||
return NextResponse.json({ success: true, data: result });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "模型上传失败,请重试" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,63 +1,10 @@
|
||||
import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
createCanvasImageTask,
|
||||
enqueueCanvasImageTask,
|
||||
getCanvasImageTaskForUser,
|
||||
} from "@/lib/canvas/image-task-queue";
|
||||
import {
|
||||
generateCanvasImage,
|
||||
type CanvasGenerateImageRequest,
|
||||
} from "@/lib/canvas/generate-image";
|
||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import {
|
||||
getImageToolCreditCost,
|
||||
type ImageToolCreditFeature,
|
||||
} from "@/lib/points/config";
|
||||
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
|
||||
|
||||
export const maxDuration = 1200;
|
||||
|
||||
interface GenerateImageRequest extends CanvasGenerateImageRequest {
|
||||
async?: boolean;
|
||||
}
|
||||
|
||||
function normalizeBillingFeature(
|
||||
value: unknown
|
||||
): Extract<ImageToolCreditFeature, "multi-view" | "repaint"> | null {
|
||||
if (value === "multi-view" || value === "repaint") return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImageRequest {
|
||||
return {
|
||||
allowFailover: typeof body.allowFailover === "boolean" ? body.allowFailover : undefined,
|
||||
prompt: String(body.prompt || "").trim(),
|
||||
negativePrompt:
|
||||
typeof body.negativePrompt === "string" ? body.negativePrompt : undefined,
|
||||
model: typeof body.model === "string" ? body.model : undefined,
|
||||
aspectRatio:
|
||||
typeof body.aspectRatio === "string" && body.aspectRatio.trim()
|
||||
? body.aspectRatio.trim()
|
||||
: "auto",
|
||||
imageSize:
|
||||
typeof body.imageSize === "string" && body.imageSize.trim()
|
||||
? body.imageSize.trim()
|
||||
: undefined,
|
||||
imageCount:
|
||||
typeof body.imageCount === "number" && Number.isFinite(body.imageCount)
|
||||
? body.imageCount
|
||||
: 1,
|
||||
promptExtend:
|
||||
typeof body.promptExtend === "boolean" ? body.promptExtend : undefined,
|
||||
referenceImage:
|
||||
typeof body.referenceImage === "string" ? body.referenceImage : undefined,
|
||||
referenceImages: Array.isArray(body.referenceImages)
|
||||
? body.referenceImages.map((item) => String(item || "").trim()).filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
@ -68,10 +15,7 @@ export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const taskId = String(searchParams.get("taskId") || "").trim();
|
||||
if (!taskId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "缺少 taskId 参数" },
|
||||
{ status: 400 }
|
||||
);
|
||||
return NextResponse.json({ success: false, error: "缺少 taskId 参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const task = await getCanvasImageTaskForUser({ taskId, userId: session.user.id });
|
||||
@ -100,105 +44,15 @@ export async function GET(request: Request) {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "查询任务失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
{ 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: GenerateImageRequest = await request.json();
|
||||
const payload: CanvasGenerateImageRequest = {
|
||||
...toGenerateImagePayload(body),
|
||||
ownerKey: session.user.id,
|
||||
};
|
||||
if (!payload.prompt) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请输入提示词" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const billingFeature = normalizeBillingFeature(body.billingFeature);
|
||||
const pointsCharge = billingFeature
|
||||
? await (async () => {
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
return prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, billingFeature),
|
||||
reason:
|
||||
billingFeature === "multi-view"
|
||||
? "image_multi_view"
|
||||
: "image_repaint",
|
||||
modelId: "canvas-generate-image",
|
||||
note:
|
||||
billingFeature === "multi-view"
|
||||
? "图片多视角生成消耗积分"
|
||||
: "图片重绘消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
})()
|
||||
: null;
|
||||
if (pointsCharge && !pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const asyncMode = body.async === true;
|
||||
if (asyncMode) {
|
||||
const task = await createCanvasImageTask({
|
||||
userId: session.user.id,
|
||||
request: payload as unknown as Record<string, unknown>,
|
||||
});
|
||||
await enqueueCanvasImageTask(task.id);
|
||||
const currentTask = await getCanvasImageTaskForUser({
|
||||
taskId: task.id,
|
||||
userId: session.user.id,
|
||||
});
|
||||
if (pointsCharge?.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume({
|
||||
note:
|
||||
billingFeature === "multi-view"
|
||||
? `图片多视角生成消耗积分(${task.id})`
|
||||
: `图片重绘消耗积分(${task.id})`,
|
||||
});
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume canvas image task points:", pointsError);
|
||||
}
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
taskId: task.id,
|
||||
status: currentTask?.status || task.status,
|
||||
queueStatus: currentTask?.queueStatus,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await generateCanvasImage(payload);
|
||||
if (pointsCharge?.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume();
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume canvas image points:", pointsError);
|
||||
}
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Canvas Generate Image] Error:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "生成失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
const session = await auth();
|
||||
if (!session?.user?.id)
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
const body = await request.json().catch(() => ({}));
|
||||
return executeCanvasImageResponse(body, session.user.id);
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video";
|
||||
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
|
||||
import { probeRemoteUrl } from "@/lib/net/url-access";
|
||||
import { validateVideoReferences } from "@/lib/video-canvas/reference-input";
|
||||
import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai";
|
||||
|
||||
export const maxDuration = 300;
|
||||
@ -48,18 +49,11 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const inputImages = Array.isArray(body.images)
|
||||
? body.images.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 8)
|
||||
: [];
|
||||
const { kept: reachableImages, dropped: unreachableImages } =
|
||||
await filterReachableRemoteUrls(inputImages, { timeoutMs: 3000 });
|
||||
|
||||
if (unreachableImages.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "参考图片链接不可访问,请重新上传后再试" },
|
||||
{ status: 400 }
|
||||
);
|
||||
const referenceCheck = await validateVideoReferences(body.images, url => probeRemoteUrl(url, { timeoutMs: 8000 }));
|
||||
if (referenceCheck.error) {
|
||||
return NextResponse.json({ success: false, error: referenceCheck.error }, { status: referenceCheck.status });
|
||||
}
|
||||
const reachableImages = referenceCheck.images;
|
||||
|
||||
const mode = body.mode || inferMode(reachableImages);
|
||||
const result = await executeDirectVideoGeneration({
|
||||
@ -94,13 +88,13 @@ export async function POST(request: Request) {
|
||||
success: true,
|
||||
data: {
|
||||
url: result.videoUrl,
|
||||
metadata: result.metadata || null,
|
||||
metadata: { ...result.metadata, temporaryVideoUrl: result.videoUrl, storageStatus: result.metadata?.permanentVideoUrl === result.videoUrl ? "archived" : "temporary" },
|
||||
modelDisplayName: result.modelDisplayName,
|
||||
modelId: result.modelId,
|
||||
modelRef: result.modelRef,
|
||||
mode,
|
||||
images: reachableImages,
|
||||
droppedImages: unreachableImages,
|
||||
droppedImages: [],
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
125
app/api/canvas/generation-tasks/route.ts
Normal file
125
app/api/canvas/generation-tasks/route.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { smartEditRequestSchema } from "@/lib/video-canvas/smart-edit/contract";
|
||||
import { audioRequestSchema } from "@/lib/video-canvas/audio-contract";
|
||||
import { canResumeCanvasTask } from "@/lib/video-canvas/task-recovery";
|
||||
import { imageOperationSchema } from "@/lib/video-canvas/image-operation";
|
||||
import { after, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { z } from "zod";
|
||||
import { canvasTaskRequestSchema } from "@/lib/video-canvas/task-contract";
|
||||
import {
|
||||
cancelCanvasGenerationTask,
|
||||
createCanvasGenerationTask,
|
||||
listCanvasGenerationTasks,
|
||||
ownsTaskProject,
|
||||
} from "@/lib/video-canvas/task-store";
|
||||
import { runCanvasGenerationQueue } from "@/lib/video-canvas/task-worker";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 900;
|
||||
|
||||
async function owner(projectId: unknown) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw Object.assign(Error("未登录"), { status: 401 });
|
||||
if (!z.string().uuid().safeParse(projectId).success)
|
||||
throw Object.assign(Error("画布 ID 无效"), { status: 400 });
|
||||
if (!(await ownsTaskProject(session.user.id, projectId as string)))
|
||||
throw Object.assign(Error("画布不存在"), { status: 404 });
|
||||
return session.user.id;
|
||||
}
|
||||
const fail = (error: unknown) =>
|
||||
NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "任务操作失败" },
|
||||
{ status: Number((error as any)?.status) || (error instanceof SyntaxError ? 400 : 500) },
|
||||
);
|
||||
const kick = (userId: string, projectId: string) =>
|
||||
after(() =>
|
||||
runCanvasGenerationQueue(userId, projectId).catch((error) =>
|
||||
console.error("[Canvas tasks]", error),
|
||||
),
|
||||
);
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const text = await request.text();
|
||||
if (new TextEncoder().encode(text).byteLength > 262144)
|
||||
throw Object.assign(Error("任务数据过大,请使用已上传的素材链接"), { status: 413 });
|
||||
const parsed = canvasTaskRequestSchema.safeParse(JSON.parse(text));
|
||||
if (!parsed.success) throw Object.assign(Error("任务参数不合法"), { status: 400 });
|
||||
if (
|
||||
parsed.data.kind === "image-operation" &&
|
||||
!imageOperationSchema.safeParse(parsed.data.params).success
|
||||
)
|
||||
throw Object.assign(Error("图片处理参数不合法"), { status: 400 });
|
||||
if (
|
||||
parsed.data.attachment &&
|
||||
((parsed.data.attachment.type === "story-asset" && parsed.data.kind !== "image") ||
|
||||
(parsed.data.attachment.type === "story-prompts" && parsed.data.kind !== "analysis"))
|
||||
)
|
||||
throw Object.assign(Error("脚本结果类型不匹配"), { status: 400 });
|
||||
if (parsed.data.kind === "audio" && !audioRequestSchema.safeParse(parsed.data.params).success)
|
||||
throw Object.assign(Error("音频参数不合法"), { status: 400 });
|
||||
if (
|
||||
parsed.data.kind === "smart-edit" &&
|
||||
!smartEditRequestSchema.safeParse(parsed.data.params).success
|
||||
)
|
||||
throw Object.assign(Error("剪辑参数无效"), { status: 400 });
|
||||
const userId = await owner(parsed.data.projectId);
|
||||
const task = await createCanvasGenerationTask(userId, parsed.data);
|
||||
kick(userId, parsed.data.projectId);
|
||||
return NextResponse.json({ success: true, task }, { status: 202 });
|
||||
} catch (error) {
|
||||
return fail(error);
|
||||
}
|
||||
}
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const projectId = new URL(request.url).searchParams.get("projectId") || "";
|
||||
const userId = await owner(projectId);
|
||||
const tasks = await listCanvasGenerationTasks(userId, projectId);
|
||||
if (tasks.some((task) => task.status === "pending" || canResumeCanvasTask(task)))
|
||||
kick(userId, projectId);
|
||||
return NextResponse.json(
|
||||
{ success: true, tasks },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
return fail(error);
|
||||
}
|
||||
}
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { projectId, taskId } = await request.json();
|
||||
const userId = await owner(projectId);
|
||||
if (!z.string().uuid().safeParse(taskId).success)
|
||||
throw Object.assign(Error("任务 ID 无效"), { status: 400 });
|
||||
const result = await cancelCanvasGenerationTask(userId, projectId, taskId);
|
||||
if (!result.task)
|
||||
return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 });
|
||||
if (!result.ok)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
result.task.status === "running"
|
||||
? "任务已提交供应商,当前线路不支持中止。结果会继续保存,请勿重复生成。"
|
||||
: "任务已结束或状态待核实,请先刷新任务状态。",
|
||||
task: result.task,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
return NextResponse.json({ success: true, task: result.task });
|
||||
} catch (error) {
|
||||
return fail(error);
|
||||
}
|
||||
}
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const { projectId, taskId, nodeId } = await request.json();
|
||||
const userId = await owner(projectId);
|
||||
if (!z.string().uuid().safeParse(taskId).success || typeof nodeId !== "string")
|
||||
return NextResponse.json({ success: false }, { status: 400 });
|
||||
const { hideCanvasGenerationNode } = await import("@/lib/video-canvas/task-store");
|
||||
const success = await hideCanvasGenerationNode(userId, projectId, taskId, nodeId);
|
||||
return NextResponse.json({ success }, { status: success ? 200 : 404 });
|
||||
} catch (error) {
|
||||
return fail(error);
|
||||
}
|
||||
}
|
||||
50
app/api/canvas/media/upload/route.ts
Normal file
50
app/api/canvas/media/upload/route.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 180;
|
||||
const probe = promisify(execFile);
|
||||
const running = new Set<string>();
|
||||
const formats: Record<string, string> = {
|
||||
"video/mp4": "mp4", "video/quicktime": "mov", "video/webm": "webm",
|
||||
"audio/mpeg": "mp3", "audio/mp3": "mp3", "audio/mp4": "m4a", "audio/x-m4a": "m4a",
|
||||
"audio/wav": "wav", "audio/x-wav": "wav", "audio/ogg": "ogg", "audio/flac": "flac", "audio/webm": "webm",
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
if (running.has(session.user.id) || running.size >= 4) return NextResponse.json({ success: false, error: "正在上传,请稍后重试" }, { status: 429 });
|
||||
running.add(session.user.id);
|
||||
let dir = "";
|
||||
try {
|
||||
const form = await request.formData();
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File) || !formats[file.type]) return NextResponse.json({ success: false, error: "请上传 MP4、MOV、WebM 视频,或 MP3、M4A、WAV、OGG、FLAC 音频" }, { status: 400 });
|
||||
if (!file.size || file.size > 125 * 1024 * 1024) return NextResponse.json({ success: false, error: "素材必须非空且不超过 125 MiB" }, { status: 413 });
|
||||
dir = await mkdtemp(join(tmpdir(), "canvas-upload-"));
|
||||
const input = join(dir, "input");
|
||||
const buffer = await file.arrayBuffer();
|
||||
await writeFile(input, Buffer.from(buffer));
|
||||
const { stdout } = await probe("ffprobe", ["-v", "error", "-protocol_whitelist", "file", "-format_whitelist", "mov,matroska,webm,mp3,wav,ogg,flac", "-show_streams", "-show_format", "-of", "json", input], { timeout: 15000, maxBuffer: 1024 * 1024 });
|
||||
const info = JSON.parse(stdout);
|
||||
const kind = file.type.startsWith("video/") ? "video" : "audio";
|
||||
const stream = info.streams?.find((item: { codec_type: string }) => item.codec_type === kind);
|
||||
const duration = Number(info.format?.duration);
|
||||
if (!stream || !Number.isFinite(duration) || duration <= 0 || duration > 600) return NextResponse.json({ success: false, error: "请上传内容完整、10 分钟以内的音视频" }, { status: 400 });
|
||||
const uploaded = await uploadFile(`canvas-${randomUUID()}.${formats[file.type]}`, buffer);
|
||||
return NextResponse.json({ success: true, data: { url: uploaded.url, duration, width: stream.width, height: stream.height } });
|
||||
} catch {
|
||||
return NextResponse.json({ success: false, error: "素材上传失败,请确认文件完整或稍后重试" }, { status: 400 });
|
||||
} finally {
|
||||
running.delete(session.user.id);
|
||||
if (dir) await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
61
app/api/canvas/prompt-tools/route.ts
Normal file
61
app/api/canvas/prompt-tools/route.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { generateText } from "ai";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
const logger = createLogger("canvas-prompt-tools");
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||
import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries";
|
||||
import { VIDEO_CANVAS_FUNCTION_CONFIG_KEY, normalizeVideoCanvasFunctionConfig } from "@/lib/video-canvas/config";
|
||||
import { promptToolErrorMessage, promptToolFunctionKey, promptToolSchema, selectPromptToolModel } from "@/lib/video-canvas/prompt-tools";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 120;
|
||||
const running = new Set<string>();
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
const parsed = promptToolSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ success: false, error: parsed.error.issues[0]?.message || "参数无效" }, { status: 400 });
|
||||
if (running.has(session.user.id)) return NextResponse.json({ success: false, error: "提示词处理中,请稍候" }, { status: 429 });
|
||||
running.add(session.user.id);
|
||||
try {
|
||||
const [setting, available] = await Promise.all([
|
||||
getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }),
|
||||
getEnabledAiModelsWithProvider(false),
|
||||
]);
|
||||
const config = normalizeVideoCanvasFunctionConfig(setting?.value);
|
||||
const definition = config.functions[promptToolFunctionKey(parsed.data.mode)];
|
||||
if (!config.enabled || !definition?.enabled) return NextResponse.json({ success: false, error: "此提示词功能已停用" }, { status: 403 });
|
||||
const requested = parsed.data.model && parsed.data.model !== "auto"
|
||||
? parsed.data.model : definition.model && definition.model !== "auto"
|
||||
? definition.model : config.defaultMultimodalModel;
|
||||
let selected;
|
||||
try {
|
||||
selected = selectPromptToolModel(available
|
||||
.filter(item => item.provider.isEnabled && item.model.isEnabled && !item.model.isSubModel)
|
||||
.map(item => ({ ...item.model, providerType: item.provider.type })), requested);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ success: false, error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
const model = await getLanguageModelById(selected.id);
|
||||
if (!model) return NextResponse.json({ success: false, error: "提示词模型初始化失败" }, { status: 502 });
|
||||
const { text } = await generateText({
|
||||
model,
|
||||
system: definition.prompt,
|
||||
prompt: parsed.data.prompt,
|
||||
temperature: parsed.data.mode === "translate" ? 0.1 : 0.4,
|
||||
maxOutputTokens: 4000,
|
||||
maxRetries: 0,
|
||||
abortSignal: AbortSignal.any([request.signal, AbortSignal.timeout(90_000)]),
|
||||
});
|
||||
if (!text.trim()) throw new Error("empty model output");
|
||||
return NextResponse.json({ success: true, data: { text: text.trim(), model: selected.id, modelName: selected.displayName || selected.modelId } });
|
||||
} catch (error) {
|
||||
const detail = error as { name?: string; statusCode?: number; cause?: { code?: string }; data?: { error?: { code?: string } } };
|
||||
logger.warn("Prompt tool request failed", { name: detail?.name, statusCode: detail?.statusCode, code: detail?.data?.error?.code || detail?.cause?.code });
|
||||
return NextResponse.json({ success: false, error: promptToolErrorMessage(error) }, { status: 502 });
|
||||
} finally {
|
||||
running.delete(session.user.id);
|
||||
}
|
||||
}
|
||||
47
app/api/canvas/smart-edit/capabilities/route.ts
Normal file
47
app/api/canvas/smart-edit/capabilities/route.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import path from "node:path";
|
||||
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||||
import { isCanvasVisionModel } from "@/lib/video-canvas/model-matching";
|
||||
import { remoteRenderConfig } from "@/lib/video-canvas/smart-edit/remote-render";
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
try {
|
||||
let capabilities = { scenes: false, beats: false, asr: false, ffmpeg: false, ffprobe: false };
|
||||
try {
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.env.SMART_EDIT_PYTHON || "python3",
|
||||
[path.resolve("services/smart-edit/analyze.py"), "--check"],
|
||||
{ timeout: 10000, maxBuffer: 65536 },
|
||||
);
|
||||
capabilities = JSON.parse(stdout);
|
||||
} catch {}
|
||||
let renderAvailable = capabilities.ffmpeg;
|
||||
const remote = remoteRenderConfig();
|
||||
if (remote) {
|
||||
try {
|
||||
const response = await fetch(remote.base + "/health", {
|
||||
headers: { authorization: "Bearer " + remote.token },
|
||||
signal: AbortSignal.timeout(3000),
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
});
|
||||
renderAvailable = response.ok && (await response.json()).status === "ok";
|
||||
} catch {
|
||||
renderAvailable = false;
|
||||
}
|
||||
}
|
||||
const models = (await getEnabledAiModelsWithProvider(false))
|
||||
.filter((item) => isCanvasVisionModel({ ...item.model, providerType: item.provider.type }))
|
||||
.map(({ model }) => ({ id: model.id, name: model.displayName, modelId: model.modelId }));
|
||||
return NextResponse.json(
|
||||
{ capabilities: { ...capabilities, render: renderAvailable }, models },
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "剪辑服务配置读取失败" }, { status: 503 });
|
||||
}
|
||||
}
|
||||
23
app/api/canvas/video/archive/route.ts
Normal file
23
app/api/canvas/video/archive/route.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { archiveCanvasVideo } from "@/lib/video-canvas/archive-video";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 300;
|
||||
const running = new Set<string>();
|
||||
const schema = z.object({ url: z.string().url().max(12000) });
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
const parsed = schema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ success: false, error: "视频地址无效" }, { status: 400 });
|
||||
if (running.has(session.user.id)) return NextResponse.json({ success: false, error: "视频正在保存,请稍候" }, { status: 429 });
|
||||
running.add(session.user.id);
|
||||
try {
|
||||
const url = await archiveCanvasVideo(parsed.data.url);
|
||||
return NextResponse.json({ success: true, data: { url, storageStatus: "archived", permanentVideoUrl: url } });
|
||||
} catch {
|
||||
return NextResponse.json({ success: false, error: "视频转存失败,原视频仍保留;请稍后重试保存,无需重新生成" }, { status: 502 });
|
||||
} finally { running.delete(session.user.id); }
|
||||
}
|
||||
@ -1,39 +1,96 @@
|
||||
import {auth} from '@/app/(auth)/auth';
|
||||
import {NextResponse} from 'next/server';
|
||||
import {z} from 'zod';
|
||||
import {mkdtemp,readFile,rm} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {uploadFile} from '@/lib/storage/service';
|
||||
import {downloadCanvasMedia} from '@/lib/video-canvas/download-media';
|
||||
import {processVideoFile} from '@/lib/video-canvas/media-processing';
|
||||
export const runtime='nodejs';
|
||||
export const maxDuration=300;
|
||||
const running=new Set<string>();
|
||||
const schema=z.object({operation:z.enum(['crop','audio-video']),url:z.string().url(),rect:z.object({x:z.number(),y:z.number(),width:z.number(),height:z.number()}).optional()});
|
||||
export async function POST(request:Request) {
|
||||
const session=await auth();
|
||||
if(!session?.user?.id) return NextResponse.json({success:false,error:'未登录'},{status:401});
|
||||
const parsed=schema.safeParse(await request.json().catch(()=>null));
|
||||
if(!parsed.success) return NextResponse.json({success:false,error:'视频处理参数无效'},{status:400});
|
||||
if(running.has(session.user.id)||running.size>=2) return NextResponse.json({success:false,error:'视频处理中,请稍后再提交'},{status:429});
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { downloadCanvasMedia } from "@/lib/video-canvas/download-media";
|
||||
import { processVideoFile } from "@/lib/video-canvas/media-processing";
|
||||
import { videoProcessSchema } from "@/lib/video-canvas/media-contract";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 300;
|
||||
const running = new Set<string>();
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id)
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
const parsed = videoProcessSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success)
|
||||
return NextResponse.json({ success: false, error: "视频处理参数无效" }, { status: 400 });
|
||||
if (running.has(session.user.id) || running.size >= 2)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "视频处理中,请稍后再提交" },
|
||||
{ status: 429 },
|
||||
);
|
||||
running.add(session.user.id);
|
||||
let dir='';
|
||||
let dir = "";
|
||||
try {
|
||||
dir=await mkdtemp(join(tmpdir(),'canvas-video-'));
|
||||
const input=join(dir,'input');
|
||||
await downloadCanvasMedia(parsed.data.url,input);
|
||||
const save=async(name:string)=>{const buffer=await readFile(join(dir,name));return (await uploadFile(`canvas-video-${randomUUID()}-${name}`,new Uint8Array(buffer).buffer)).url;};
|
||||
if(parsed.data.operation==='crop') {
|
||||
const metadata=await processVideoFile(input,join(dir,'crop.mp4'),'crop',parsed.data.rect);
|
||||
return NextResponse.json({success:true,data:{url:await save('crop.mp4'),...metadata}});
|
||||
dir = await mkdtemp(join(tmpdir(), "canvas-video-"));
|
||||
const input = join(dir, "input");
|
||||
await downloadCanvasMedia(parsed.data.url, input);
|
||||
const save = async (name: string) => {
|
||||
const buffer = await readFile(join(dir, name));
|
||||
return (
|
||||
await uploadFile(`canvas-video-${randomUUID()}-${name}`, new Uint8Array(buffer).buffer)
|
||||
).url;
|
||||
};
|
||||
if (parsed.data.operation === "frame" || parsed.data.operation === "slow") {
|
||||
const name = parsed.data.operation === "frame" ? "frame.png" : "slow.mp4";
|
||||
const metadata = await processVideoFile(
|
||||
input,
|
||||
join(dir, name),
|
||||
parsed.data.operation,
|
||||
undefined,
|
||||
undefined,
|
||||
parsed.data,
|
||||
);
|
||||
return NextResponse.json({ success: true, data: { url: await save(name), ...metadata } });
|
||||
}
|
||||
const metadata=await processVideoFile(input,join(dir,'audio.m4a'),'extract-audio');
|
||||
await processVideoFile(input,join(dir,'silent.mp4'),'silent-video');
|
||||
return NextResponse.json({success:true,data:{audioUrl:await save('audio.m4a'),url:await save('silent.mp4'),...metadata}});
|
||||
} catch(error) {
|
||||
const message=error instanceof Error ? error.message : '视频处理失败';
|
||||
return NextResponse.json({success:false,error:message.startsWith('Command failed')?'视频处理失败,请确认文件完整且格式受支持':message},{status:400});
|
||||
} finally {running.delete(session.user.id);if(dir)await rm(dir,{recursive:true,force:true});}
|
||||
if (parsed.data.operation === "trim") {
|
||||
const metadata = await processVideoFile(
|
||||
input,
|
||||
join(dir, "clip.mp4"),
|
||||
"trim",
|
||||
undefined,
|
||||
parsed.data.range,
|
||||
);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { url: await save("clip.mp4"), ...metadata },
|
||||
});
|
||||
}
|
||||
if (parsed.data.operation === "crop") {
|
||||
const metadata = await processVideoFile(
|
||||
input,
|
||||
join(dir, "crop.mp4"),
|
||||
"crop",
|
||||
parsed.data.rect,
|
||||
);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { url: await save("crop.mp4"), ...metadata },
|
||||
});
|
||||
}
|
||||
const metadata = await processVideoFile(input, join(dir, "audio.m4a"), "extract-audio");
|
||||
await processVideoFile(input, join(dir, "silent.mp4"), "silent-video");
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { audioUrl: await save("audio.m4a"), url: await save("silent.mp4"), ...metadata },
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "视频处理失败";
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: message.startsWith("Command failed")
|
||||
? "视频处理失败,请确认文件完整且格式受支持"
|
||||
: message,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
} finally {
|
||||
running.delete(session.user.id);
|
||||
if (dir) await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,993 +1,12 @@
|
||||
import { isCanvasLanguageModel, isCanvasVisionModel } from "@/lib/video-canvas/model-matching";
|
||||
import { generateText } from "ai";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { executeWorkflowAnalysisResponse } from "@/lib/video-canvas/workflow-analysis-response";
|
||||
import { NextResponse } from "next/server";
|
||||
import sharp from "sharp";
|
||||
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,
|
||||
} from "@/lib/db/queries";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 180;
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const VIDEO_ANALYSIS_FRAME_COUNT = 4;
|
||||
const VIDEO_ANALYSIS_MAX_SIDE = 960;
|
||||
const VIDEO_ANALYSIS_TIMEOUT_MS = 15000;
|
||||
|
||||
const requestSchema = z.object({
|
||||
mode: z.enum(["video-analysis", "character-scene-extraction", "story-script", "agent-plan"]),
|
||||
prompt: z.string().trim().optional(),
|
||||
imageUrls: z.array(z.string().trim()).optional(),
|
||||
videoUrls: z.array(z.string().trim()).optional(),
|
||||
nodeTitle: z.string().trim().optional(),
|
||||
preferredModelRef: z.string().trim().optional(),
|
||||
model: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
function sanitizeUrls(value: unknown, limit = 6) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter((url) => /^https?:\/\//i.test(url))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function supportsVision(model: {
|
||||
supportsVision?: boolean | null;
|
||||
capabilities?: unknown;
|
||||
defaultParams?: unknown;
|
||||
}) {
|
||||
return (
|
||||
Boolean(model.supportsVision) ||
|
||||
isAiModelMultimodal({
|
||||
capabilities: model.capabilities,
|
||||
defaultParams: model.defaultParams,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function extractJsonBlock<T = Record<string, unknown>>(text: string): T | null {
|
||||
const matched = String(text || "").match(/\{[\s\S]*\}/);
|
||||
if (!matched) return null;
|
||||
try {
|
||||
return JSON.parse(matched[0]) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function shouldUseLocalWorkflowE2E(request: Request) {
|
||||
const hostname = new URL(request.url).hostname;
|
||||
return (
|
||||
process.env.CANVAS_E2E_LOCAL === "true" &&
|
||||
(hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeStringList(value: unknown, limit = 8) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return Array.from(
|
||||
new Set(
|
||||
value
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, limit)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeShotIdeas(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item, index) => {
|
||||
const raw =
|
||||
item && typeof item === "object" && !Array.isArray(item)
|
||||
? (item as Record<string, unknown>)
|
||||
: {};
|
||||
const title = normalizeText(raw.title || `镜头 ${index + 1}`);
|
||||
const description = normalizeText(raw.description || raw.prompt || raw.summary);
|
||||
if (!title && !description) return null;
|
||||
return {
|
||||
title: title || `镜头 ${index + 1}`,
|
||||
description,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(item): item is { title: string; description: string } => Boolean(item)
|
||||
)
|
||||
.slice(0, 6);
|
||||
}
|
||||
|
||||
function normalizeVideoAnalysisRows(
|
||||
value: unknown,
|
||||
framePreviews: string[],
|
||||
) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item, index) => {
|
||||
const raw =
|
||||
item && typeof item === "object" && !Array.isArray(item)
|
||||
? (item as Record<string, unknown>)
|
||||
: {};
|
||||
const sceneDescription = normalizeText(
|
||||
raw.sceneDescription || raw.scene || raw.title || raw.description,
|
||||
);
|
||||
const narrative = normalizeText(
|
||||
raw.narrative || raw.story || raw.storyContent || raw.narration || raw.summary,
|
||||
);
|
||||
const shot = normalizeText(raw.shot || raw.shotNumber || raw.index || index + 1);
|
||||
const startTime = normalizeText(raw.startTime || raw.start || raw.start_time);
|
||||
const endTime = normalizeText(raw.endTime || raw.end || raw.end_time);
|
||||
const duration = normalizeText(raw.duration || raw.durationSeconds || raw.duration_seconds);
|
||||
const shotSize = normalizeText(raw.shotSize || raw.frameSize || raw.shot_type || raw.sceneScale);
|
||||
const cameraAngle = normalizeText(raw.cameraAngle || raw.angle || raw.camera_angle);
|
||||
const cameraMovement = normalizeText(
|
||||
raw.cameraMovement || raw.cameraMove || raw.camera_motion || raw.motionType,
|
||||
);
|
||||
const focusDepth = normalizeText(
|
||||
raw.focusDepth || raw.depthOfField || raw.focalDepth || raw.focus_depth,
|
||||
);
|
||||
const lighting = normalizeText(raw.lighting || raw.light || raw.lightDesign);
|
||||
const backgroundMusic = normalizeText(
|
||||
raw.backgroundMusic || raw.music || raw.bgm,
|
||||
);
|
||||
const voiceAndSfx = normalizeText(
|
||||
raw.voiceAndSfx || raw.sound || raw.audio || raw.sfx,
|
||||
);
|
||||
const imagePrompt = normalizeText(
|
||||
raw.imagePrompt || raw.image_generation_prompt || raw.image,
|
||||
);
|
||||
const motionPrompt = normalizeText(
|
||||
raw.motionPrompt || raw.motion || raw.videoPrompt || raw.video_motion_prompt,
|
||||
);
|
||||
const keyframeIndex = Number(
|
||||
raw.keyframeIndex || raw.frameIndex || raw.key_frame_index || index + 1,
|
||||
);
|
||||
if (
|
||||
!sceneDescription &&
|
||||
!narrative &&
|
||||
!backgroundMusic &&
|
||||
!voiceAndSfx &&
|
||||
!imagePrompt &&
|
||||
!motionPrompt &&
|
||||
!shotSize &&
|
||||
!cameraAngle &&
|
||||
!cameraMovement &&
|
||||
!focusDepth &&
|
||||
!lighting
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const normalizedFrameIndex =
|
||||
Number.isFinite(keyframeIndex) && keyframeIndex > 0
|
||||
? Math.max(0, Math.min(framePreviews.length - 1, Math.round(keyframeIndex) - 1))
|
||||
: index % Math.max(framePreviews.length, 1);
|
||||
return {
|
||||
shot: shot || String(index + 1),
|
||||
startTime: startTime || (index === 0 ? "0" : ""),
|
||||
endTime,
|
||||
duration,
|
||||
sceneDescription: sceneDescription || `镜头 ${index + 1}`,
|
||||
narrative: narrative || sceneDescription || `镜头 ${index + 1}`,
|
||||
shotSize: shotSize || "中景至近景",
|
||||
cameraAngle: cameraAngle || "平视",
|
||||
cameraMovement: cameraMovement || "缓慢推镜头 (Slow Zoom In)",
|
||||
focusDepth: focusDepth || "浅景深",
|
||||
lighting: lighting || "冷调低沉照明,主体光线柔和。",
|
||||
backgroundMusic: backgroundMusic || "无",
|
||||
voiceAndSfx: voiceAndSfx || "无",
|
||||
imagePrompt: imagePrompt || sceneDescription || "延续当前视频气质生成关键帧",
|
||||
motionPrompt: motionPrompt || "保持镜头运动自然、连贯、符合前后节奏。",
|
||||
keyframeUrl: framePreviews[normalizedFrameIndex] || "",
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
item,
|
||||
): item is {
|
||||
shot: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
duration: string;
|
||||
sceneDescription: string;
|
||||
narrative: string;
|
||||
shotSize: string;
|
||||
cameraAngle: string;
|
||||
cameraMovement: string;
|
||||
focusDepth: string;
|
||||
lighting: string;
|
||||
backgroundMusic: string;
|
||||
voiceAndSfx: string;
|
||||
imagePrompt: string;
|
||||
motionPrompt: string;
|
||||
keyframeUrl: string;
|
||||
} => Boolean(item),
|
||||
)
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
function normalizeStoryShot(value: unknown, index: number) {
|
||||
const text = normalizeText(value);
|
||||
if (/^\d+$/.test(text)) return text.padStart(2, "0");
|
||||
return text || String(index + 1).padStart(2, "0");
|
||||
}
|
||||
|
||||
type NormalizedStoryScriptRow = {
|
||||
shot: string;
|
||||
duration: string;
|
||||
scene: string;
|
||||
sceneDescription: string;
|
||||
shotSize: string;
|
||||
lighting: string;
|
||||
dialogue: string;
|
||||
backgroundMusic: string;
|
||||
voiceAndSfx: string;
|
||||
cameraMovement: string;
|
||||
imagePrompt: string;
|
||||
motionPrompt: string;
|
||||
keyframeUrl: string;
|
||||
};
|
||||
|
||||
function normalizeStoryScriptRows(value: unknown): NormalizedStoryScriptRow[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item, index) => {
|
||||
if (Array.isArray(item)) {
|
||||
const cells = item.map((cell) => normalizeText(cell));
|
||||
const shot = normalizeStoryShot(cells[0], index);
|
||||
const secondCellLooksLikeDuration = /^\d+(\.\d+)?\s*(s|秒)?$/i.test(cells[1] || "");
|
||||
const duration = secondCellLooksLikeDuration ? cells[1] : "5s";
|
||||
const scene = secondCellLooksLikeDuration ? cells[2] || `镜头 ${shot}` : cells[1] || `镜头 ${shot}`;
|
||||
const sceneDescription = secondCellLooksLikeDuration ? cells[2] || scene : cells[2] || cells[3] || scene;
|
||||
return {
|
||||
shot,
|
||||
duration,
|
||||
scene,
|
||||
sceneDescription,
|
||||
shotSize: (secondCellLooksLikeDuration ? cells[3] : "") || "中景",
|
||||
lighting: (secondCellLooksLikeDuration ? cells[4] : cells[4]) || "电影感光影",
|
||||
dialogue: (secondCellLooksLikeDuration ? cells[5] : "") || "+",
|
||||
backgroundMusic: "无",
|
||||
voiceAndSfx: (secondCellLooksLikeDuration ? cells[6] : cells[3]) || "无",
|
||||
cameraMovement: (secondCellLooksLikeDuration ? cells[7] : "") || "缓慢推镜头",
|
||||
imagePrompt: (secondCellLooksLikeDuration ? cells[8] : cells[5]) || sceneDescription,
|
||||
motionPrompt:
|
||||
(secondCellLooksLikeDuration ? cells[9] || cells[7] : cells[6]) ||
|
||||
"保持镜头运动自然、连贯,围绕主体动作推进。",
|
||||
keyframeUrl: "",
|
||||
};
|
||||
}
|
||||
|
||||
const raw =
|
||||
item && typeof item === "object" && !Array.isArray(item)
|
||||
? (item as Record<string, unknown>)
|
||||
: {};
|
||||
const shot = normalizeStoryShot(raw.shot || raw.shotNumber || raw.index || raw.id, index);
|
||||
const duration = normalizeText(raw.duration || raw.length || raw.time || raw.seconds);
|
||||
const scene = normalizeText(raw.scene || raw.location || raw.title || `镜头 ${shot}`);
|
||||
const sceneDescription = normalizeText(
|
||||
raw.sceneDescription ||
|
||||
raw.description ||
|
||||
raw.visual ||
|
||||
raw.picture ||
|
||||
raw.screen ||
|
||||
raw.summary,
|
||||
);
|
||||
const shotSize = normalizeText(
|
||||
raw.shotSize || raw.frameSize || raw.shot_type || raw.sceneScale || raw.cameraShot,
|
||||
);
|
||||
const lighting = normalizeText(
|
||||
raw.lighting || raw.light || raw.atmosphere || raw.mood || raw.lightMood,
|
||||
);
|
||||
const dialogue = normalizeText(
|
||||
raw.dialogue || raw.dialog || raw.narration || raw.voiceover || raw.monologue || raw.line,
|
||||
);
|
||||
const cameraMovement = normalizeText(
|
||||
raw.cameraMovement || raw.cameraMove || raw.movement || raw.lensMovement || raw.motion,
|
||||
);
|
||||
const imagePrompt = normalizeText(
|
||||
raw.imagePrompt ||
|
||||
raw.image_generation_prompt ||
|
||||
raw.image ||
|
||||
raw.keyframePrompt ||
|
||||
raw.keyFramePrompt,
|
||||
);
|
||||
const motionPrompt = normalizeText(
|
||||
raw.motionPrompt ||
|
||||
raw.videoPrompt ||
|
||||
raw.video_motion_prompt ||
|
||||
raw.motion ||
|
||||
raw.cameraMovement,
|
||||
);
|
||||
const backgroundMusic = normalizeText(
|
||||
raw.backgroundMusic || raw.music || raw.bgm || raw.soundtrack,
|
||||
);
|
||||
const voiceAndSfx = normalizeText(
|
||||
raw.voiceAndSfx || raw.voice || raw.sound || raw.audio || raw.sfx,
|
||||
);
|
||||
|
||||
if (
|
||||
!scene &&
|
||||
!sceneDescription &&
|
||||
!imagePrompt &&
|
||||
!motionPrompt &&
|
||||
!backgroundMusic &&
|
||||
!voiceAndSfx
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
shot,
|
||||
duration: duration || "5s",
|
||||
scene,
|
||||
sceneDescription: sceneDescription || imagePrompt || scene || `镜头 ${shot}`,
|
||||
shotSize: shotSize || "中景",
|
||||
lighting: lighting || "电影感光影",
|
||||
dialogue: dialogue || "+",
|
||||
backgroundMusic: backgroundMusic || "无",
|
||||
voiceAndSfx: voiceAndSfx || "无",
|
||||
cameraMovement: cameraMovement || "缓慢推镜头",
|
||||
imagePrompt: imagePrompt || sceneDescription || scene || `镜头 ${shot}关键帧`,
|
||||
motionPrompt: motionPrompt || cameraMovement || "保持镜头运动自然、连贯,围绕主体动作推进。",
|
||||
keyframeUrl: "",
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
item,
|
||||
): item is NormalizedStoryScriptRow => Boolean(item),
|
||||
)
|
||||
.slice(0, 18);
|
||||
}
|
||||
|
||||
const storyExpansionPhases = [
|
||||
{
|
||||
description: "建立镜头,交代环境、人物位置和当前压力",
|
||||
cameraMovement: "缓慢推镜头",
|
||||
imagePrompt: "广角或中景建立画面,环境信息清晰,主体位置明确",
|
||||
lighting: "环境主光明确,氛围基调清晰",
|
||||
motionPrompt: "镜头缓慢推进或轻微横移,建立空间关系",
|
||||
shotSize: "中景",
|
||||
},
|
||||
{
|
||||
description: "动作推进,突出人物的关键行为或剧情转折",
|
||||
cameraMovement: "跟随主体动作推进",
|
||||
imagePrompt: "中景表现人物动作,服装、道具和光影保持连续",
|
||||
lighting: "主体光影连续,动作区域清晰",
|
||||
motionPrompt: "跟随主体动作推进,节奏自然连贯",
|
||||
shotSize: "中景",
|
||||
},
|
||||
{
|
||||
description: "情绪反应,用近景或特写强化人物心理和冲突",
|
||||
cameraMovement: "轻微推近或稳定特写",
|
||||
imagePrompt: "近景或特写,突出表情、眼神、手部或关键道具细节",
|
||||
lighting: "面部光线突出,情绪反差增强",
|
||||
motionPrompt: "轻微推近或稳定特写,强调情绪变化",
|
||||
shotSize: "特写",
|
||||
},
|
||||
{
|
||||
description: "关系与氛围,表现周围人物、环境反馈或声画张力",
|
||||
cameraMovement: "横移扫过环境或切换视角",
|
||||
imagePrompt: "加入环境反馈、群像反应或空间层次,电影感光影",
|
||||
lighting: "场景氛围增强,空间层次明显",
|
||||
motionPrompt: "镜头扫过环境或切换视角,保持节奏递进",
|
||||
shotSize: "全景",
|
||||
},
|
||||
{
|
||||
description: "转场收束,为下一镜头保留动作、视线或情绪钩子",
|
||||
cameraMovement: "顺势后移、上摇或转场",
|
||||
imagePrompt: "构图留出转场方向,动作或视线指向下一段剧情",
|
||||
lighting: "光影引导视线并保留转场方向",
|
||||
motionPrompt: "镜头后移、上摇或顺势转场,衔接下一镜头",
|
||||
shotSize: "大远景",
|
||||
},
|
||||
];
|
||||
|
||||
function expandStoryScriptRowsToTarget(
|
||||
rows: NormalizedStoryScriptRow[],
|
||||
targetCount = 18,
|
||||
): NormalizedStoryScriptRow[] {
|
||||
if (rows.length === 0) return [];
|
||||
if (rows.length >= targetCount) {
|
||||
return rows.slice(0, targetCount).map((row, index) => ({
|
||||
...row,
|
||||
shot: String(index + 1).padStart(2, "0"),
|
||||
}));
|
||||
}
|
||||
|
||||
const baseCount = Math.floor(targetCount / rows.length);
|
||||
const extraCount = targetCount % rows.length;
|
||||
const expanded: NormalizedStoryScriptRow[] = [];
|
||||
|
||||
rows.forEach((row, rowIndex) => {
|
||||
const segmentCount = baseCount + (rowIndex < extraCount ? 1 : 0);
|
||||
for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) {
|
||||
const phase =
|
||||
storyExpansionPhases[
|
||||
Math.min(
|
||||
storyExpansionPhases.length - 1,
|
||||
Math.floor((segmentIndex / Math.max(segmentCount, 1)) * storyExpansionPhases.length),
|
||||
)
|
||||
];
|
||||
expanded.push({
|
||||
...row,
|
||||
shot: String(expanded.length + 1).padStart(2, "0"),
|
||||
sceneDescription: `${phase.description}:${row.sceneDescription}`,
|
||||
shotSize: row.shotSize || phase.shotSize,
|
||||
lighting: row.lighting || phase.lighting,
|
||||
cameraMovement: row.cameraMovement || phase.cameraMovement,
|
||||
imagePrompt: `${row.imagePrompt};${phase.imagePrompt}`,
|
||||
motionPrompt: `${phase.motionPrompt};${row.motionPrompt}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return expanded.slice(0, targetCount);
|
||||
}
|
||||
|
||||
function normalizeCharacters(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => {
|
||||
const raw =
|
||||
item && typeof item === "object" && !Array.isArray(item)
|
||||
? (item as Record<string, unknown>)
|
||||
: {};
|
||||
const name = normalizeText(raw.name || raw.title);
|
||||
const description = normalizeText(raw.description || raw.summary);
|
||||
const prompt = normalizeText(raw.prompt);
|
||||
if (!name && !description && !prompt) return null;
|
||||
return {
|
||||
name: name || "未命名角色",
|
||||
description,
|
||||
prompt,
|
||||
look: normalizeText(raw.look) || undefined,
|
||||
wardrobe: normalizeText(raw.wardrobe) || undefined,
|
||||
signatureAction: normalizeText(raw.signatureAction) || undefined,
|
||||
traits: normalizeStringList(raw.traits, 6),
|
||||
};
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
function normalizeScenes(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => {
|
||||
const raw =
|
||||
item && typeof item === "object" && !Array.isArray(item)
|
||||
? (item as Record<string, unknown>)
|
||||
: {};
|
||||
const name = normalizeText(raw.name || raw.title);
|
||||
const description = normalizeText(raw.description || raw.summary);
|
||||
const prompt = normalizeText(raw.prompt);
|
||||
if (!name && !description && !prompt) return null;
|
||||
return {
|
||||
name: name || "未命名场景",
|
||||
description,
|
||||
prompt,
|
||||
mood: normalizeText(raw.mood) || undefined,
|
||||
lighting: normalizeText(raw.lighting) || undefined,
|
||||
props: normalizeStringList(raw.props, 6),
|
||||
};
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
function buildVideoAnalysisPrompt(params: {
|
||||
nodeTitle?: string;
|
||||
prompt: string;
|
||||
frameCount: number;
|
||||
imageCount: number;
|
||||
videoCount: number;
|
||||
}) {
|
||||
return [
|
||||
"你是视频画布工作流里的视频分析节点,需要为后续分镜和生成节点提供结构化输出。",
|
||||
params.nodeTitle ? `当前节点:${params.nodeTitle}` : "",
|
||||
`上游参考图数量:${params.imageCount}`,
|
||||
`上游视频数量:${params.videoCount}`,
|
||||
`已提供按时间顺序抽取的视频关键帧数量:${params.frameCount}`,
|
||||
params.prompt ? `上游脚本/说明:\n${params.prompt}` : "上游没有额外脚本,请重点依据参考画面分析。",
|
||||
"",
|
||||
"请只输出 JSON,不要输出 markdown,不要输出解释。",
|
||||
"JSON 结构如下:",
|
||||
`{
|
||||
"summary": "一句话总结当前视频/镜头风格,不超过120字",
|
||||
"highlights": ["3-5条镜头分析要点"],
|
||||
"analysisRows": [
|
||||
{
|
||||
"shot": "1",
|
||||
"startTime": "0",
|
||||
"endTime": "5",
|
||||
"duration": "5",
|
||||
"sceneDescription": "画面描述,说明主体、环境、动作、构图和色调",
|
||||
"narrative": "叙事内容,说明这一镜头承担的剧情/情绪/信息作用",
|
||||
"shotSize": "景别,例如中景至近景",
|
||||
"cameraAngle": "摄影机角度,例如平视",
|
||||
"cameraMovement": "摄影机运动,例如缓慢推镜头 (Slow Zoom In)",
|
||||
"focusDepth": "焦距与景深,例如浅景深",
|
||||
"lighting": "光线,例如冷调低沉照明,屏幕光作为侧逆光",
|
||||
"backgroundMusic": "这一段适合的背景音乐氛围",
|
||||
"voiceAndSfx": "人声/环境声/音效建议,没有就写无",
|
||||
"imagePrompt": "后续生成关键帧时可直接使用的英文图像提示词",
|
||||
"motionPrompt": "后续生成视频时可直接使用的英文运动提示词",
|
||||
"keyframeIndex": 1
|
||||
}
|
||||
],
|
||||
"shotIdeas": [
|
||||
{ "title": "镜头 1", "description": "一句话描述镜头目标、景别或动作" }
|
||||
],
|
||||
"visualMotifs": ["2-4个可复用的视觉母题"],
|
||||
"recommendedNextSteps": ["2-4条建议的下游动作"]
|
||||
}`,
|
||||
"analysisRows 请按镜头时间顺序输出;短视频可以输出 1 行,复杂视频输出 2-6 行。",
|
||||
"每行必须覆盖镜号、开始时间、结束时间、时长、画面描述、叙事内容、景别、摄影机角度、摄影机运动、焦距与景深、光线、背景音乐、人声/音效、图像生成提示词、视频运动提示词和关键帧。",
|
||||
"imagePrompt 和 motionPrompt 优先使用英文;其他字段使用中文;keyframeIndex 使用 1 开始的序号,对应你看到的关键帧顺序。",
|
||||
"如果没有真实视频帧,只能依据参考图和脚本推断,也要正常输出,但措辞要稳健。",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
async function getVideoDurationSeconds(videoUrl: string) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"ffprobe",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
videoUrl,
|
||||
],
|
||||
{
|
||||
timeout: VIDEO_ANALYSIS_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const duration = Number.parseFloat(String(stdout || "").trim());
|
||||
return Number.isFinite(duration) && duration > 0 ? duration : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildVideoFrameTimestamps(durationSeconds: number | null, frameCount: number) {
|
||||
const safeCount = Math.max(1, frameCount);
|
||||
if (!durationSeconds || !Number.isFinite(durationSeconds) || durationSeconds <= 0.5) {
|
||||
return Array.from({ length: safeCount }).map((_, index) =>
|
||||
Number((0.3 + index * 0.45).toFixed(2)),
|
||||
);
|
||||
}
|
||||
const start = Math.min(0.35, durationSeconds * 0.12);
|
||||
const end = Math.max(start + 0.6, durationSeconds * 0.88);
|
||||
if (safeCount === 1) return [Number(((start + end) / 2).toFixed(2))];
|
||||
const step = (end - start) / (safeCount - 1);
|
||||
return Array.from({ length: safeCount }).map((_, index) =>
|
||||
Number((start + step * index).toFixed(2)),
|
||||
);
|
||||
}
|
||||
|
||||
async function downscaleVideoFrameForVision(inputBuffer: Buffer) {
|
||||
return sharp(inputBuffer, { failOn: "none" })
|
||||
.rotate()
|
||||
.resize({
|
||||
width: VIDEO_ANALYSIS_MAX_SIDE,
|
||||
height: VIDEO_ANALYSIS_MAX_SIDE,
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.jpeg({ quality: 82, mozjpeg: true })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
async function extractVideoFrameDataUrls(videoUrl: string, frameCount = VIDEO_ANALYSIS_FRAME_COUNT) {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "canvas-video-analysis-"));
|
||||
try {
|
||||
const durationSeconds = await getVideoDurationSeconds(videoUrl);
|
||||
const timestamps = buildVideoFrameTimestamps(durationSeconds, frameCount);
|
||||
const frames = await Promise.all(
|
||||
timestamps.map(async (timestamp, index) => {
|
||||
const outputPath = path.join(tempDir, `frame-${index + 1}.jpg`);
|
||||
await execFileAsync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-ss",
|
||||
String(timestamp),
|
||||
"-i",
|
||||
videoUrl,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-q:v",
|
||||
"4",
|
||||
"-y",
|
||||
outputPath,
|
||||
],
|
||||
{
|
||||
timeout: VIDEO_ANALYSIS_TIMEOUT_MS,
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const buffer = await readFile(outputPath);
|
||||
const processed = await downscaleVideoFrameForVision(buffer);
|
||||
return `data:image/jpeg;base64,${processed.toString("base64")}`;
|
||||
}),
|
||||
);
|
||||
return frames.filter(Boolean);
|
||||
} catch (error) {
|
||||
console.warn("[Canvas Workflow Analyze] extract video frames failed:", {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
videoUrl,
|
||||
});
|
||||
return [];
|
||||
} finally {
|
||||
await rm(tempDir, { force: true, recursive: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCharacterScenePrompt(params: {
|
||||
nodeTitle?: string;
|
||||
prompt: string;
|
||||
imageCount: number;
|
||||
}) {
|
||||
return [
|
||||
"你是视频画布工作流里的角色场景提取节点,需要从脚本、设定和参考图里提取角色与场景资产。",
|
||||
params.nodeTitle ? `当前节点:${params.nodeTitle}` : "",
|
||||
`参考图数量:${params.imageCount}`,
|
||||
params.prompt ? `输入内容:\n${params.prompt}` : "没有额外脚本,请根据参考图提取角色和场景线索。",
|
||||
"",
|
||||
"请只输出 JSON,不要输出 markdown,不要输出解释。",
|
||||
"JSON 结构如下:",
|
||||
`{
|
||||
"summary": "一句话总结当前故事世界与资产线索",
|
||||
"characters": [
|
||||
{
|
||||
"name": "角色名",
|
||||
"description": "角色外观与身份简介",
|
||||
"look": "外貌特征",
|
||||
"wardrobe": "服装/道具",
|
||||
"traits": ["性格关键词"],
|
||||
"signatureAction": "代表动作",
|
||||
"prompt": "可直接用于生成角色图/角色视频的提示词"
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"name": "场景名",
|
||||
"description": "空间、布景和氛围简介",
|
||||
"mood": "情绪与气氛",
|
||||
"lighting": "布光特征",
|
||||
"props": ["核心道具"],
|
||||
"prompt": "可直接用于生成场景图/场景视频的提示词"
|
||||
}
|
||||
],
|
||||
"relationships": ["角色与场景或角色之间的关系"],
|
||||
"recommendedNextSteps": ["建议连接的后续节点动作"]
|
||||
}`,
|
||||
"角色和场景都要尽量具体,避免空泛形容词。",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function buildAgentPlanPrompt(params: {
|
||||
nodeTitle?: string;
|
||||
prompt: string;
|
||||
imageCount: number;
|
||||
videoCount: number;
|
||||
}) {
|
||||
return [
|
||||
"你是视频画布里的自定义代理节点,需要把当前创作目标拆成可执行的工作流计划。",
|
||||
params.nodeTitle ? `当前节点:${params.nodeTitle}` : "",
|
||||
`参考图数量:${params.imageCount}`,
|
||||
`视频数量:${params.videoCount}`,
|
||||
params.prompt ? `上游需求:\n${params.prompt}` : "没有额外脚本,请基于当前上下文给出稳妥计划。",
|
||||
"",
|
||||
"请只输出 JSON,不要输出 markdown,不要输出解释。",
|
||||
"JSON 结构如下:",
|
||||
`{
|
||||
"summary": "一句话说明这个代理链的目标",
|
||||
"agentPlan": ["3-6个执行步骤"],
|
||||
"deliverables": ["2-4个预期交付物"],
|
||||
"recommendedNodes": ["建议继续连接的节点类型或能力名"]
|
||||
}`,
|
||||
"步骤要偏工作流视角,而不是泛泛而谈。",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function buildStoryScriptPrompt(params: {
|
||||
nodeTitle?: string;
|
||||
prompt: string;
|
||||
imageCount: number;
|
||||
videoCount: number;
|
||||
}) {
|
||||
return [
|
||||
"你是视频画布里的故事脚本生成节点,需要把用户输入、参考图和参考视频整理成可直接用于后续分镜/生图/生视频的结构化脚本表。",
|
||||
params.nodeTitle ? `当前节点:${params.nodeTitle}` : "",
|
||||
`参考图数量:${params.imageCount}`,
|
||||
`视频数量:${params.videoCount}`,
|
||||
params.prompt ? `输入内容:\n${params.prompt}` : "没有额外脚本,请生成一个节奏清晰的短视频脚本。",
|
||||
"",
|
||||
"请只输出 JSON,不要输出 markdown,不要输出解释。",
|
||||
"JSON 结构如下:",
|
||||
`{
|
||||
"summary": "一句话概括故事主题、情绪和视频方向,不超过120字",
|
||||
"analysisRows": [
|
||||
{
|
||||
"shot": "01",
|
||||
"duration": "5s",
|
||||
"scene": "场景名或地点,如 盛唐·金銮殿·日",
|
||||
"sceneDescription": "画面内容、人物动作和剧情推进,不要重复 scene 字段",
|
||||
"shotSize": "景别,如中景、特写、全景、大远景",
|
||||
"lighting": "光影氛围,如暖金主光带阴影,气氛紧张压抑",
|
||||
"dialogue": "对白或旁白,没有就写 +",
|
||||
"backgroundMusic": "背景音乐或情绪氛围,没有就写无",
|
||||
"voiceAndSfx": "环境声、动作声或音效,没有就写无",
|
||||
"cameraMovement": "运镜,如缓慢推镜头、固定、横移、上摇、环绕轨道跟拍",
|
||||
"imagePrompt": "后续生成该镜头关键帧时可直接使用的中文图像提示词",
|
||||
"motionPrompt": "后续生成视频时可直接使用的中文视频运动提示词"
|
||||
}
|
||||
],
|
||||
"assetCandidates": {
|
||||
"style": "全局视觉风格,如 盛唐·国风二次元",
|
||||
"characters": ["需要生成的角色资产名"],
|
||||
"scenes": ["需要生成的场景资产名"],
|
||||
"props": ["需要生成的道具资产名"]
|
||||
},
|
||||
"shotIdeas": [
|
||||
{ "title": "镜头 1", "description": "一句话描述镜头目标、景别或动作" }
|
||||
],
|
||||
"recommendedNextSteps": ["2-4条建议的下游动作"]
|
||||
}`,
|
||||
"analysisRows 必须直接输出 18 行,按镜头时间顺序编号 01-18;每行默认 5s 或 6s,剧情高潮可用 6s。",
|
||||
"每行要覆盖竞品脚本表同等信息:镜号、时长、画面描述、景别、光影氛围、对白·旁白、音效、运镜、最终图像提示词和视频运动提示词。",
|
||||
"画面描述中用 @角色名 或 @场景/道具名 标出可复用资产,例如 @沈昭昭、@金銮殿、@侍卫长刀。",
|
||||
"imagePrompt 要像可直接生成分镜图的中文提示词,包含主体、环境、构图、光影、风格与一致性要求;motionPrompt 要按起始状态、动作过程、结束状态、音效组织。",
|
||||
"assetCandidates 根据 18 镜头抽取 3-6 个角色、2-5 个场景和 1-4 个道具,方便后续准备资产。",
|
||||
"每行要像专业视频画布脚本表:场景具体、画面可生成、声音可执行、镜头节奏明确,不要只写剧情摘要。",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
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(() => ({}));
|
||||
const parsed = requestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请求参数不合法" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const prompt = normalizeText(parsed.data.prompt);
|
||||
const imageUrls = sanitizeUrls(parsed.data.imageUrls, 4);
|
||||
const videoUrls = sanitizeUrls(parsed.data.videoUrls, 4);
|
||||
const extractedVideoFrames =
|
||||
parsed.data.mode === "video-analysis" && videoUrls.length > 0
|
||||
? await extractVideoFrameDataUrls(videoUrls[0], VIDEO_ANALYSIS_FRAME_COUNT)
|
||||
: [];
|
||||
const preferredModelRef = normalizeText(parsed.data.preferredModelRef || parsed.data.model);
|
||||
if (!prompt && imageUrls.length === 0 && videoUrls.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请至少提供脚本、图片或视频上下文" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const [allModels, agentModel] = await Promise.all([
|
||||
getEnabledAiModelsWithProvider(false),
|
||||
getAgentAiModelWithProvider(),
|
||||
]);
|
||||
const hasVisionInputs = imageUrls.length > 0 || extractedVideoFrames.length > 0;
|
||||
const compatibleModels = allModels.filter((item) => {
|
||||
const descriptor = {...item.model, providerType: item.provider.type};
|
||||
return !item.model.isSubModel && item.model.isEnabled &&
|
||||
(hasVisionInputs ? isCanvasVisionModel(descriptor) : isCanvasLanguageModel(descriptor));
|
||||
});
|
||||
const requestedRef = preferredModelRef === "auto" ? "" : preferredModelRef;
|
||||
const preferredModel = requestedRef ? compatibleModels.find(
|
||||
item => item.model.id === requestedRef || item.model.modelId === requestedRef
|
||||
) : undefined;
|
||||
if (requestedRef && !preferredModel) {
|
||||
return NextResponse.json({ success: false, error: "所选模型不可用或不支持当前分析输入,请重新选择分析模型" }, { status: 400 });
|
||||
}
|
||||
const selectedModel = preferredModel || compatibleModels.find(item => item.model.id === agentModel?.model.id) || compatibleModels[0];
|
||||
if (!selectedModel) {
|
||||
return NextResponse.json({ success: false, error: hasVisionInputs ? "当前没有可用的视觉理解模型" : "当前没有可用的文字分析模型" }, { status: 400 });
|
||||
}
|
||||
|
||||
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({
|
||||
frameCount: extractedVideoFrames.length,
|
||||
nodeTitle: normalizeText(parsed.data.nodeTitle),
|
||||
prompt,
|
||||
imageCount: imageUrls.length,
|
||||
videoCount: videoUrls.length,
|
||||
})
|
||||
: parsed.data.mode === "character-scene-extraction"
|
||||
? buildCharacterScenePrompt({
|
||||
nodeTitle: normalizeText(parsed.data.nodeTitle),
|
||||
prompt,
|
||||
imageCount: imageUrls.length,
|
||||
})
|
||||
: parsed.data.mode === "story-script"
|
||||
? buildStoryScriptPrompt({
|
||||
nodeTitle: normalizeText(parsed.data.nodeTitle),
|
||||
prompt,
|
||||
imageCount: imageUrls.length,
|
||||
videoCount: videoUrls.length,
|
||||
})
|
||||
: buildAgentPlanPrompt({
|
||||
nodeTitle: normalizeText(parsed.data.nodeTitle),
|
||||
prompt,
|
||||
imageCount: imageUrls.length,
|
||||
videoCount: videoUrls.length,
|
||||
});
|
||||
|
||||
const inlinedImageReferences = useVision
|
||||
? (
|
||||
await inlineVisionImages(imageUrls, {
|
||||
maxImages: 4,
|
||||
maxSide: 960,
|
||||
timeoutMs: 12000,
|
||||
retries: 1,
|
||||
loggerPrefix: "[Canvas Workflow Analyze]",
|
||||
})
|
||||
).inlineDataUrls
|
||||
: [];
|
||||
const referenceImages = useVision
|
||||
? [...inlinedImageReferences, ...extractedVideoFrames].slice(0, 6)
|
||||
: [];
|
||||
|
||||
const { text } = await generateText({
|
||||
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);
|
||||
|
||||
const parsedJson = extractJsonBlock<Record<string, unknown>>(text) || {};
|
||||
const summary =
|
||||
normalizeText(parsedJson.summary) ||
|
||||
normalizeText(text).slice(0, 160);
|
||||
|
||||
if (parsed.data.mode === "video-analysis") {
|
||||
const analysisRows = normalizeVideoAnalysisRows(
|
||||
parsedJson.analysisRows,
|
||||
extractedVideoFrames,
|
||||
);
|
||||
const shotIdeas =
|
||||
normalizeShotIdeas(parsedJson.shotIdeas).length > 0
|
||||
? normalizeShotIdeas(parsedJson.shotIdeas)
|
||||
: analysisRows.map((row, index) => ({
|
||||
title: `镜头 ${index + 1}`,
|
||||
description: row.sceneDescription,
|
||||
}));
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
summary,
|
||||
analysisRows,
|
||||
framePreviews: extractedVideoFrames,
|
||||
highlights: normalizeStringList(parsedJson.highlights, 6),
|
||||
shotIdeas,
|
||||
visualMotifs: normalizeStringList(parsedJson.visualMotifs, 6),
|
||||
recommendedNextSteps: normalizeStringList(parsedJson.recommendedNextSteps, 6),
|
||||
model: selectedModel.model.displayName,
|
||||
raw: text,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.data.mode === "character-scene-extraction") {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
summary,
|
||||
characters: normalizeCharacters(parsedJson.characters),
|
||||
scenes: normalizeScenes(parsedJson.scenes),
|
||||
relationships: normalizeStringList(parsedJson.relationships, 8),
|
||||
recommendedNextSteps: normalizeStringList(parsedJson.recommendedNextSteps, 6),
|
||||
model: selectedModel.model.displayName,
|
||||
raw: text,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.data.mode === "story-script") {
|
||||
const analysisRows = expandStoryScriptRowsToTarget(normalizeStoryScriptRows(
|
||||
parsedJson.analysisRows || parsedJson.storyRows || parsedJson.rows || parsedJson.shots,
|
||||
));
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
summary,
|
||||
analysisRows,
|
||||
shotIdeas:
|
||||
normalizeShotIdeas(parsedJson.shotIdeas).length > 0
|
||||
? normalizeShotIdeas(parsedJson.shotIdeas)
|
||||
: analysisRows.map((row, index) => ({
|
||||
title: `镜头 ${index + 1}`,
|
||||
description: row.sceneDescription,
|
||||
})),
|
||||
recommendedNextSteps: normalizeStringList(parsedJson.recommendedNextSteps, 6),
|
||||
model: selectedModel.model.displayName,
|
||||
raw: text,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
summary,
|
||||
agentPlan: normalizeStringList(parsedJson.agentPlan, 8),
|
||||
deliverables: normalizeStringList(parsedJson.deliverables, 6),
|
||||
recommendedNodes: normalizeStringList(parsedJson.recommendedNodes, 6),
|
||||
model: selectedModel.model.displayName,
|
||||
raw: text,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Canvas Workflow Analyze] Error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "工作流分析失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
const session = await auth();
|
||||
if (!session?.user?.id)
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
const body = await request.json().catch(() => ({}));
|
||||
return executeWorkflowAnalysisResponse(body, request.signal);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,239 +1,10 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { ExtendServiceError, executeExtendJob } from "@/lib/ai/extend/service";
|
||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||
import {
|
||||
getFriendlyRateLimitMessage,
|
||||
getUserFacingRequestErrorMessage,
|
||||
isRateLimitError,
|
||||
} from "@/lib/request-error";
|
||||
|
||||
import { executeExtendPOST } from "@/lib/canvas/operations/extend";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 180;
|
||||
|
||||
function parseIncludeBase64(value: unknown): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
const normalized = String(value ?? "").trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes";
|
||||
}
|
||||
|
||||
function buildExtendSuccessPayload(result: Awaited<ReturnType<typeof executeExtendJob>>) {
|
||||
return {
|
||||
success: true,
|
||||
original_url: result.original_url,
|
||||
thumbnail_url: result.thumbnail_url,
|
||||
...(result.image_base64 ? { base64: result.image_base64 } : {}),
|
||||
processing_time: result.processing_time,
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
|
||||
let imageUrl: string | null = null;
|
||||
let imageBase64: string | null = null;
|
||||
let prompt = "图像拓展处理";
|
||||
let chatId: string | null = null;
|
||||
let messageId: string | null = null;
|
||||
let includeBase64 = false;
|
||||
const requestId = `extend-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const body = await request.json();
|
||||
imageUrl = body.image_url ? String(body.image_url).trim() : null;
|
||||
imageBase64 = body.image_base64 ? String(body.image_base64).trim() : null;
|
||||
prompt = body.prompt || prompt;
|
||||
chatId = body.chatId || null;
|
||||
messageId = body.messageId || null;
|
||||
includeBase64 = parseIncludeBase64(body.includeBase64);
|
||||
|
||||
if (!imageUrl && !imageBase64) {
|
||||
return NextResponse.json(
|
||||
{ error: "缺少image_base64或image_url参数" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[Extend API] Request received:", {
|
||||
requestId,
|
||||
source: imageUrl ? "url" : "base64",
|
||||
includeBase64,
|
||||
chatId,
|
||||
messageId,
|
||||
xScale: body.xScale,
|
||||
yScale: body.yScale,
|
||||
});
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "extend"),
|
||||
reason: "image_extend",
|
||||
modelId: "image-extend",
|
||||
note: "图片扩图消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const result = await executeExtendJob({
|
||||
userId: session.user.id,
|
||||
imageUrl,
|
||||
imageBase64,
|
||||
prompt,
|
||||
chatId,
|
||||
messageId,
|
||||
xScale: body.xScale ? Number(body.xScale) : undefined,
|
||||
yScale: body.yScale ? Number(body.yScale) : undefined,
|
||||
includeBase64,
|
||||
requestId,
|
||||
});
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume();
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume extend points:", pointsError);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[Extend API] Request completed:", {
|
||||
requestId,
|
||||
includeBase64,
|
||||
processingTime: result.processing_time,
|
||||
originalUrl: result.original_url.slice(0, 120),
|
||||
});
|
||||
|
||||
return NextResponse.json(buildExtendSuccessPayload(result));
|
||||
}
|
||||
|
||||
if (contentType.includes("multipart/form-data")) {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "缺少file参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const multipartChatId = String(formData.get("chatId") || "") || null;
|
||||
const multipartMessageId = String(formData.get("messageId") || "") || null;
|
||||
includeBase64 = parseIncludeBase64(formData.get("includeBase64"));
|
||||
console.log("[Extend API] Multipart request received:", {
|
||||
requestId,
|
||||
includeBase64,
|
||||
chatId: multipartChatId,
|
||||
messageId: multipartMessageId,
|
||||
bytes: buffer.length,
|
||||
});
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "extend"),
|
||||
reason: "image_extend",
|
||||
modelId: "image-extend",
|
||||
note: "图片扩图消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const result = await executeExtendJob({
|
||||
userId: session.user.id,
|
||||
imageBase64: buffer.toString("base64"),
|
||||
prompt: String(formData.get("prompt") || prompt),
|
||||
chatId: multipartChatId,
|
||||
messageId: multipartMessageId,
|
||||
includeBase64,
|
||||
requestId,
|
||||
});
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume();
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume extend points:", pointsError);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[Extend API] Multipart request completed:", {
|
||||
requestId,
|
||||
includeBase64,
|
||||
processingTime: result.processing_time,
|
||||
originalUrl: result.original_url.slice(0, 120),
|
||||
});
|
||||
|
||||
return NextResponse.json(buildExtendSuccessPayload(result));
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "不支持的Content-Type,请使用application/json或multipart/form-data" },
|
||||
{ status: 400 },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ExtendServiceError) {
|
||||
console.error("[Extend API] Request failed:", {
|
||||
status: error.status,
|
||||
message: error.message,
|
||||
detail: error.detail,
|
||||
});
|
||||
if (error.status === 429 || isRateLimitError(error)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: getFriendlyRateLimitMessage("当前操作过于频繁,请稍后再试"),
|
||||
detail: error.detail || error.message,
|
||||
retryAfter: 10,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Retry-After": "10",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: getUserFacingRequestErrorMessage(error, "图像拓展失败"),
|
||||
detail: error.detail || error.message,
|
||||
},
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[Extend API] 错误:", error);
|
||||
if (isRateLimitError(error)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: getFriendlyRateLimitMessage("当前操作过于频繁,请稍后再试"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
retryAfter: 10,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Retry-After": "10",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: getUserFacingRequestErrorMessage(error, "图像拓展失败"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
return executeExtendPOST(request, session.user.id);
|
||||
}
|
||||
|
||||
@ -1,138 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
executeRmbgJob,
|
||||
fetchImageBufferFromUrl,
|
||||
RmbgServiceError,
|
||||
} from "@/lib/ai/rmbg/service";
|
||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||
|
||||
import { executeRemoveBackgroundPOST } from "@/lib/canvas/operations/rmbg";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
|
||||
let imageBuffer: Buffer;
|
||||
let imageBase64: string | undefined;
|
||||
let prompt = "抠图处理";
|
||||
let chatId: string | null = null;
|
||||
let messageId: string | null = null;
|
||||
let skipGallerySave = false;
|
||||
let sourceImageUrl: string | null = null;
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const body = await request.json();
|
||||
prompt = body.prompt || "抠图处理";
|
||||
chatId = body.chatId || null;
|
||||
messageId = body.messageId || null;
|
||||
skipGallerySave = body.skipGallerySave === true || body.skip_gallery_save === 1;
|
||||
|
||||
const imageUrl = body.imageUrl || body.image_url;
|
||||
const explicitSourceImageUrl = body.sourceImageUrl || body.source_image_url;
|
||||
|
||||
if (imageUrl) {
|
||||
sourceImageUrl = String(imageUrl);
|
||||
imageBuffer = await fetchImageBufferFromUrl(sourceImageUrl);
|
||||
} else if (body.image_base64) {
|
||||
imageBase64 = String(body.image_base64);
|
||||
imageBuffer = Buffer.from(imageBase64, "base64");
|
||||
if (explicitSourceImageUrl) {
|
||||
sourceImageUrl = String(explicitSourceImageUrl);
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "缺少 imageUrl 或 image_base64 参数" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (contentType.includes("multipart/form-data")) {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "缺少file参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
imageBuffer = Buffer.from(arrayBuffer);
|
||||
imageBase64 = imageBuffer.toString("base64");
|
||||
prompt = (formData.get("prompt") as string) || "抠图处理";
|
||||
chatId = (formData.get("chatId") as string) || null;
|
||||
messageId = (formData.get("messageId") as string) || null;
|
||||
skipGallerySave =
|
||||
formData.get("skipGallerySave") === "true" || formData.get("skip_gallery_save") === "1";
|
||||
sourceImageUrl =
|
||||
(formData.get("sourceImageUrl") as string) ||
|
||||
(formData.get("source_image_url") as string) ||
|
||||
null;
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "不支持的Content-Type,请使用application/json或multipart/form-data" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "remove-background"),
|
||||
reason: "image_remove_background",
|
||||
modelId: "rmbg",
|
||||
note: "图片抠图消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const result = await executeRmbgJob({
|
||||
userId: session.user.id,
|
||||
imageBuffer,
|
||||
imageBase64,
|
||||
prompt,
|
||||
chatId,
|
||||
messageId,
|
||||
skipGallerySave,
|
||||
sourceImageUrl,
|
||||
});
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume();
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume RMBG points:", pointsError);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RmbgServiceError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message,
|
||||
detail: error.detail || error.message,
|
||||
},
|
||||
{ status: error.status }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[RMBG API] 处理错误:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
return executeRemoveBackgroundPOST(request, session.user.id);
|
||||
}
|
||||
|
||||
306
components/canvas/AudioGenerationComposer.tsx
Normal file
306
components/canvas/AudioGenerationComposer.tsx
Normal file
@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
audioContract,
|
||||
audioRequestSchema,
|
||||
buildAudioRequest,
|
||||
type AudioRequest,
|
||||
} from "@/lib/video-canvas/audio-contract";
|
||||
type Model = { id: string; modelId: string; name: string; kind: string };
|
||||
export function AudioGenerationComposer({
|
||||
initial,
|
||||
busy,
|
||||
onSubmit,
|
||||
onUpload,
|
||||
onDraft,
|
||||
}: {
|
||||
initial?: Record<string, unknown>;
|
||||
busy: boolean;
|
||||
onSubmit: (params: AudioRequest) => Promise<unknown>;
|
||||
onUpload: (file: File) => Promise<void>;
|
||||
onDraft: (params: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const [models, setModels] = useState<Model[]>([]),
|
||||
[error, setError] = useState(""),
|
||||
[loading, setLoading] = useState(true),
|
||||
[sending, setSending] = useState(false);
|
||||
const [draft, setDraft] = useState<Record<string, any>>(() => ({
|
||||
mode: "tts",
|
||||
text: "",
|
||||
voice: "Cherry",
|
||||
format: "wav",
|
||||
language: "Chinese",
|
||||
instrumental: true,
|
||||
gender: "female",
|
||||
rate: 1,
|
||||
pitch: 1,
|
||||
volume: 50,
|
||||
...initial,
|
||||
}));
|
||||
const patch = (value: Record<string, unknown>) => {
|
||||
const next = { ...draft, ...value };
|
||||
setDraft(next);
|
||||
onDraft(next);
|
||||
};
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetch("/api/canvas/audio/models", { signal: controller.signal })
|
||||
.then(async (r) => {
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw Error(data.error || "音频模型读取失败");
|
||||
setModels(data.models || []);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!controller.signal.aborted) setError(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
const options = models.filter((m) => m.kind === draft.mode),
|
||||
model = options.find((m) => m.id === draft.modelRef) || options[0],
|
||||
contract = model ? audioContract(model.modelId) : undefined;
|
||||
const voice = contract?.voices.some((v) => v.id === draft.voice)
|
||||
? draft.voice
|
||||
: contract?.voices[0]?.id;
|
||||
const format = contract?.formats.includes(draft.format) ? draft.format : contract?.formats[0];
|
||||
const input = "mt-1 w-full rounded-lg border border-white/15 bg-[#303030] p-2 text-sm";
|
||||
const run = async () => {
|
||||
setError("");
|
||||
setSending(true);
|
||||
try {
|
||||
if (!model) throw Error("当前模式没有已启用的官方模型");
|
||||
const params = audioRequestSchema.parse({
|
||||
...draft,
|
||||
modelRef: model.id,
|
||||
voice,
|
||||
format,
|
||||
...(!contract?.controls ? { rate: 1, pitch: 1, volume: 50 } : {}),
|
||||
...(!contract?.instructions ? { instructions: "" } : {}),
|
||||
});
|
||||
buildAudioRequest(params, model.modelId);
|
||||
patch(params);
|
||||
await onSubmit(params);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "提交失败");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section
|
||||
className="fixed bottom-8 left-1/2 z-[100] max-h-[75vh] w-[min(620px,94vw)] -translate-x-1/2 overflow-y-auto rounded-2xl border border-white/10 bg-[#222] p-5 text-white shadow-xl"
|
||||
data-canvas-overlay="true"
|
||||
aria-label="阿里云音频生成"
|
||||
>
|
||||
<h3 className="font-semibold">语音与音乐 · 阿里云官方</h3>
|
||||
<fieldset disabled={busy || sending} className="mt-3 space-y-3 disabled:opacity-60">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label>
|
||||
生成类型
|
||||
<select
|
||||
aria-label="音频生成类型"
|
||||
className={input}
|
||||
value={draft.mode}
|
||||
onChange={(e) => patch({ mode: e.target.value, modelRef: "" })}
|
||||
>
|
||||
<option value="tts">文字配音</option>
|
||||
<option value="music">音乐 / 歌曲</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
模型
|
||||
<select
|
||||
aria-label="音频模型"
|
||||
className={input}
|
||||
value={model?.id || ""}
|
||||
onChange={(e) => patch({ modelRef: e.target.value })}
|
||||
>
|
||||
{!options.length ? (
|
||||
<option value="">{loading ? "读取模型中…" : "暂无已开通模型"}</option>
|
||||
) : (
|
||||
options.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
{draft.mode === "tts" ? "配音文本(最多 600 字)" : "音乐描述"}
|
||||
<textarea
|
||||
aria-label="音频内容"
|
||||
className={input}
|
||||
rows={4}
|
||||
maxLength={draft.mode === "tts" ? 600 : 2000}
|
||||
value={draft.text}
|
||||
onChange={(e) => patch({ text: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
{draft.mode === "tts" ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label>
|
||||
音色
|
||||
<select
|
||||
aria-label="配音音色"
|
||||
className={input}
|
||||
value={voice || ""}
|
||||
onChange={(e) => patch({ voice: e.target.value })}
|
||||
>
|
||||
{contract?.voices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
语言
|
||||
<select
|
||||
aria-label="配音语言"
|
||||
className={input}
|
||||
value={draft.language}
|
||||
onChange={(e) => patch({ language: e.target.value })}
|
||||
>
|
||||
<option value="Chinese">中文</option>
|
||||
<option value="English">英文</option>
|
||||
<option value="Auto">自动 / 中英混合</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{contract?.instructions ? (
|
||||
<label className="block">
|
||||
声音风格指令
|
||||
<input
|
||||
className={input}
|
||||
aria-label="声音风格指令"
|
||||
placeholder="例如:温暖自然,语速略慢"
|
||||
maxLength={1200}
|
||||
value={draft.instructions || ""}
|
||||
onChange={(e) => patch({ instructions: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{contract?.controls ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(["rate", "pitch", "volume"] as const).map((key, i) => (
|
||||
<label key={key}>
|
||||
{["语速", "音调", "音量"][i]}
|
||||
<input
|
||||
aria-label={["语速", "音调", "音量"][i]}
|
||||
className={input}
|
||||
type="number"
|
||||
min={key === "volume" ? 0 : 0.5}
|
||||
max={key === "volume" ? 100 : 2}
|
||||
step={key === "volume" ? 1 : 0.1}
|
||||
value={draft[key]}
|
||||
onChange={(e) => patch({ [key]: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="flex gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.instrumental}
|
||||
onChange={(e) => patch({ instrumental: e.target.checked })}
|
||||
/>
|
||||
纯音乐(无人声)
|
||||
</label>
|
||||
{!draft.instrumental ? (
|
||||
<>
|
||||
<label className="block">
|
||||
自定义歌词(选填,填写后优先于描述)
|
||||
<textarea
|
||||
aria-label="音乐歌词"
|
||||
className={input}
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
value={draft.lyrics || ""}
|
||||
onChange={(e) => patch({ lyrics: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
演唱声音
|
||||
<select
|
||||
className={input}
|
||||
aria-label="演唱声音"
|
||||
value={draft.gender}
|
||||
onChange={(e) => patch({ gender: e.target.value })}
|
||||
>
|
||||
<option value="female">女声</option>
|
||||
<option value="male">男声</option>
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
{!options.length ? (
|
||||
<p className="text-sm text-amber-200">
|
||||
Fun-Music 需要阿里云邀测权限,开通并启用模型后即可生成。
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-end gap-3">
|
||||
<label>
|
||||
格式
|
||||
<select
|
||||
aria-label="音频格式"
|
||||
className={input}
|
||||
value={format || "wav"}
|
||||
onChange={(e) => patch({ format: e.target.value })}
|
||||
>
|
||||
{(contract?.formats || ["wav"]).map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!model || loading}
|
||||
className="rounded-lg bg-white px-4 py-2 text-black disabled:opacity-40"
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{busy || sending ? "处理中…" : "生成音频"}
|
||||
</button>
|
||||
<label className="cursor-pointer rounded-lg border border-white/20 px-3 py-2">
|
||||
上传音频
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await onUpload(file);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "上传失败");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
{error ? (
|
||||
<p role="alert" className="mt-3 text-sm text-red-300">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
78
components/canvas/PromptTranslateButton.tsx
Normal file
78
components/canvas/PromptTranslateButton.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
export function PromptTranslateButton({
|
||||
prompt,
|
||||
model,
|
||||
onApply,
|
||||
}: {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
onApply: (text: string) => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false),
|
||||
[result, setResult] = useState("");
|
||||
const controller = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => controller.current?.abort(), []);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !prompt.trim()}
|
||||
className="rounded px-2 py-1 text-sm disabled:opacity-40"
|
||||
onClick={async () => {
|
||||
const request = new AbortController();
|
||||
controller.current = request;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await fetch("/api/canvas/prompt-tools", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode: "translate", prompt, model }),
|
||||
signal: AbortSignal.any([request.signal, AbortSignal.timeout(100000)]),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok || !body.success || !body.data?.text)
|
||||
throw Error(body.error || "翻译没有返回结果");
|
||||
if (!request.signal.aborted) setResult(body.data.text);
|
||||
} catch (error) {
|
||||
if (!request.signal.aborted)
|
||||
toast.error(error instanceof Error ? error.message : "翻译失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? "翻译中…" : "翻译"}
|
||||
</button>
|
||||
{result ? (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="翻译结果"
|
||||
className="fixed inset-4 z-[700] flex flex-col gap-3 rounded-xl border border-white/20 bg-[#222] p-5 text-white"
|
||||
>
|
||||
<textarea
|
||||
aria-label="翻译内容"
|
||||
className="min-h-0 flex-1 resize-none bg-transparent"
|
||||
value={result}
|
||||
onChange={(event) => setResult(event.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button type="button" onClick={() => setResult("")}>
|
||||
放弃
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onApply(result);
|
||||
setResult("");
|
||||
}}
|
||||
>
|
||||
应用翻译
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
components/canvas/VideoArchiveButton.tsx
Normal file
23
components/canvas/VideoArchiveButton.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
import { useRef, useState } from "react";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function VideoArchiveButton({ url, automaticBusy = false, onSaved }: { url: string; automaticBusy?: boolean; onSaved: (url: string) => void }) {
|
||||
const running = useRef(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
return <button type="button" disabled={busy || automaticBusy} title="当前视频使用临时链接,转存后可长期保留,无需重新生成" className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg px-3 text-[15px] text-amber-200 hover:bg-white/10 disabled:opacity-50" onClick={async () => {
|
||||
if (running.current) return;
|
||||
running.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await fetch("/api/canvas/video/archive", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }), signal: AbortSignal.timeout(290_000) });
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success || !result.data?.url) throw new Error(result.error || "视频转存失败");
|
||||
onSaved(result.data.url);
|
||||
toast.success("视频已转存,画布正在保存新地址");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error && error.name === "TimeoutError" ? "视频保存超时,原视频已保留,请稍后重试" : error instanceof Error ? error.message : "视频转存失败");
|
||||
} finally { running.current = false; setBusy(false); }
|
||||
}}>{busy || automaticBusy ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}{busy || automaticBusy ? "正在转存" : "转存视频"}</button>;
|
||||
}
|
||||
58
components/canvas/VideoCanvasConflictNotice.tsx
Normal file
58
components/canvas/VideoCanvasConflictNotice.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import type { CanvasConflictChoice } from "@/lib/video-canvas/merge-snapshot";
|
||||
export function VideoCanvasConflictNotice({
|
||||
fields,
|
||||
onResolve,
|
||||
onDownload,
|
||||
}: {
|
||||
fields: string[];
|
||||
onResolve: (choice: CanvasConflictChoice) => Promise<void> | undefined;
|
||||
onDownload: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
if (!fields.length) return null;
|
||||
const resolve = async (choice: CanvasConflictChoice) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await onResolve(choice);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="absolute left-1/2 top-20 z-[200] w-[min(680px,90vw)] -translate-x-1/2 rounded-xl border border-amber-400/40 bg-[#29251b] p-4 text-sm text-white shadow-xl"
|
||||
>
|
||||
<p className="font-semibold">其他页面修改了相同内容,当前草稿已保留</p>
|
||||
<p className="mt-2 text-white/70">
|
||||
共有 {fields.length} 处冲突。不同位置的修改会自动合并;以下选择仅处理冲突部分。
|
||||
</p>
|
||||
<ul className="my-2 max-h-32 overflow-auto text-xs text-white/60">
|
||||
{fields.map((field, index) => (
|
||||
<li key={`${field}-${index}`}>{field}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
disabled={busy}
|
||||
className="rounded bg-white/10 px-3 py-2 disabled:opacity-40"
|
||||
onClick={() => void resolve("local")}
|
||||
>
|
||||
冲突处保留本页
|
||||
</button>
|
||||
<button
|
||||
disabled={busy}
|
||||
className="rounded bg-white/10 px-3 py-2 disabled:opacity-40"
|
||||
onClick={() => void resolve("remote")}
|
||||
>
|
||||
冲突处采用服务器版
|
||||
</button>
|
||||
<button className="rounded bg-white/10 px-3 py-2" onClick={onDownload}>
|
||||
下载当前草稿
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
components/canvas/VideoPromptToolDialog.tsx
Normal file
49
components/canvas/VideoPromptToolDialog.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import type { PromptToolMode } from "@/lib/video-canvas/prompt-tools";
|
||||
|
||||
export function VideoPromptToolDialog({ mode, busy, error, output, source, currentPrompt, model, models, onModelChange, onOutputChange, onRun, onApply, onClose }: {
|
||||
mode: PromptToolMode;
|
||||
busy: boolean;
|
||||
error: string;
|
||||
output: string;
|
||||
source: string;
|
||||
currentPrompt: string;
|
||||
model: string;
|
||||
models: { id: string; label: string }[];
|
||||
onModelChange: (value: string) => void;
|
||||
onOutputChange: (value: string) => void;
|
||||
onRun: () => void;
|
||||
onApply: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const title = mode === "optimize" ? "提示词优化" : "提示词翻译";
|
||||
return (
|
||||
<Dialog open onOpenChange={open => { if (!open) onClose(); }}>
|
||||
<DialogContent hideCloseButton aria-describedby={undefined} onPointerDown={event => event.stopPropagation()} className="flex max-h-[90dvh] w-[620px] max-w-full flex-col gap-4 overflow-y-auto rounded-2xl border border-white/10 bg-[#262626] p-5 text-white shadow-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-base font-medium">{title}</DialogTitle>
|
||||
<button aria-label="关闭提示词工具" className="rounded px-2 py-1 text-white/60 hover:bg-white/10" onClick={onClose} type="button">✕</button>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 text-sm text-white/65">
|
||||
处理模型
|
||||
<select aria-label="提示词处理模型" className="min-w-0 flex-1 rounded-lg border border-white/15 bg-[#202020] px-3 py-2" disabled={busy} onChange={event => onModelChange(event.target.value)} value={model}>
|
||||
<option value="auto">使用管理员默认模型</option>
|
||||
{model !== "auto" && !models.some(item => item.id === model) ? <option value={model}>当前配置模型(暂不可选)</option> : null}
|
||||
{models.map(item => <option key={item.id} value={item.id}>{item.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<details className="text-sm text-white/55"><summary className="cursor-pointer">查看原提示词</summary><p className="mt-2 whitespace-pre-wrap">{source}</p></details>
|
||||
{error ? <p role="alert" className="rounded-lg bg-red-400/10 p-3 text-sm text-red-200">{error}</p> : null}
|
||||
<textarea aria-label={`${title}结果`} className="min-h-[220px] w-full resize-y rounded-xl border border-white/10 bg-black/20 p-3 text-sm leading-6 outline-none focus:border-white/30" disabled={busy} onChange={event => onOutputChange(event.target.value)} placeholder={busy ? "正在处理,请稍候…" : "处理结果会显示在这里,确认后填入视频节点。"} value={output} />
|
||||
{source !== currentPrompt ? <p className="text-xs text-amber-200">原文已修改,应用结果将替换当前提示词。</p> : null}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button className="rounded-lg px-3 py-2 text-sm text-white/65 hover:bg-white/10" onClick={onClose} type="button">{busy ? "取消处理" : "取消"}</button>
|
||||
<button className="rounded-lg border border-white/15 px-3 py-2 text-sm disabled:opacity-40" disabled={busy} onClick={onRun} type="button">{busy ? "处理中…" : output ? "重新生成" : "重试"}</button>
|
||||
<button className="rounded-lg bg-white px-4 py-2 text-sm text-black disabled:opacity-40" disabled={busy || !output.trim() || Boolean(error)} onClick={onApply} type="button">应用到提示词</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
470
components/canvas/director/DirectorMotionPanel.tsx
Normal file
470
components/canvas/director/DirectorMotionPanel.tsx
Normal file
@ -0,0 +1,470 @@
|
||||
"use client";
|
||||
import { transformDirectorMotion } from "@/lib/video-canvas/director-extensions";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
presetDirectorMotion,
|
||||
sampleDirectorMotion,
|
||||
readDirectorMotion,
|
||||
resizeDirectorMotion,
|
||||
validDirectorPose,
|
||||
type DirectorMotion,
|
||||
type DirectorPose,
|
||||
} from "@/lib/video-canvas/director-motion";
|
||||
export function DirectorMotionPanel({
|
||||
motion,
|
||||
hasObjectMotion = false,
|
||||
externalSeek,
|
||||
compact = false,
|
||||
pose,
|
||||
disabled,
|
||||
onChange,
|
||||
onPose,
|
||||
onPreview,
|
||||
onExport,
|
||||
onSend,
|
||||
exportProgress,
|
||||
onCancel,
|
||||
onOverview,
|
||||
}: {
|
||||
compact?: boolean;
|
||||
hasObjectMotion?: boolean;
|
||||
externalSeek?: { time: number; editing?: boolean };
|
||||
motion: DirectorMotion;
|
||||
pose: DirectorPose;
|
||||
disabled: boolean;
|
||||
onChange: (motion: DirectorMotion) => void;
|
||||
onPose: (pose: DirectorPose) => void;
|
||||
onPreview: (time: number | null) => void;
|
||||
onExport: () => void;
|
||||
onSend: () => void;
|
||||
exportProgress: number | null;
|
||||
onCancel: () => void;
|
||||
onOverview: () => void;
|
||||
}) {
|
||||
const [autoKeyframe, setAutoKeyframe] = useState(false);
|
||||
const previousPose = useRef(JSON.stringify(pose));
|
||||
const [time, setTime] = useState(0),
|
||||
[playing, setPlaying] = useState(false),
|
||||
[preview, setPreview] = useState(false);
|
||||
const timeRef = useRef(time),
|
||||
latest = useRef({ motion, onPreview });
|
||||
latest.current = { motion, onPreview };
|
||||
timeRef.current = time;
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const button =
|
||||
"rounded border border-white/15 px-2 py-1.5 text-xs hover:bg-white/10 disabled:opacity-40";
|
||||
const seek = (t: number) => {
|
||||
const next = Math.max(0, Math.min(motion.duration, t));
|
||||
setTime(next);
|
||||
timeRef.current = next;
|
||||
setPreview(true);
|
||||
onPreview(next);
|
||||
};
|
||||
const edit = () => {
|
||||
if (preview) {
|
||||
const sampled = sampleDirectorMotion(motion, timeRef.current);
|
||||
if (sampled) onPose(sampled);
|
||||
}
|
||||
setPlaying(false);
|
||||
setPreview(false);
|
||||
onPreview(null);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
let frame = 0,
|
||||
previous = 0;
|
||||
const tick = (now: number) => {
|
||||
const m = latest.current.motion;
|
||||
const next = timeRef.current + (previous ? (now - previous) / 1000 : 0);
|
||||
previous = now;
|
||||
const t = next >= m.duration ? (m.loop ? next % m.duration : m.duration) : next;
|
||||
timeRef.current = t;
|
||||
setTime(t);
|
||||
latest.current.onPreview(t);
|
||||
if (next >= m.duration && !m.loop) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
frame = requestAnimationFrame(tick);
|
||||
};
|
||||
const hide = () => {
|
||||
if (document.hidden) {
|
||||
setPlaying(false);
|
||||
toast.info("页面已切换,运镜预览已暂停");
|
||||
}
|
||||
};
|
||||
frame = requestAnimationFrame(tick);
|
||||
document.addEventListener("visibilitychange", hide);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener("visibilitychange", hide);
|
||||
};
|
||||
}, [playing]);
|
||||
useEffect(() => {
|
||||
if (disabled) setPlaying(false);
|
||||
}, [disabled]);
|
||||
useEffect(() => {
|
||||
if (timeRef.current > motion.duration) {
|
||||
setTime(motion.duration);
|
||||
onPreview(preview ? motion.duration : null);
|
||||
}
|
||||
}, [motion.duration, onPreview, preview]);
|
||||
const add = () => {
|
||||
if (!validDirectorPose(pose)) {
|
||||
toast.error("相机不能与注视点重合");
|
||||
return;
|
||||
}
|
||||
const existing = motion.keyframes.find((k) => Math.abs(k.time - time) < 0.01);
|
||||
if (!existing && motion.keyframes.length >= 100) {
|
||||
toast.error("最多支持 100 个关键帧");
|
||||
return;
|
||||
}
|
||||
const key = {
|
||||
id: existing?.id || crypto.randomUUID(),
|
||||
time: existing?.time ?? Number(time.toFixed(2)),
|
||||
position: [...pose.position] as DirectorPose["position"],
|
||||
target: [...pose.target] as DirectorPose["target"],
|
||||
fov: pose.fov,
|
||||
};
|
||||
onChange({
|
||||
...motion,
|
||||
keyframes: [...motion.keyframes.filter((k) => k.id !== key.id), key].sort(
|
||||
(a, b) => a.time - b.time,
|
||||
),
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
const signature = JSON.stringify(pose),
|
||||
changed = signature !== previousPose.current;
|
||||
previousPose.current = signature;
|
||||
if (autoKeyframe && changed && !preview && !disabled) add();
|
||||
}, [pose, autoKeyframe, preview, disabled]);
|
||||
useEffect(() => {
|
||||
if (!externalSeek) return;
|
||||
setPlaying(false);
|
||||
setTime(externalSeek.time);
|
||||
timeRef.current = externalSeek.time;
|
||||
setPreview(!externalSeek.editing);
|
||||
}, [externalSeek]);
|
||||
const downloadJson = () => {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([JSON.stringify({ version: 1, motion }, null, 2)], {
|
||||
type: "application/json",
|
||||
}),
|
||||
);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "director-motion.json";
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
return (
|
||||
<section className="space-y-3 border-t border-white/15 pt-3" aria-label="镜头轨迹">
|
||||
<div className="text-sm font-semibold">镜头轨迹</div>
|
||||
<fieldset
|
||||
disabled={disabled}
|
||||
className={
|
||||
compact
|
||||
? "grid grid-cols-1 gap-3 sm:grid-cols-3 lg:grid-cols-4 disabled:opacity-50"
|
||||
: "space-y-3 disabled:opacity-50"
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="text-xs">
|
||||
时长(秒)
|
||||
<input
|
||||
aria-label="运镜时长"
|
||||
type="number"
|
||||
min={2}
|
||||
max={15}
|
||||
step={1}
|
||||
value={motion.duration}
|
||||
className="mt-1 w-full rounded bg-white/10 p-1"
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
if (n >= 2 && n <= 15) {
|
||||
edit();
|
||||
onChange(resizeDirectorMotion(motion, n));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs">
|
||||
速度
|
||||
<select
|
||||
aria-label="运镜插值"
|
||||
value={motion.easing}
|
||||
className="mt-1 w-full rounded bg-[#303030] p-1"
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...motion,
|
||||
easing: e.target.value as DirectorMotion["easing"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="smooth">缓入缓出</option>
|
||||
<option value="linear">匀速</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(["push", "pull", "orbit", "pan"] as const).map((kind, i) => (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
key={kind}
|
||||
onClick={() => {
|
||||
const base =
|
||||
(preview ? sampleDirectorMotion(motion, timeRef.current) : pose) || pose;
|
||||
if (!validDirectorPose(base)) {
|
||||
toast.error("相机不能与注视点重合");
|
||||
return;
|
||||
}
|
||||
edit();
|
||||
setTime(0);
|
||||
onChange(presetDirectorMotion(base, kind, motion.duration));
|
||||
}}
|
||||
>
|
||||
{["推进轨迹", "拉远轨迹", "环绕轨迹", "横移轨迹"][i]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={motion.keyframes.length < 2 && !hasObjectMotion}
|
||||
onClick={() => {
|
||||
edit();
|
||||
onOverview();
|
||||
}}
|
||||
>
|
||||
查看完整轨迹
|
||||
</button>
|
||||
<p className="text-xs text-white/50">
|
||||
预设会替换当前轨迹;黄色线条为相机路径。编辑机位后,在指定时间记录关键帧。
|
||||
</p>
|
||||
<label className="block text-xs">
|
||||
时间 {time.toFixed(2)} / {motion.duration.toFixed(2)} 秒
|
||||
<input
|
||||
aria-label="运镜时间轴"
|
||||
className="w-full"
|
||||
type="range"
|
||||
min={0}
|
||||
max={motion.duration}
|
||||
step={0.01}
|
||||
value={time}
|
||||
onChange={(e) => {
|
||||
setPlaying(false);
|
||||
seek(Number(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={motion.keyframes.length < 2 && !hasObjectMotion}
|
||||
onClick={() => {
|
||||
if (playing) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
seek(time >= motion.duration ? 0 : time);
|
||||
setPlaying(true);
|
||||
}}
|
||||
>
|
||||
{playing ? "暂停运镜" : "播放运镜"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => {
|
||||
setPlaying(false);
|
||||
seek(0);
|
||||
}}
|
||||
>
|
||||
回到起点
|
||||
</button>
|
||||
<button type="button" className={button} onClick={edit}>
|
||||
编辑机位
|
||||
</button>
|
||||
<label className="flex items-center gap-1 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="循环运镜"
|
||||
checked={motion.loop}
|
||||
onChange={(e) => onChange({ ...motion, loop: e.target.checked })}
|
||||
/>
|
||||
循环
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="自动记录关键帧"
|
||||
checked={autoKeyframe}
|
||||
onChange={(e) => setAutoKeyframe(e.target.checked)}
|
||||
/>
|
||||
编辑机位时自动记录当前帧
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={!motion.keyframes.length}
|
||||
onClick={() => {
|
||||
edit();
|
||||
onChange(transformDirectorMotion(motion, [1, 0, 0], 0));
|
||||
}}
|
||||
>
|
||||
整条轨迹右移 1 米
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={!motion.keyframes.length}
|
||||
onClick={() => {
|
||||
edit();
|
||||
onChange(transformDirectorMotion(motion, [0, 0, 0], 90));
|
||||
}}
|
||||
>
|
||||
轨迹与朝向旋转 90°
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className={button} disabled={preview} onClick={add}>
|
||||
记录/更新当前机位
|
||||
</button>
|
||||
{preview ? (
|
||||
<p className="text-xs text-amber-200">正在预览轨迹;点击“编辑机位”后可调整并记录机位。</p>
|
||||
) : null}
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{motion.keyframes.map((k, i) => (
|
||||
<div key={k.id} className="flex items-center gap-1 rounded bg-white/5 p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
aria-label={`编辑关键帧 ${i + 1}`}
|
||||
onClick={() => {
|
||||
edit();
|
||||
setTime(k.time);
|
||||
onPose(k);
|
||||
}}
|
||||
>
|
||||
帧 {i + 1}
|
||||
</button>
|
||||
<input
|
||||
aria-label={`关键帧 ${i + 1} 时间`}
|
||||
type="number"
|
||||
step={0.1}
|
||||
min={0}
|
||||
max={motion.duration}
|
||||
value={Number(k.time.toFixed(3))}
|
||||
className="min-w-0 flex-1 rounded bg-white/10 p-1 text-xs"
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
if (n < 0 || n > motion.duration || !Number.isFinite(n)) return;
|
||||
if (
|
||||
motion.keyframes.some(
|
||||
(other) => other.id !== k.id && Math.abs(other.time - n) < 0.01,
|
||||
)
|
||||
) {
|
||||
toast.error("关键帧时间不能重复");
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
...motion,
|
||||
keyframes: motion.keyframes
|
||||
.map((other) => (other.id === k.id ? { ...k, time: n } : other))
|
||||
.sort((a, b) => a.time - b.time),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
aria-label={`删除关键帧 ${i + 1}`}
|
||||
onClick={() => {
|
||||
edit();
|
||||
onChange({
|
||||
...motion,
|
||||
keyframes: motion.keyframes.filter((other) => other.id !== k.id),
|
||||
});
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button type="button" className={button} onClick={downloadJson}>
|
||||
保存轨迹文件
|
||||
</button>
|
||||
<button type="button" className={button} onClick={() => input.current?.click()}>
|
||||
导入轨迹文件
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${button} w-full`}
|
||||
disabled={motion.keyframes.length < 2 && !hasObjectMotion}
|
||||
onClick={() => {
|
||||
edit();
|
||||
onExport();
|
||||
}}
|
||||
>
|
||||
导出真实运镜视频
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${button} w-full`}
|
||||
disabled={motion.keyframes.length < 2}
|
||||
onClick={() => {
|
||||
edit();
|
||||
onSend();
|
||||
}}
|
||||
>
|
||||
发送首帧与运镜到视频节点
|
||||
</button>
|
||||
<p className="text-xs leading-5 text-white/50">
|
||||
真实导出严格使用本场景轨迹,不含音频。AI
|
||||
生视频使用首帧与运镜描述作参考,不保证精确复现轨迹。
|
||||
</p>
|
||||
</fieldset>
|
||||
{exportProgress !== null ? (
|
||||
<div role="status" className="text-xs">
|
||||
正在导出 {exportProgress.toFixed(1)} / {motion.duration} 秒{" "}
|
||||
<button className={button} onClick={onCancel}>
|
||||
取消导出
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
try {
|
||||
if (file.size > 256 * 1024) throw Error("轨迹文件不能超过 256 KiB");
|
||||
const data = JSON.parse(await file.text());
|
||||
if (data.version !== 1) throw Error("不支持的轨迹文件版本");
|
||||
const m = readDirectorMotion(data.motion);
|
||||
if (m.keyframes.length < 2 || m.keyframes.length !== data.motion?.keyframes?.length)
|
||||
throw Error("轨迹数据无效或包含重复时间");
|
||||
edit();
|
||||
onChange(m);
|
||||
setTime(0);
|
||||
toast.success("轨迹已导入");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "轨迹导入失败");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
256
components/canvas/director/DirectorObjectTimeline.tsx
Normal file
256
components/canvas/director/DirectorObjectTimeline.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { DirectorScene } from "@/lib/video-canvas/director-scene";
|
||||
import { recordObjectFrames } from "@/lib/video-canvas/director-object-tracks";
|
||||
export function DirectorObjectTimeline({
|
||||
scene,
|
||||
time,
|
||||
selected,
|
||||
disabled,
|
||||
preview,
|
||||
auto,
|
||||
onAuto,
|
||||
onChange,
|
||||
onSeek,
|
||||
onEdit,
|
||||
onSelect,
|
||||
}: {
|
||||
scene: DirectorScene;
|
||||
time: number;
|
||||
selected: string | null;
|
||||
disabled: boolean;
|
||||
preview: boolean;
|
||||
auto: boolean;
|
||||
onAuto: (v: boolean) => void;
|
||||
onChange: (s: DirectorScene) => void;
|
||||
onSeek: (t: number) => void;
|
||||
onEdit: () => void;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const [unit, setUnit] = useState("seconds"),
|
||||
[zoom, setZoom] = useState(1);
|
||||
const factor = unit === "frames" ? 30 : 1,
|
||||
track = selected ? scene.objectTracks?.[selected] : undefined;
|
||||
const button = "rounded border border-white/20 px-2 py-1 text-xs disabled:opacity-40",
|
||||
field = "rounded bg-[#303030] p-1 text-xs";
|
||||
return (
|
||||
<fieldset
|
||||
disabled={disabled}
|
||||
className="space-y-3 border-t border-white/15 p-3"
|
||||
aria-label="对象多轨时间轴"
|
||||
>
|
||||
<legend className="px-2 text-sm">对象多轨 · 与相机同步</legend>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label>
|
||||
单位{" "}
|
||||
<select
|
||||
aria-label="时间轴单位"
|
||||
value={unit}
|
||||
className={field}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
>
|
||||
<option value="seconds">秒</option>
|
||||
<option value="frames">帧(30 fps)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
缩放{" "}
|
||||
<select
|
||||
aria-label="对象时间轴缩放"
|
||||
value={zoom}
|
||||
className={field}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
>
|
||||
{[1, 2, 4, 8].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}×
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span>
|
||||
{(time * factor).toFixed(unit === "frames" ? 0 : 2)} /{" "}
|
||||
{(scene.motion.duration * factor).toFixed(unit === "frames" ? 0 : 2)}{" "}
|
||||
{unit === "frames" ? "帧" : "秒"}
|
||||
</span>
|
||||
<label>
|
||||
<input type="checkbox" checked={auto} onChange={(e) => onAuto(e.target.checked)} />{" "}
|
||||
自动记录对象帧
|
||||
</label>
|
||||
<button type="button" className={button} onClick={onEdit}>
|
||||
编辑当前时间对象
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={preview || !selected}
|
||||
onClick={() => {
|
||||
try {
|
||||
onChange(recordObjectFrames(scene, [selected!], time, () => crypto.randomUUID()));
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
记录所选对象帧
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<div style={{ minWidth: `${zoom * 100}%` }}>
|
||||
<input
|
||||
aria-label="对象播放头"
|
||||
type="range"
|
||||
min={0}
|
||||
max={scene.motion.duration}
|
||||
step={1 / 30}
|
||||
value={time}
|
||||
className="w-full"
|
||||
onChange={(e) => onSeek(Number(e.target.value))}
|
||||
/>
|
||||
{scene.objects.map((o) => {
|
||||
const t = scene.objectTracks?.[o.id];
|
||||
return (
|
||||
<div
|
||||
key={o.id}
|
||||
className="grid grid-cols-[150px_60px_1fr] items-center gap-2 border-t border-white/10 py-2 text-xs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`truncate text-left ${selected === o.id ? "text-amber-300" : ""}`}
|
||||
onClick={() => onSelect(o.id)}
|
||||
>
|
||||
{o.name}
|
||||
</button>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${o.name}启用轨道`}
|
||||
checked={!t?.muted}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...scene,
|
||||
objectTracks: {
|
||||
...scene.objectTracks,
|
||||
[o.id]: {
|
||||
keyframes: t?.keyframes || [],
|
||||
easing: t?.easing || "linear",
|
||||
muted: !e.target.checked,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<div className="relative h-6 rounded bg-white/5">
|
||||
{t?.keyframes.map((k, i) => (
|
||||
<button
|
||||
key={k.id}
|
||||
type="button"
|
||||
aria-label={`${o.name}关键帧${i + 1}`}
|
||||
className="absolute top-0 -translate-x-1/2 text-lg text-amber-300"
|
||||
style={{ left: `${(k.time / scene.motion.duration) * 100}%` }}
|
||||
onClick={() => {
|
||||
onSelect(o.id);
|
||||
onSeek(k.time);
|
||||
}}
|
||||
>
|
||||
◆
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{track && selected ? (
|
||||
<div className="space-y-2">
|
||||
<label>
|
||||
对象插值{" "}
|
||||
<select
|
||||
aria-label="对象插值"
|
||||
value={track.easing}
|
||||
className={field}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...scene,
|
||||
objectTracks: {
|
||||
...scene.objectTracks,
|
||||
[selected]: { ...track, easing: e.target.value as "smooth" | "linear" },
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="linear">匀速</option>
|
||||
<option value="smooth">缓入缓出</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{track.keyframes.map((k, i) => (
|
||||
<label className="flex items-center gap-1" key={k.id}>
|
||||
帧 {i + 1}
|
||||
<input
|
||||
className={`${field} w-20`}
|
||||
aria-label={`对象关键帧${i + 1}时间`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={scene.motion.duration * factor}
|
||||
step={unit === "frames" ? 1 : 0.01}
|
||||
value={Number((k.time * factor).toFixed(2))}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value) / factor;
|
||||
if (
|
||||
!Number.isFinite(next) ||
|
||||
next < 0 ||
|
||||
next > scene.motion.duration ||
|
||||
track.keyframes.some(
|
||||
(other) => other.id !== k.id && Math.abs(other.time - next) < 0.001,
|
||||
)
|
||||
)
|
||||
return;
|
||||
onChange({
|
||||
...scene,
|
||||
objectTracks: {
|
||||
...scene.objectTracks,
|
||||
[selected]: {
|
||||
...track,
|
||||
keyframes: track.keyframes
|
||||
.map((old) => (old.id === k.id ? { ...old, time: next } : old))
|
||||
.sort((a, b) => a.time - b.time),
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
aria-label={`删除对象关键帧${i + 1}`}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...scene,
|
||||
objectTracks: {
|
||||
...scene.objectTracks,
|
||||
[selected]: {
|
||||
...track,
|
||||
keyframes: track.keyframes.filter((old) => old.id !== k.id),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-xs text-white/50">
|
||||
拖动播放头预览全部对象;点击“编辑当前时间对象”后调整场景对象,再记录关键帧。真实视频导出使用相同轨道。
|
||||
</p>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
118
components/canvas/director/DirectorReconstructionPanel.tsx
Normal file
118
components/canvas/director/DirectorReconstructionPanel.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { readDirectorScene, type DirectorScene } from "@/lib/video-canvas/director-scene";
|
||||
import { insertReconstructedScene } from "@/lib/video-canvas/director-reconstruction";
|
||||
export type ReconstructionTask = {
|
||||
status: string;
|
||||
error?: string | null;
|
||||
result?: Record<string, unknown> | null;
|
||||
};
|
||||
export function DirectorReconstructionPanel({
|
||||
scene,
|
||||
onChange,
|
||||
onSubmit,
|
||||
task,
|
||||
disabled,
|
||||
}: {
|
||||
scene: DirectorScene;
|
||||
onChange: (s: DirectorScene) => void;
|
||||
onSubmit: (input: { imageUrl: string; prompt: string }) => Promise<unknown>;
|
||||
task?: ReconstructionTask;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null),
|
||||
[prompt, setPrompt] = useState(""),
|
||||
[busy, setBusy] = useState(false);
|
||||
const waiting = task && ["pending", "running"].includes(task.status);
|
||||
const result = task?.status === "succeeded" ? task.result : undefined;
|
||||
return (
|
||||
<fieldset
|
||||
disabled={disabled || busy}
|
||||
className="space-y-2 rounded border border-white/15 p-3 text-xs"
|
||||
>
|
||||
<legend>AI 识图重建布景</legend>
|
||||
<p className="text-white/60">
|
||||
从参考图识别人物、物体与机位,生成可编辑的简化几何布景;不包含精细网格和纹理。
|
||||
</p>
|
||||
<input
|
||||
aria-label="重建参考图"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
<textarea
|
||||
aria-label="重建要求"
|
||||
className="w-full rounded bg-[#303030] p-2"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="需要保留的主体、构图与大致尺寸"
|
||||
maxLength={2000}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!file || !!waiting}
|
||||
className="rounded bg-amber-400 px-3 py-2 text-black disabled:opacity-40"
|
||||
onClick={async () => {
|
||||
if (!file) return;
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
toast.error("参考图需小于 20 MiB");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("purpose", "director-studio");
|
||||
const response = await fetch("/api/files/upload", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(120000),
|
||||
});
|
||||
const data = await response.json();
|
||||
const url = data.url || data.data?.url;
|
||||
if (!response.ok || !url) throw Error(data.error || "参考图上传失败");
|
||||
await onSubmit({ imageUrl: url, prompt });
|
||||
toast.success("重建任务已提交,结果将保存为独立导演台节点");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "重建失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? "正在提交…" : waiting ? "正在重建…" : "识图重建"}
|
||||
</button>
|
||||
{task?.error ? <p role="alert">{task.error}</p> : null}
|
||||
{result?.directorScene ? (
|
||||
<div>
|
||||
<p className="my-2">{String(result.summary || "布景已生成")}</p>
|
||||
{[false, true].map((replace) => (
|
||||
<button
|
||||
type="button"
|
||||
key={String(replace)}
|
||||
className="mr-2 rounded border border-white/20 px-2 py-1"
|
||||
onClick={() => {
|
||||
try {
|
||||
onChange(
|
||||
insertReconstructedScene(
|
||||
scene,
|
||||
readDirectorScene(result.directorScene),
|
||||
replace,
|
||||
() => crypto.randomUUID(),
|
||||
),
|
||||
);
|
||||
toast.success(replace ? "已替换场景对象" : "已插入场景对象");
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{replace ? "替换场景对象" : "插入当前场景"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
963
components/canvas/director/DirectorStudioDialog.tsx
Normal file
963
components/canvas/director/DirectorStudioDialog.tsx
Normal file
@ -0,0 +1,963 @@
|
||||
"use client";
|
||||
import {
|
||||
commitDirectorScene,
|
||||
resolveDirectorCamera,
|
||||
cameraAngles,
|
||||
rotateDirectorCamera,
|
||||
directorBodies,
|
||||
createDirectorCrowd,
|
||||
} from "@/lib/video-canvas/director-extensions";
|
||||
import { directorPoses, type DirectorPoseName } from "@/lib/video-canvas/director-poses";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Camera, Plus, Upload, X, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { DirectorViewport, type DirectorViewportHandle } from "./DirectorViewport";
|
||||
import {
|
||||
readDirectorScene,
|
||||
directorRatios,
|
||||
directorImageSize,
|
||||
type DirectorScene,
|
||||
type DirectorSnapshot,
|
||||
type DirectorObject,
|
||||
type DirectorVector,
|
||||
} from "@/lib/video-canvas/director-scene";
|
||||
import { DirectorObjectTimeline } from "./DirectorObjectTimeline";
|
||||
import {
|
||||
DirectorReconstructionPanel,
|
||||
type ReconstructionTask,
|
||||
} from "./DirectorReconstructionPanel";
|
||||
import {
|
||||
recordObjectFrames,
|
||||
sampleDirectorObjects,
|
||||
} from "@/lib/video-canvas/director-object-tracks";
|
||||
import { DirectorMotionPanel } from "./DirectorMotionPanel";
|
||||
import {
|
||||
sampleDirectorMotion,
|
||||
mergeDirectorCameraPose,
|
||||
type DirectorMotion,
|
||||
} from "@/lib/video-canvas/director-motion";
|
||||
async function upload(file: File) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append(
|
||||
"purpose",
|
||||
file.type.startsWith("video/") ? "canvas-video-process" : "director-studio",
|
||||
);
|
||||
const response = await fetch(
|
||||
/\.glb$/i.test(file.name) ? "/api/canvas/director/upload-model" : "/api/files/upload",
|
||||
{
|
||||
method: "POST",
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(120000),
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
const url = result.url || result.data?.url;
|
||||
if (!response.ok || !url) throw Error(result.error || "上传失败");
|
||||
return String(url);
|
||||
}
|
||||
export function DirectorStudioDialog({
|
||||
initialScene,
|
||||
onChange,
|
||||
onClose,
|
||||
onSend,
|
||||
onSendMotion,
|
||||
onSendVideo,
|
||||
onReconstruct,
|
||||
reconstructionTask,
|
||||
}: {
|
||||
onReconstruct?: (input: { imageUrl: string; prompt: string }) => Promise<unknown>;
|
||||
reconstructionTask?: ReconstructionTask;
|
||||
initialScene: unknown;
|
||||
onChange: (scene: DirectorScene) => void;
|
||||
onClose: () => void;
|
||||
onSend: (snapshots: DirectorSnapshot[]) => void;
|
||||
onSendMotion: (snapshot: DirectorSnapshot, motion: DirectorMotion) => void;
|
||||
onSendVideo: (video: { url: string; width: number; height: number; duration: number }) => void;
|
||||
}) {
|
||||
const [scene, setScene] = useState(() => readDirectorScene(initialScene));
|
||||
const current = useRef(scene);
|
||||
current.current = scene;
|
||||
const commit = (input: DirectorScene) => {
|
||||
const next = commitDirectorScene(current.current, input);
|
||||
if (next.activeCameraId !== current.current.activeCameraId) setPreviewTime(null);
|
||||
current.current = next;
|
||||
setScene(next);
|
||||
onChange(next);
|
||||
};
|
||||
const [objectSearch, setObjectSearch] = useState("");
|
||||
const [selected, setSelected] = useState(scene.objects[0]?.id || "");
|
||||
const [mode, setMode] = useState<"translate" | "rotate" | "scale">("translate");
|
||||
const [cameraView, setCameraView] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const viewport = useRef<DirectorViewportHandle>(null);
|
||||
const timelineDrawer = useRef<HTMLDetailsElement>(null);
|
||||
const reconstructionAnchor = useRef<HTMLDivElement>(null);
|
||||
const [previewTime, setPreviewState] = useState<number | null>(null);
|
||||
const [timelineTime, setTimelineTime] = useState(0);
|
||||
const [externalSeek, setExternalSeek] = useState<{ time: number; editing?: boolean }>();
|
||||
const [autoObjectFrame, setAutoObjectFrame] = useState(false);
|
||||
const setPreviewTime = (time: number | null) => {
|
||||
setPreviewState(time);
|
||||
if (time !== null) setTimelineTime(time);
|
||||
};
|
||||
const [exportProgress, setExportProgress] = useState<number | null>(null);
|
||||
const recording = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => recording.current?.abort(), []);
|
||||
const [exportedVideo, setExportedVideo] = useState<{
|
||||
blob: Blob;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
duration: number;
|
||||
} | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (exportedVideo) URL.revokeObjectURL(exportedVideo.url);
|
||||
},
|
||||
[exportedVideo],
|
||||
);
|
||||
const exportMotion = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setExportProgress(0);
|
||||
const controller = new AbortController();
|
||||
recording.current = controller;
|
||||
try {
|
||||
const motion = current.current.motion;
|
||||
const dimensions = directorImageSize(current.current.ratio);
|
||||
const blob = await viewport.current!.record(controller.signal, setExportProgress);
|
||||
setExportedVideo({
|
||||
blob,
|
||||
url: URL.createObjectURL(blob),
|
||||
...dimensions,
|
||||
duration: motion.duration,
|
||||
});
|
||||
toast.success("真实运镜视频已导出,可下载或发送到画布");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "轨迹导出失败");
|
||||
} finally {
|
||||
recording.current = null;
|
||||
setBusy(false);
|
||||
setExportProgress(null);
|
||||
}
|
||||
};
|
||||
const sendMotion = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const motion = {
|
||||
...current.current.motion,
|
||||
keyframes: current.current.motion.keyframes.map((k) => ({
|
||||
...k,
|
||||
...resolveDirectorCamera(current.current, k),
|
||||
id: k.id,
|
||||
time: k.time,
|
||||
})),
|
||||
};
|
||||
const pose = sampleDirectorMotion(motion, 0);
|
||||
if (!pose || motion.keyframes.length < 2) throw Error("至少需要两个关键帧");
|
||||
const image = await viewport.current!.capture(pose, 0);
|
||||
const url = await upload(
|
||||
new File([image.blob], `director-motion-${Date.now()}.png`, {
|
||||
type: "image/png",
|
||||
}),
|
||||
);
|
||||
const snapshot = {
|
||||
id: crypto.randomUUID(),
|
||||
title: "运镜首帧",
|
||||
url,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
cameraId: current.current.activeCameraId,
|
||||
};
|
||||
commit({
|
||||
...current.current,
|
||||
snapshots: [...current.current.snapshots, snapshot],
|
||||
});
|
||||
onSendMotion(snapshot, motion);
|
||||
toast.success("首帧和运镜描述已发送,请在视频节点选择模型");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "发送失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const backgroundInput = useRef<HTMLInputElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const camera = resolveDirectorCamera(scene);
|
||||
const angles = cameraAngles(camera);
|
||||
const object = scene.objects.find((o) => o.id === selected);
|
||||
const patchObject = (id: string, patch: Partial<DirectorObject>) => {
|
||||
let next = {
|
||||
...current.current,
|
||||
objects: current.current.objects.map((o) => (o.id === id ? { ...o, ...patch } : o)),
|
||||
};
|
||||
try {
|
||||
if (
|
||||
autoObjectFrame &&
|
||||
previewTime === null &&
|
||||
["position", "rotation", "scale", "visible"].some((key) => Object.hasOwn(patch, key))
|
||||
)
|
||||
next = recordObjectFrames(next, [id], timelineTime, () => crypto.randomUUID());
|
||||
commit(next);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message);
|
||||
}
|
||||
};
|
||||
const patchCamera = (patch: Partial<typeof camera>) =>
|
||||
commit({
|
||||
...current.current,
|
||||
cameras: current.current.cameras.map((c) =>
|
||||
c.id === current.current.activeCameraId
|
||||
? {
|
||||
...mergeDirectorCameraPose(c, patch),
|
||||
...(Object.hasOwn(patch, "followObjectId")
|
||||
? { followObjectId: patch.followObjectId }
|
||||
: {}),
|
||||
}
|
||||
: c,
|
||||
),
|
||||
});
|
||||
const addObject = (kind: DirectorObject["kind"], name: string, url?: string) => {
|
||||
const id = crypto.randomUUID();
|
||||
commit({
|
||||
...current.current,
|
||||
objects: [
|
||||
...current.current.objects,
|
||||
{
|
||||
id,
|
||||
kind,
|
||||
name,
|
||||
url,
|
||||
position: [current.current.objects.length * 1.2, 0.5, 0],
|
||||
rotation: [0, 0, 0],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
],
|
||||
});
|
||||
setSelected(id);
|
||||
};
|
||||
const screenshot = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const image = await viewport.current!.capture();
|
||||
const url = await upload(
|
||||
new File([image.blob], `director-${Date.now()}.png`, {
|
||||
type: "image/png",
|
||||
}),
|
||||
);
|
||||
const active = current.current.cameras.find((c) => c.id === current.current.activeCameraId)!;
|
||||
const snapshot = {
|
||||
id: crypto.randomUUID(),
|
||||
title: `${active.name}-截图${current.current.snapshots.length + 1}`,
|
||||
url,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
cameraId: active.id,
|
||||
};
|
||||
commit({
|
||||
...current.current,
|
||||
snapshots: [...current.current.snapshots, snapshot],
|
||||
});
|
||||
toast.success("真实机位截图已保存");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "截图失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const field = "w-full rounded bg-white/10 px-2 py-1.5 text-sm outline-none";
|
||||
const button =
|
||||
"whitespace-nowrap rounded-lg border border-white/10 px-2 py-2 text-sm hover:bg-white/10 disabled:opacity-40";
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="3D导演台"
|
||||
data-canvas-overlay="true"
|
||||
className="fixed inset-0 z-[600] flex flex-col bg-[#141414] text-white"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b border-white/10 px-5">
|
||||
<strong>3D导演台</strong>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className={button}
|
||||
aria-pressed={!cameraView}
|
||||
disabled={busy}
|
||||
onClick={() => setCameraView(false)}
|
||||
>
|
||||
导演视角
|
||||
</button>
|
||||
<button
|
||||
className={button}
|
||||
aria-pressed={cameraView}
|
||||
disabled={busy}
|
||||
onClick={() => setCameraView(true)}
|
||||
>
|
||||
机位视角
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="关闭导演台"
|
||||
className={button}
|
||||
onClick={() => {
|
||||
recording.current?.abort();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[210px_minmax(0,1fr)_260px]">
|
||||
<aside inert={busy} className="overflow-y-auto border-r border-white/10 p-4">
|
||||
<div className="mb-3 text-sm text-white/60">场景对象</div>
|
||||
<input
|
||||
aria-label="搜索场景对象"
|
||||
className={`${field} mb-3`}
|
||||
placeholder="搜索场景对象"
|
||||
value={objectSearch}
|
||||
onChange={(e) => setObjectSearch(e.target.value)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{scene.objects
|
||||
.filter((o) => o.name.toLowerCase().includes(objectSearch.trim().toLowerCase()))
|
||||
.map((o) => (
|
||||
<button
|
||||
className={`${button} w-full text-left ${selected === o.id ? "bg-white/15" : ""}`}
|
||||
key={o.id}
|
||||
onClick={() => setSelected(o.id)}
|
||||
>
|
||||
{o.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 flex flex-col gap-2">
|
||||
<button className={button} onClick={() => addObject("actor", "基础人偶")}>
|
||||
添加人偶
|
||||
</button>
|
||||
<button className={button} onClick={() => addObject("box", "立方体")}>
|
||||
添加立方体
|
||||
</button>
|
||||
<button className={button} onClick={() => addObject("sphere", "球体")}>
|
||||
添加球体
|
||||
</button>
|
||||
<button className={button} disabled={busy} onClick={() => fileInput.current?.click()}>
|
||||
<Upload size={14} className="mr-2 inline" />
|
||||
上传 GLB 模型
|
||||
</button>
|
||||
<button
|
||||
className={button}
|
||||
disabled={busy}
|
||||
onClick={() => backgroundInput.current?.click()}
|
||||
>
|
||||
上传 360° 全景背景
|
||||
</button>
|
||||
<label className="block text-xs">
|
||||
全景背景历史
|
||||
<select
|
||||
aria-label="全景背景历史"
|
||||
className={`${field} mt-1`}
|
||||
value={scene.backgroundUrl || ""}
|
||||
onChange={(e) =>
|
||||
commit({ ...current.current, backgroundUrl: e.target.value || undefined })
|
||||
}
|
||||
>
|
||||
<option value="">默认背景</option>
|
||||
{scene.backgroundHistory?.map((url, index) => (
|
||||
<option key={url} value={url}>
|
||||
全景背景 {index + 1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className={button}
|
||||
disabled={!onReconstruct || busy}
|
||||
onClick={() => {
|
||||
if (timelineDrawer.current) timelineDrawer.current.open = true;
|
||||
reconstructionAnchor.current?.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
>
|
||||
AI 识图重建布景
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-4 text-xs leading-5 text-white/45">
|
||||
左键旋转视角,右键平移,滚轮缩放。选择对象后使用变换轴调整。
|
||||
</p>
|
||||
</aside>
|
||||
<main
|
||||
inert={busy}
|
||||
className="relative flex min-h-0 items-center justify-center overflow-hidden bg-[#080808]"
|
||||
>
|
||||
<div
|
||||
className="h-full max-h-full w-full"
|
||||
style={{ aspectRatio: scene.ratio.replace(":", " / ") }}
|
||||
>
|
||||
<DirectorViewport
|
||||
ref={viewport}
|
||||
scene={scene}
|
||||
selected={selected}
|
||||
mode={mode}
|
||||
cameraView={cameraView}
|
||||
previewTime={previewTime}
|
||||
locked={busy}
|
||||
onTransform={patchObject}
|
||||
onCamera={(position, target) => patchCamera({ position, target })}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-1 rounded-xl bg-black/75 p-2">
|
||||
{(["translate", "rotate", "scale"] as const).map((m, i) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`${button} ${mode === m ? "bg-white/20" : ""}`}
|
||||
onClick={() => setMode(m)}
|
||||
>
|
||||
{["移动", "旋转", "缩放"][i]}
|
||||
</button>
|
||||
))}
|
||||
<button className={button} disabled={busy} onClick={screenshot}>
|
||||
<Camera className="mr-1 inline" size={16} />
|
||||
{busy ? "处理中..." : "截图"}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
<aside className="space-y-4 overflow-y-auto border-l border-white/10 p-4">
|
||||
<fieldset disabled={busy || previewTime !== null} className="space-y-4">
|
||||
<label className="block text-sm">
|
||||
机位
|
||||
<select
|
||||
aria-label="机位"
|
||||
className={`${field} mt-2`}
|
||||
value={scene.activeCameraId}
|
||||
onChange={(e) => commit({ ...current.current, activeCameraId: e.target.value })}
|
||||
>
|
||||
{scene.cameras.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{[
|
||||
["正面", [0, 2, 7]],
|
||||
["侧面", [7, 2, 0]],
|
||||
["俯视", [0, 8, 0.1]],
|
||||
["正面特写", [0, 1.7, 2]],
|
||||
["正面全景", [0, 2, 10]],
|
||||
["侧面近景", [2, 1.5, 0]],
|
||||
["背面", [0, 2, -7]],
|
||||
["45°俯拍", [4, 6, 4]],
|
||||
["低机位", [0, 0.3, 4]],
|
||||
["左过肩", [-1, 1.8, -2]],
|
||||
["右过肩", [1, 1.8, -2]],
|
||||
["鸟瞰", [0, 14, 0.1]],
|
||||
].map(([name, position]) => (
|
||||
<button
|
||||
className={button}
|
||||
key={String(name)}
|
||||
onClick={() => {
|
||||
const id = crypto.randomUUID();
|
||||
commit({
|
||||
...current.current,
|
||||
activeCameraId: id,
|
||||
cameras: [
|
||||
...current.current.cameras,
|
||||
{
|
||||
id,
|
||||
name: `${name}机位${current.current.cameras.length + 1}`,
|
||||
position: position as DirectorVector,
|
||||
target: [0, 1, 0],
|
||||
fov: 50,
|
||||
},
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus size={12} className="inline" />
|
||||
{String(name)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="block text-sm">
|
||||
跟随对象
|
||||
<select
|
||||
aria-label="相机跟随对象"
|
||||
className={`${field} mt-2`}
|
||||
value={camera.followObjectId || ""}
|
||||
onChange={(e) =>
|
||||
patchCamera({
|
||||
followObjectId: e.target.value || undefined,
|
||||
target: camera.target,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">不跟随</option>
|
||||
{scene.objects.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(["yaw", "pitch"] as const).map((axis) => (
|
||||
<label key={axis} className="text-xs">
|
||||
{axis === "yaw" ? "水平旋转(度)" : "俯仰旋转(度)"}
|
||||
<input
|
||||
aria-label={`相机${axis}`}
|
||||
className={field}
|
||||
type="number"
|
||||
step="1"
|
||||
min={axis === "yaw" ? -180 : -89}
|
||||
max={axis === "yaw" ? 180 : 89}
|
||||
value={Number(angles[axis].toFixed(1))}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
if (Number.isFinite(n))
|
||||
patchCamera({
|
||||
target: rotateDirectorCamera(
|
||||
camera,
|
||||
axis === "yaw" ? n : angles.yaw,
|
||||
axis === "pitch" ? n : angles.pitch,
|
||||
),
|
||||
followObjectId: undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="block text-sm">
|
||||
视角 FOV
|
||||
<input
|
||||
aria-label="视角 FOV"
|
||||
className={`${field} mt-2`}
|
||||
type="number"
|
||||
min={15}
|
||||
max={90}
|
||||
value={Number(camera.fov.toFixed(2))}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
if (n >= 15 && n <= 90) patchCamera({ fov: n });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
截图比例
|
||||
<select
|
||||
aria-label="截图比例"
|
||||
className={`${field} mt-2`}
|
||||
value={scene.ratio}
|
||||
onChange={(e) => commit({ ...current.current, ratio: e.target.value })}
|
||||
>
|
||||
{directorRatios.map((r) => (
|
||||
<option key={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{(["position", "target"] as const).map((key, j) => (
|
||||
<label className="block text-xs" key={key}>
|
||||
{j === 0 ? "相机位置" : "相机注视点"}
|
||||
<div className="mt-1 grid grid-cols-3 gap-1">
|
||||
{camera[key].map((n, i) => (
|
||||
<input
|
||||
key={i}
|
||||
aria-label={`相机-${key}-${"XYZ"[i]}`}
|
||||
className={field}
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={Number(n.toFixed(2))}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
if (!Number.isFinite(n) || Math.abs(n) > 10000) return;
|
||||
const values = [...camera[key]] as DirectorVector;
|
||||
values[i] = n;
|
||||
patchCamera({ [key]: values });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
{object ? (
|
||||
<section className="space-y-2 border-t border-white/10 pt-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<input
|
||||
aria-label="对象名称"
|
||||
className={field}
|
||||
value={object.name}
|
||||
maxLength={80}
|
||||
onChange={(e) => patchObject(object.id, { name: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
aria-label="删除所选场景对象"
|
||||
onClick={() => {
|
||||
commit({
|
||||
...current.current,
|
||||
objects: current.current.objects.filter((o) => o.id !== object.id),
|
||||
});
|
||||
setSelected("");
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={object.visible !== false}
|
||||
onChange={(e) => patchObject(object.id, { visible: e.target.checked })}
|
||||
/>{" "}
|
||||
显示
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={object.locked === true}
|
||||
onChange={(e) => patchObject(object.id, { locked: e.target.checked })}
|
||||
/>{" "}
|
||||
锁定变换轴
|
||||
</label>
|
||||
{object.kind !== "model" && (
|
||||
<label>
|
||||
颜色{" "}
|
||||
<input
|
||||
aria-label="对象颜色"
|
||||
type="color"
|
||||
value={object.color || (object.kind === "actor" ? "#687bb0" : "#c3664c")}
|
||||
onChange={(e) => patchObject(object.id, { color: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{object.kind === "actor" && (
|
||||
<label className="block text-xs">
|
||||
人偶体型
|
||||
<select
|
||||
aria-label="人偶体型"
|
||||
className={`${field} mt-1`}
|
||||
value={object.body || "标准"}
|
||||
onChange={(e) =>
|
||||
patchObject(object.id, {
|
||||
body: e.target.value as keyof typeof directorBodies,
|
||||
})
|
||||
}
|
||||
>
|
||||
{Object.keys(directorBodies).map((name) => (
|
||||
<option key={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{object.kind === "actor" && (
|
||||
<label className="block text-xs">
|
||||
人偶姿势
|
||||
<select
|
||||
aria-label="人偶姿势"
|
||||
className={`${field} mt-1`}
|
||||
value={object.pose || "站立"}
|
||||
onChange={(e) =>
|
||||
patchObject(object.id, { pose: e.target.value as DirectorPoseName })
|
||||
}
|
||||
>
|
||||
{Object.keys(directorPoses).map((name) => (
|
||||
<option key={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
className={button}
|
||||
onClick={() => {
|
||||
const copy = {
|
||||
...object,
|
||||
id: crypto.randomUUID(),
|
||||
name: `${object.name} 副本`,
|
||||
position: [
|
||||
object.position[0] + 1,
|
||||
...object.position.slice(1),
|
||||
] as DirectorVector,
|
||||
};
|
||||
commit({ ...current.current, objects: [...current.current.objects, copy] });
|
||||
setSelected(copy.id);
|
||||
}}
|
||||
>
|
||||
复制对象
|
||||
</button>
|
||||
<button
|
||||
className={button}
|
||||
onClick={() => {
|
||||
try {
|
||||
const crowd = createDirectorCrowd(
|
||||
object,
|
||||
current.current.objects.length,
|
||||
() => crypto.randomUUID(),
|
||||
);
|
||||
commit({
|
||||
...current.current,
|
||||
objects: [...current.current.objects, ...crowd],
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "阵列创建失败");
|
||||
}
|
||||
}}
|
||||
>
|
||||
创建 3×3 阵列
|
||||
</button>
|
||||
<button
|
||||
className={button}
|
||||
onClick={() =>
|
||||
patchCamera({
|
||||
target: [object.position[0], object.position[1] + 1, object.position[2]],
|
||||
})
|
||||
}
|
||||
>
|
||||
相机对准所选对象
|
||||
</button>
|
||||
<label className="block text-xs">
|
||||
等比缩放
|
||||
<input
|
||||
aria-label="等比缩放"
|
||||
className={`${field} mt-1`}
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.01"
|
||||
value={Number(object.scale[0].toFixed(2))}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
if (n > 0 && Number.isFinite(n))
|
||||
patchObject(object.id, {
|
||||
scale: object.scale.map(
|
||||
(v) => (v * n) / object.scale[0],
|
||||
) as DirectorVector,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{(["position", "rotation", "scale"] as const).map((key, j) => (
|
||||
<div key={key}>
|
||||
<div className="mb-1 text-xs text-white/50">
|
||||
{["位置", "旋转(度)", "缩放"][j]}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{object[key].map((v, i) => (
|
||||
<input
|
||||
key={i}
|
||||
aria-label={`${object.name}-${key}-${"XYZ"[i]}`}
|
||||
className={field}
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={Number((key === "rotation" ? (v * 180) / Math.PI : v).toFixed(2))}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
if (!Number.isFinite(n) || (key === "scale" && n <= 0)) return;
|
||||
const values = [...object[key]] as DirectorVector;
|
||||
values[i] = key === "rotation" ? (n * Math.PI) / 180 : n;
|
||||
patchObject(object.id, { [key]: values });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
</fieldset>
|
||||
{exportedVideo ? (
|
||||
<section className="space-y-2 border-t border-white/10 pt-3" aria-label="运镜导出结果">
|
||||
<video
|
||||
controls
|
||||
src={exportedVideo.url}
|
||||
className="w-full"
|
||||
aria-label="真实运镜导出预览"
|
||||
/>
|
||||
<a
|
||||
className={button}
|
||||
href={exportedVideo.url}
|
||||
download={`director-motion.${exportedVideo.blob.type.includes("mp4") ? "mp4" : "webm"}`}
|
||||
>
|
||||
下载运镜视频
|
||||
</a>
|
||||
<button
|
||||
disabled={busy}
|
||||
className={`${button} w-full`}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const ext = exportedVideo.blob.type.includes("mp4") ? "mp4" : "webm";
|
||||
const url = await upload(
|
||||
new File([exportedVideo.blob], `director-motion-${Date.now()}.${ext}`, {
|
||||
type: exportedVideo.blob.type.split(";")[0],
|
||||
}),
|
||||
);
|
||||
onSendVideo({ ...exportedVideo, url });
|
||||
toast.success("运镜视频已发送到画布");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "上传失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
发送真实运镜视频到画布
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="space-y-2 border-t border-white/10 pt-3">
|
||||
<div className="text-sm">机位截图({scene.snapshots.length})</div>
|
||||
{scene.snapshots.map((s) => (
|
||||
<div key={s.id} className="rounded-lg border border-white/10 p-2">
|
||||
<img src={s.url} alt={s.title} className="w-full rounded" />
|
||||
<div className="mt-1 truncate text-xs">
|
||||
{s.title} · {s.width}×{s.height}
|
||||
</div>
|
||||
<button
|
||||
className={`${button} mt-2 w-full`}
|
||||
onClick={() => {
|
||||
onSend([s]);
|
||||
toast.success("截图已发送到画布");
|
||||
}}
|
||||
>
|
||||
发送到画布
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
<details
|
||||
ref={timelineDrawer}
|
||||
open
|
||||
className="max-h-[32vh] shrink-0 overflow-auto border-t border-white/15 bg-[#1c1c1c] px-4 pb-3"
|
||||
>
|
||||
<summary className="cursor-pointer py-2 text-sm">
|
||||
相机时间轴与运镜 · {camera.name}(独立轨道)
|
||||
</summary>
|
||||
<DirectorMotionPanel
|
||||
key={scene.activeCameraId}
|
||||
compact
|
||||
hasObjectMotion={Object.values(scene.objectTracks || {}).some(
|
||||
(t) => !t.muted && t.keyframes.length >= 2,
|
||||
)}
|
||||
externalSeek={externalSeek}
|
||||
motion={scene.motion}
|
||||
pose={camera}
|
||||
disabled={busy}
|
||||
onChange={(motion) => commit({ ...current.current, motion })}
|
||||
onPose={patchCamera}
|
||||
onPreview={setPreviewTime}
|
||||
onExport={exportMotion}
|
||||
onSend={sendMotion}
|
||||
exportProgress={exportProgress}
|
||||
onCancel={() => recording.current?.abort()}
|
||||
onOverview={() => {
|
||||
setCameraView(false);
|
||||
viewport.current?.showPath();
|
||||
}}
|
||||
/>
|
||||
<DirectorObjectTimeline
|
||||
scene={scene}
|
||||
time={timelineTime}
|
||||
selected={selected}
|
||||
disabled={busy}
|
||||
preview={previewTime !== null}
|
||||
auto={autoObjectFrame}
|
||||
onAuto={setAutoObjectFrame}
|
||||
onChange={commit}
|
||||
onSelect={setSelected}
|
||||
onSeek={(time) => {
|
||||
setExternalSeek({ time });
|
||||
setPreviewTime(time);
|
||||
}}
|
||||
onEdit={() => {
|
||||
commit({
|
||||
...current.current,
|
||||
objects: sampleDirectorObjects(current.current, timelineTime),
|
||||
});
|
||||
setPreviewTime(null);
|
||||
setExternalSeek({ time: timelineTime, editing: true });
|
||||
}}
|
||||
/>
|
||||
{onReconstruct ? (
|
||||
<div ref={reconstructionAnchor}>
|
||||
<DirectorReconstructionPanel
|
||||
scene={scene}
|
||||
onChange={commit}
|
||||
onSubmit={onReconstruct}
|
||||
task={reconstructionTask}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</details>
|
||||
<input
|
||||
ref={backgroundInput}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
toast.error("全景背景需小于 20 MiB");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const ratio = bitmap.width / bitmap.height;
|
||||
bitmap.close();
|
||||
if (Math.abs(ratio - 2) > 0.02) throw Error("请上传 2:1 比例的等距柱状全景图");
|
||||
const url = await upload(file);
|
||||
commit({
|
||||
...current.current,
|
||||
backgroundUrl: url,
|
||||
backgroundHistory: [
|
||||
...new Set([...(current.current.backgroundHistory || []), url]),
|
||||
].slice(-20),
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "全景背景上传失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={fileInput}
|
||||
className="hidden"
|
||||
type="file"
|
||||
accept=".glb,model/gltf-binary"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
if (!/\.glb$/i.test(file.name) || file.size > 25 * 1024 * 1024) {
|
||||
toast.error("请上传 25 MiB 以内的 GLB 模型");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const header = new DataView(await file.slice(0, 12).arrayBuffer());
|
||||
if (header.byteLength < 12 || header.getUint32(0, true) !== 0x46546c67)
|
||||
throw Error("文件不是有效 GLB 模型");
|
||||
const url = await upload(file);
|
||||
addObject("model", file.name, url);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "模型上传失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
756
components/canvas/director/DirectorViewport.tsx
Normal file
756
components/canvas/director/DirectorViewport.tsx
Normal file
@ -0,0 +1,756 @@
|
||||
"use client";
|
||||
import { sampleDirectorObjects } from "@/lib/video-canvas/director-object-tracks";
|
||||
import { resolveDirectorCamera, directorBodies } from "@/lib/video-canvas/director-extensions";
|
||||
import { directorPoses } from "@/lib/video-canvas/director-poses";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { TransformControls } from "three/examples/jsm/controls/TransformControls.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import {
|
||||
directorImageSize,
|
||||
type DirectorScene,
|
||||
type DirectorObject,
|
||||
} from "@/lib/video-canvas/director-scene";
|
||||
import { sampleDirectorMotion, type DirectorPose } from "@/lib/video-canvas/director-motion";
|
||||
import { recordDirectorVideo } from "@/lib/video-canvas/director-recording";
|
||||
export type DirectorViewportHandle = {
|
||||
showPath: () => void;
|
||||
record: (signal: AbortSignal, onProgress: (time: number) => void) => Promise<Blob>;
|
||||
capture: (
|
||||
pose?: DirectorPose,
|
||||
time?: number,
|
||||
) => Promise<{ blob: Blob; width: number; height: number }>;
|
||||
};
|
||||
type Props = {
|
||||
scene: DirectorScene;
|
||||
selected: string;
|
||||
mode: "translate" | "rotate" | "scale";
|
||||
cameraView: boolean;
|
||||
previewTime: number | null;
|
||||
locked: boolean;
|
||||
onTransform: (id: string, patch: Partial<DirectorObject>) => void;
|
||||
onCamera: (position: [number, number, number], target: [number, number, number]) => void;
|
||||
};
|
||||
function dispose(root: THREE.Object3D) {
|
||||
root.traverse((o) => {
|
||||
if (o instanceof THREE.Mesh || o instanceof THREE.Line) {
|
||||
o.geometry.dispose();
|
||||
for (const m of Array.isArray(o.material) ? o.material : [o.material]) {
|
||||
for (const value of Object.values(m)) if (value instanceof THREE.Texture) value.dispose();
|
||||
m.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
function DirectorViewport(props, ref) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const latest = useRef(props);
|
||||
latest.current = props;
|
||||
const runtime = useRef<{
|
||||
renderer: THREE.WebGLRenderer;
|
||||
world: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
orbit: OrbitControls;
|
||||
transform: TransformControls;
|
||||
helper: THREE.Object3D;
|
||||
grid: THREE.GridHelper;
|
||||
path: THREE.Group;
|
||||
objects: THREE.Group;
|
||||
pending: number;
|
||||
resize: () => void;
|
||||
} | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [rendererRevision, setRendererRevision] = useState(0);
|
||||
const [resourceRevision, setResourceRevision] = useState(0);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [backgroundPending, setBackgroundPending] = useState(false);
|
||||
const [backgroundError, setBackgroundError] = useState("");
|
||||
const applyObjectTime = (time: number | null) => {
|
||||
const objects = sampleDirectorObjects(latest.current.scene, time),
|
||||
r = runtime.current;
|
||||
if (r)
|
||||
for (const item of objects) {
|
||||
const mesh = r.objects.children.find((o) => o.name === item.id);
|
||||
if (mesh) {
|
||||
mesh.position.fromArray(item.position);
|
||||
mesh.rotation.set(...item.rotation);
|
||||
mesh.scale.fromArray(item.scale);
|
||||
mesh.visible = item.visible !== false;
|
||||
}
|
||||
}
|
||||
return { ...latest.current.scene, objects };
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!host.current) return;
|
||||
setError("");
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
} catch {
|
||||
setError("无法初始化 3D 视图,请检查浏览器 WebGL 支持");
|
||||
return;
|
||||
}
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setClearColor("#c5cbd3");
|
||||
host.current.appendChild(renderer.domElement);
|
||||
renderer.domElement.setAttribute("aria-label", "3D 导演台场景");
|
||||
Object.assign(renderer.domElement.style, {
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
});
|
||||
const world = new THREE.Scene();
|
||||
world.background = new THREE.Color("#c5cbd3");
|
||||
world.add(new THREE.HemisphereLight(0xffffff, 0x647080, 2.4));
|
||||
const light = new THREE.DirectionalLight(0xffffff, 3);
|
||||
light.position.set(4, 8, 5);
|
||||
world.add(light);
|
||||
const floor = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(100, 100),
|
||||
new THREE.MeshStandardMaterial({ color: 0xb1b6be, roughness: 1 }),
|
||||
);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.position.y = -0.02;
|
||||
world.add(floor);
|
||||
const grid = new THREE.GridHelper(30, 30, 0x596573, 0x929daa);
|
||||
world.add(grid);
|
||||
const path = new THREE.Group();
|
||||
world.add(path);
|
||||
const camera = new THREE.PerspectiveCamera(50, 1, 0.05, 500);
|
||||
camera.position.set(4, 3, 7);
|
||||
const orbit = new OrbitControls(camera, renderer.domElement);
|
||||
orbit.target.set(0, 1, 0);
|
||||
orbit.minDistance = 0.3;
|
||||
orbit.maxDistance = 80;
|
||||
const transform = new TransformControls(camera, renderer.domElement);
|
||||
const helper = transform.getHelper();
|
||||
world.add(helper);
|
||||
const objects = new THREE.Group();
|
||||
world.add(objects);
|
||||
transform.addEventListener("dragging-changed", (e) => {
|
||||
orbit.enabled =
|
||||
!e.value && !latest.current.cameraView && latest.current.previewTime === null;
|
||||
});
|
||||
transform.addEventListener("mouseUp", () => {
|
||||
const o = transform.object;
|
||||
if (o)
|
||||
latest.current.onTransform(o.name, {
|
||||
position: o.position.toArray(),
|
||||
rotation: [o.rotation.x, o.rotation.y, o.rotation.z],
|
||||
scale: o.scale.toArray(),
|
||||
});
|
||||
});
|
||||
orbit.addEventListener("end", () => {
|
||||
if (latest.current.previewTime !== null || latest.current.cameraView) return;
|
||||
latest.current.onCamera(camera.position.toArray(), orbit.target.toArray());
|
||||
});
|
||||
|
||||
const resize = () => {
|
||||
if (!host.current) return;
|
||||
const { width, height } = host.current.getBoundingClientRect();
|
||||
const size = directorImageSize(latest.current.scene.ratio);
|
||||
const aspect = size.width / size.height;
|
||||
const fitWidth = Math.max(1, Math.min(width, height * aspect));
|
||||
renderer.setSize(fitWidth, fitWidth / aspect);
|
||||
camera.aspect = aspect;
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
const state = {
|
||||
renderer,
|
||||
world,
|
||||
camera,
|
||||
orbit,
|
||||
transform,
|
||||
helper,
|
||||
grid,
|
||||
path,
|
||||
objects,
|
||||
pending: 0,
|
||||
resize,
|
||||
};
|
||||
runtime.current = state;
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(host.current);
|
||||
resize();
|
||||
renderer.setAnimationLoop(() => {
|
||||
if (orbit.enabled) orbit.update();
|
||||
renderer.render(world, camera);
|
||||
});
|
||||
const contextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
renderer.setAnimationLoop(null);
|
||||
setReady(false);
|
||||
setError("3D 显示连接已中断,请点击重试恢复场景");
|
||||
};
|
||||
const contextRestored = () => setRendererRevision((value) => value + 1);
|
||||
renderer.domElement.addEventListener("webglcontextlost", contextLost);
|
||||
renderer.domElement.addEventListener("webglcontextrestored", contextRestored);
|
||||
setReady(true);
|
||||
return () => {
|
||||
renderer.domElement.removeEventListener("webglcontextlost", contextLost);
|
||||
renderer.domElement.removeEventListener("webglcontextrestored", contextRestored);
|
||||
runtime.current = null;
|
||||
observer.disconnect();
|
||||
renderer.setAnimationLoop(null);
|
||||
transform.dispose();
|
||||
orbit.dispose();
|
||||
dispose(world);
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
};
|
||||
}, [rendererRevision]);
|
||||
useEffect(() => {
|
||||
const r = runtime.current;
|
||||
if (!r || !ready) return;
|
||||
let active = true;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
r.transform.detach();
|
||||
const desired = new Set(props.scene.objects.map((o) => o.id));
|
||||
r.objects.children.slice().forEach((o) => {
|
||||
if (!desired.has(o.name)) {
|
||||
dispose(o);
|
||||
r.objects.remove(o);
|
||||
}
|
||||
});
|
||||
setError("");
|
||||
r.pending = 0;
|
||||
const attach = (object: THREE.Object3D, item: DirectorObject) => {
|
||||
if (!active) {
|
||||
dispose(object);
|
||||
return;
|
||||
}
|
||||
object.name = item.id;
|
||||
// A GLB may finish loading after playback has paused at a nonzero time.
|
||||
const sampled =
|
||||
latest.current.previewTime === null
|
||||
? item
|
||||
: sampleDirectorObjects(latest.current.scene, latest.current.previewTime).find(
|
||||
(o) => o.id === item.id,
|
||||
) || item;
|
||||
object.visible = sampled.visible !== false;
|
||||
object.userData.directorSignature = JSON.stringify([
|
||||
item.kind,
|
||||
item.url,
|
||||
item.pose,
|
||||
item.color,
|
||||
item.body,
|
||||
]);
|
||||
object.position.fromArray(sampled.position);
|
||||
object.rotation.set(...sampled.rotation);
|
||||
object.scale.fromArray(sampled.scale);
|
||||
r.objects.add(object);
|
||||
if (
|
||||
item.id === latest.current.selected &&
|
||||
!item.locked &&
|
||||
item.visible !== false &&
|
||||
!latest.current.cameraView &&
|
||||
latest.current.previewTime === null
|
||||
)
|
||||
r.transform.attach(object);
|
||||
};
|
||||
for (const item of props.scene.objects) {
|
||||
const existing = r.objects.children.find((o) => o.name === item.id);
|
||||
if (
|
||||
existing &&
|
||||
existing.userData.directorSignature ===
|
||||
JSON.stringify([item.kind, item.url, item.pose, item.color, item.body])
|
||||
) {
|
||||
attach(existing, item);
|
||||
continue;
|
||||
}
|
||||
if (existing) {
|
||||
dispose(existing);
|
||||
r.objects.remove(existing);
|
||||
}
|
||||
if (item.kind === "model" && item.url) {
|
||||
r.pending++;
|
||||
let settled = false;
|
||||
const finish = (message?: string) => {
|
||||
if (!active || settled) return false;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
r.pending--;
|
||||
setLoading(r.pending > 0);
|
||||
if (message) setError(`${item.name}:${message}`);
|
||||
return true;
|
||||
};
|
||||
const timeout = setTimeout(() => finish("模型加载超过 30 秒,请检查网络后重试"), 30000);
|
||||
timers.push(timeout);
|
||||
new GLTFLoader().load(
|
||||
buildThreeAssetUrl(item.url),
|
||||
(g) => {
|
||||
if (!active || settled) {
|
||||
dispose(g.scene);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const box = new THREE.Box3().setFromObject(g.scene);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
if (
|
||||
box.isEmpty() ||
|
||||
!size.toArray().every(Number.isFinite) ||
|
||||
size.length() < 0.000001
|
||||
) {
|
||||
throw Error("模型没有可显示的有效几何内容");
|
||||
}
|
||||
const scale = 2 / Math.max(size.x, size.y, size.z, 0.001);
|
||||
g.scene.scale.setScalar(scale);
|
||||
const normalized = new THREE.Group();
|
||||
normalized.add(g.scene);
|
||||
const fitted = new THREE.Box3().setFromObject(normalized);
|
||||
const center = fitted.getCenter(new THREE.Vector3());
|
||||
g.scene.position.set(-center.x, -fitted.min.y, -center.z);
|
||||
attach(normalized, item);
|
||||
finish();
|
||||
} catch (failure) {
|
||||
dispose(g.scene);
|
||||
finish(failure instanceof Error ? failure.message : "模型内容读取失败");
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
() => finish("模型加载失败,请检查 GLB 文件及资源访问权限"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: item.color || (item.kind === "actor" ? "#687bb0" : "#c3664c"),
|
||||
roughness: 0.6,
|
||||
});
|
||||
let object: THREE.Object3D;
|
||||
if (item.kind === "actor") {
|
||||
const group = new THREE.Group();
|
||||
const pose = directorPoses[item.pose || "站立"] || directorPoses["站立"];
|
||||
const radians = THREE.MathUtils.degToRad;
|
||||
const hips = new THREE.Group();
|
||||
hips.position.y = 1 + ("height" in pose ? pose.height : 0);
|
||||
group.add(hips);
|
||||
const torso = new THREE.Group();
|
||||
torso.rotation.x = radians(pose.lean);
|
||||
hips.add(torso);
|
||||
const body = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.65, 0.28), mat);
|
||||
body.position.y = 0.38;
|
||||
torso.add(body);
|
||||
const head = new THREE.Mesh(new THREE.SphereGeometry(0.2, 20, 16), mat.clone());
|
||||
head.position.y = 0.95;
|
||||
torso.add(head);
|
||||
const limb = (
|
||||
parent: THREE.Object3D,
|
||||
position: [number, number, number],
|
||||
angle: number,
|
||||
bend: number,
|
||||
length: number,
|
||||
radius: number,
|
||||
spread = 0,
|
||||
) => {
|
||||
const root = new THREE.Group();
|
||||
root.position.set(...position);
|
||||
root.rotation.x = radians(angle);
|
||||
root.rotation.z = radians(spread);
|
||||
parent.add(root);
|
||||
const upper = new THREE.Mesh(
|
||||
new THREE.CapsuleGeometry(radius, length - radius * 2, 4, 12),
|
||||
mat.clone(),
|
||||
);
|
||||
upper.position.y = -length / 2;
|
||||
root.add(upper);
|
||||
const joint = new THREE.Group();
|
||||
joint.position.y = -length;
|
||||
joint.rotation.x = radians(bend);
|
||||
root.add(joint);
|
||||
const lower = new THREE.Mesh(
|
||||
new THREE.CapsuleGeometry(radius * 0.85, length - radius * 2, 4, 12),
|
||||
mat.clone(),
|
||||
);
|
||||
lower.position.y = -length / 2;
|
||||
joint.add(lower);
|
||||
};
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const side = i === 0 ? -1 : 1;
|
||||
limb(
|
||||
torso,
|
||||
[side * 0.33, 0.65, 0],
|
||||
pose.arms[i],
|
||||
-pose.elbows[i],
|
||||
0.34,
|
||||
0.07,
|
||||
("spread" in pose ? pose.spread : 5) * side,
|
||||
);
|
||||
limb(hips, [side * 0.15, 0, 0], pose.legs[i], pose.knees[i], 0.46, 0.1);
|
||||
}
|
||||
const [width, height, headScale] =
|
||||
directorBodies[item.body || "标准"] || directorBodies["标准"];
|
||||
group.scale.set(width, height, width);
|
||||
head.scale.setScalar(headScale);
|
||||
const root = new THREE.Group();
|
||||
root.add(group);
|
||||
object = root;
|
||||
} else {
|
||||
object = new THREE.Mesh(
|
||||
item.kind === "sphere"
|
||||
? new THREE.SphereGeometry(0.5, 32, 20)
|
||||
: new THREE.BoxGeometry(1, 1, 1),
|
||||
mat,
|
||||
);
|
||||
}
|
||||
attach(object, item);
|
||||
}
|
||||
setLoading(r.pending > 0);
|
||||
return () => {
|
||||
active = false;
|
||||
timers.forEach(clearTimeout);
|
||||
};
|
||||
}, [props.scene.objects, ready, resourceRevision]);
|
||||
useEffect(() => {
|
||||
const r = runtime.current;
|
||||
if (!r) return;
|
||||
let active = true;
|
||||
let texture: THREE.Texture | undefined;
|
||||
setBackgroundError("");
|
||||
r.world.background = new THREE.Color("#c5cbd3");
|
||||
if (!props.scene.backgroundUrl) {
|
||||
setBackgroundPending(false);
|
||||
return;
|
||||
}
|
||||
setBackgroundPending(true);
|
||||
const timeout = setTimeout(() => {
|
||||
if (active) {
|
||||
active = false;
|
||||
setBackgroundPending(false);
|
||||
setBackgroundError("全景背景加载超时,请重新选择背景");
|
||||
}
|
||||
}, 20000);
|
||||
new THREE.TextureLoader().load(
|
||||
buildThreeAssetUrl(props.scene.backgroundUrl),
|
||||
(loaded) => {
|
||||
clearTimeout(timeout);
|
||||
if (!active) {
|
||||
loaded.dispose();
|
||||
return;
|
||||
}
|
||||
texture = loaded;
|
||||
loaded.mapping = THREE.EquirectangularReflectionMapping;
|
||||
loaded.colorSpace = THREE.SRGBColorSpace;
|
||||
r.world.background = loaded;
|
||||
setBackgroundPending(false);
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
if (active) {
|
||||
setBackgroundPending(false);
|
||||
setBackgroundError("全景背景加载失败,请检查图片或切回默认背景");
|
||||
}
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
active = false;
|
||||
clearTimeout(timeout);
|
||||
if (r.world.background === texture) r.world.background = new THREE.Color("#c5cbd3");
|
||||
texture?.dispose();
|
||||
};
|
||||
}, [props.scene.backgroundUrl, ready, resourceRevision]);
|
||||
useEffect(() => {
|
||||
const r = runtime.current;
|
||||
if (!r) return;
|
||||
if (props.locked) {
|
||||
r.orbit.enabled = false;
|
||||
r.transform.detach();
|
||||
return;
|
||||
}
|
||||
const camera = resolveDirectorCamera(props.scene);
|
||||
if (props.previewTime === null) {
|
||||
r.camera.position.fromArray(camera.position);
|
||||
r.camera.fov = camera.fov;
|
||||
r.camera.updateProjectionMatrix();
|
||||
r.orbit.target.fromArray(camera.target);
|
||||
r.orbit.update();
|
||||
}
|
||||
r.orbit.enabled = !props.cameraView && props.previewTime === null;
|
||||
r.transform.setMode(props.mode);
|
||||
const selected = r.objects.getObjectByName(props.selected);
|
||||
if (
|
||||
selected &&
|
||||
selected.visible &&
|
||||
!props.scene.objects.find((o) => o.id === props.selected)?.locked &&
|
||||
!props.cameraView &&
|
||||
props.previewTime === null
|
||||
)
|
||||
r.transform.attach(selected);
|
||||
else r.transform.detach();
|
||||
}, [
|
||||
props.scene.objects,
|
||||
props.scene.cameras,
|
||||
props.scene.activeCameraId,
|
||||
props.selected,
|
||||
props.mode,
|
||||
props.cameraView,
|
||||
props.previewTime === null,
|
||||
props.locked,
|
||||
ready,
|
||||
]);
|
||||
useEffect(() => {
|
||||
const r = runtime.current;
|
||||
if (!r) return;
|
||||
dispose(r.path);
|
||||
r.path.clear();
|
||||
const motion = props.scene.motion;
|
||||
const tracks = [
|
||||
...(motion.keyframes.length >= 2
|
||||
? [
|
||||
{
|
||||
color: 0xfbbf24,
|
||||
keys: motion.keyframes,
|
||||
points: Array.from(
|
||||
{ length: 121 },
|
||||
(_, i) =>
|
||||
new THREE.Vector3(
|
||||
...sampleDirectorMotion(motion, (motion.duration * i) / 120)!.position,
|
||||
),
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...Object.entries(props.scene.objectTracks || {})
|
||||
.filter(([, t]) => !t.muted && t.keyframes.length >= 2)
|
||||
.map(([id, t]) => ({
|
||||
color: 0x67e8f9,
|
||||
keys: t.keyframes,
|
||||
points: Array.from(
|
||||
{ length: 121 },
|
||||
(_, i) =>
|
||||
new THREE.Vector3(
|
||||
...sampleDirectorObjects(props.scene, (motion.duration * i) / 120).find(
|
||||
(o) => o.id === id,
|
||||
)!.position,
|
||||
),
|
||||
),
|
||||
})),
|
||||
];
|
||||
for (const track of tracks) {
|
||||
r.path.add(
|
||||
new THREE.Line(
|
||||
new THREE.BufferGeometry().setFromPoints(track.points),
|
||||
new THREE.LineBasicMaterial({ color: track.color }),
|
||||
),
|
||||
);
|
||||
for (const k of track.keys) {
|
||||
const dot = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.08, 10, 8),
|
||||
new THREE.MeshBasicMaterial({ color: track.color }),
|
||||
);
|
||||
dot.position.fromArray(k.position);
|
||||
r.path.add(dot);
|
||||
}
|
||||
}
|
||||
}, [props.scene.motion, props.scene.objectTracks, props.scene.objects, ready]);
|
||||
useEffect(() => {
|
||||
const r = runtime.current;
|
||||
if (!r) return;
|
||||
r.path.visible = !props.locked && !props.cameraView && props.previewTime === null;
|
||||
if (props.locked) return;
|
||||
const sampledScene = applyObjectTime(props.previewTime);
|
||||
if (props.previewTime === null) return;
|
||||
const sampled = sampleDirectorMotion(props.scene.motion, props.previewTime);
|
||||
const pose = resolveDirectorCamera(sampledScene, sampled || undefined);
|
||||
r.camera.position.fromArray(pose.position);
|
||||
r.orbit.target.fromArray(pose.target);
|
||||
r.camera.fov = pose.fov;
|
||||
r.camera.updateProjectionMatrix();
|
||||
r.camera.lookAt(r.orbit.target);
|
||||
r.transform.detach();
|
||||
}, [
|
||||
props.previewTime,
|
||||
props.scene.motion,
|
||||
props.scene.cameras,
|
||||
props.scene.objects,
|
||||
props.scene.objectTracks,
|
||||
props.cameraView,
|
||||
props.locked,
|
||||
ready,
|
||||
]);
|
||||
useEffect(() => {
|
||||
runtime.current?.resize();
|
||||
}, [props.scene.ratio, ready]);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
showPath: () => {
|
||||
const r = runtime.current;
|
||||
if (!r) return;
|
||||
const points = latest.current.scene.motion.keyframes.map(
|
||||
(k) => new THREE.Vector3(...k.position),
|
||||
);
|
||||
for (const track of Object.values(latest.current.scene.objectTracks || {}))
|
||||
if (!track.muted)
|
||||
for (const key of track.keyframes) points.push(new THREE.Vector3(...key.position));
|
||||
if (!points.length) return;
|
||||
points.push(new THREE.Vector3(0, 1, 0));
|
||||
const box = new THREE.Box3().setFromPoints(points),
|
||||
center = box.getCenter(new THREE.Vector3());
|
||||
const radius = Math.max(3, box.getSize(new THREE.Vector3()).length());
|
||||
const position = center
|
||||
.clone()
|
||||
.add(new THREE.Vector3(1, 0.8, 1).normalize().multiplyScalar(radius * 1.4));
|
||||
latest.current.onCamera(position.toArray(), center.toArray());
|
||||
},
|
||||
record: async (signal, onProgress) => {
|
||||
if (backgroundPending || backgroundError)
|
||||
throw Error(backgroundError || "全景背景仍在加载,请稍后导出");
|
||||
const r = runtime.current;
|
||||
if (!r || !ready || r.pending || error) throw Error(error || "3D 场景尚未准备好");
|
||||
if (
|
||||
latest.current.scene.motion.keyframes.length < 2 &&
|
||||
!Object.values(latest.current.scene.objectTracks || {}).some(
|
||||
(t) => !t.muted && t.keyframes.length >= 2,
|
||||
)
|
||||
)
|
||||
throw Error("相机或对象轨道至少需要两个关键帧");
|
||||
const motion = latest.current.scene.motion;
|
||||
const size = r.renderer.getSize(new THREE.Vector2()),
|
||||
pixelRatio = r.renderer.getPixelRatio();
|
||||
const position = r.camera.position.clone(),
|
||||
target = r.orbit.target.clone(),
|
||||
fov = r.camera.fov,
|
||||
aspect = r.camera.aspect;
|
||||
const visibility = [r.helper.visible, r.grid.visible, r.path.visible],
|
||||
enabled = r.orbit.enabled;
|
||||
const dimensions = directorImageSize(latest.current.scene.ratio);
|
||||
r.helper.visible = false;
|
||||
r.grid.visible = false;
|
||||
r.path.visible = false;
|
||||
r.orbit.enabled = false;
|
||||
r.renderer.setPixelRatio(1);
|
||||
r.renderer.setSize(dimensions.width, dimensions.height, false);
|
||||
r.camera.aspect = dimensions.width / dimensions.height;
|
||||
try {
|
||||
return await recordDirectorVideo({
|
||||
canvas: r.renderer.domElement,
|
||||
duration: motion.duration,
|
||||
signal,
|
||||
onProgress,
|
||||
render: (time) => {
|
||||
if (r.renderer.getContext().isContextLost())
|
||||
throw Error("3D 显示连接中断,已取消视频导出");
|
||||
const pose = resolveDirectorCamera(
|
||||
applyObjectTime(time),
|
||||
sampleDirectorMotion(motion, time) || undefined,
|
||||
);
|
||||
r.camera.position.fromArray(pose.position);
|
||||
r.orbit.target.fromArray(pose.target);
|
||||
r.camera.fov = pose.fov;
|
||||
r.camera.updateProjectionMatrix();
|
||||
r.camera.lookAt(r.orbit.target);
|
||||
r.renderer.render(r.world, r.camera);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (runtime.current === r) {
|
||||
applyObjectTime(latest.current.previewTime);
|
||||
r.renderer.setPixelRatio(pixelRatio);
|
||||
r.renderer.setSize(size.x, size.y);
|
||||
r.camera.position.copy(position);
|
||||
r.orbit.target.copy(target);
|
||||
r.camera.fov = fov;
|
||||
r.camera.aspect = aspect;
|
||||
r.camera.updateProjectionMatrix();
|
||||
r.orbit.enabled = enabled;
|
||||
r.orbit.update();
|
||||
[r.helper.visible, r.grid.visible, r.path.visible] = visibility;
|
||||
}
|
||||
}
|
||||
},
|
||||
capture: async (pose, time) => {
|
||||
if (backgroundPending || backgroundError)
|
||||
throw Error(backgroundError || "全景背景仍在加载,请稍后截图");
|
||||
const r = runtime.current;
|
||||
if (!r || !ready || r.pending || error) throw Error(error || "3D 场景尚未准备好");
|
||||
const { width, height } = directorImageSize(latest.current.scene.ratio);
|
||||
const previousSize = r.renderer.getSize(new THREE.Vector2());
|
||||
const pixelRatio = r.renderer.getPixelRatio();
|
||||
const aspect = r.camera.aspect;
|
||||
const position = r.camera.position.clone(),
|
||||
target = r.orbit.target.clone(),
|
||||
fov = r.camera.fov;
|
||||
const visibility = [r.helper.visible, r.grid.visible, r.path.visible];
|
||||
const enabled = r.orbit.enabled;
|
||||
r.orbit.enabled = false;
|
||||
const capturedScene = applyObjectTime(time ?? latest.current.previewTime);
|
||||
if (pose) {
|
||||
pose = resolveDirectorCamera(capturedScene, pose);
|
||||
r.camera.position.fromArray(pose.position);
|
||||
r.orbit.target.fromArray(pose.target);
|
||||
r.camera.fov = pose.fov;
|
||||
r.camera.lookAt(r.orbit.target);
|
||||
}
|
||||
r.path.visible = false;
|
||||
r.helper.visible = false;
|
||||
r.grid.visible = false;
|
||||
r.renderer.setPixelRatio(1);
|
||||
r.renderer.setSize(width, height, false);
|
||||
r.camera.aspect = width / height;
|
||||
r.camera.updateProjectionMatrix();
|
||||
r.renderer.render(r.world, r.camera);
|
||||
try {
|
||||
const blob = await new Promise<Blob>((resolve, reject) =>
|
||||
r.renderer.domElement.toBlob(
|
||||
(b) => (b ? resolve(b) : reject(Error("截图失败"))),
|
||||
"image/png",
|
||||
),
|
||||
);
|
||||
return { blob, width, height };
|
||||
} finally {
|
||||
applyObjectTime(latest.current.previewTime);
|
||||
r.renderer.setPixelRatio(pixelRatio);
|
||||
r.renderer.setSize(previousSize.x, previousSize.y);
|
||||
r.camera.aspect = aspect;
|
||||
r.camera.updateProjectionMatrix();
|
||||
r.camera.position.copy(position);
|
||||
r.orbit.target.copy(target);
|
||||
r.camera.fov = fov;
|
||||
r.camera.updateProjectionMatrix();
|
||||
r.orbit.update();
|
||||
[r.helper.visible, r.grid.visible, r.path.visible] = visibility;
|
||||
r.orbit.enabled = enabled;
|
||||
}
|
||||
},
|
||||
}),
|
||||
[ready, error, backgroundPending, backgroundError],
|
||||
);
|
||||
return (
|
||||
<div className="relative h-full w-full" data-testid="director-viewport">
|
||||
<div ref={host} className="h-full w-full" />
|
||||
{error || backgroundError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="absolute inset-x-4 top-4 rounded bg-red-950/90 p-3 text-sm text-white"
|
||||
>
|
||||
{error || backgroundError}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-3 rounded border border-white/30 px-2 py-1"
|
||||
onClick={() => {
|
||||
if (ready) setResourceRevision((value) => value + 1);
|
||||
else setRendererRevision((value) => value + 1);
|
||||
}}
|
||||
>
|
||||
重试 3D 加载
|
||||
</button>
|
||||
</div>
|
||||
) : loading || backgroundPending ? (
|
||||
<div className="absolute left-4 top-4 rounded bg-black/60 p-2 text-white">
|
||||
{backgroundPending ? "正在加载全景背景…" : "正在加载模型…"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
149
components/canvas/smart-edit/EditPreview.tsx
Normal file
149
components/canvas/smart-edit/EditPreview.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
compiledSubtitles,
|
||||
planTimeline,
|
||||
SECOND,
|
||||
type EditPlan,
|
||||
} from "@/lib/video-canvas/smart-edit/contract";
|
||||
export function EditPreview({
|
||||
plan,
|
||||
time,
|
||||
playing,
|
||||
onTime,
|
||||
onPlaying,
|
||||
onError,
|
||||
}: {
|
||||
plan: EditPlan;
|
||||
time: number;
|
||||
playing: boolean;
|
||||
onTime: (t: number) => void;
|
||||
onPlaying: (v: boolean) => void;
|
||||
onError: (s: string) => void;
|
||||
}) {
|
||||
const videos = useRef(new Map<string, HTMLVideoElement>()),
|
||||
music = useRef<HTMLAudioElement | null>(null),
|
||||
latest = useRef({ time, plan, onTime, onPlaying });
|
||||
latest.current = { time, plan, onTime, onPlaying };
|
||||
const timeline = planTimeline(plan),
|
||||
current = timeline.clips.filter((c) => time * SECOND >= c.start && time * SECOND < c.end),
|
||||
subs = compiledSubtitles(plan).filter((s) => time * SECOND >= s.start && time * SECOND < s.end);
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
let frame = 0,
|
||||
last = 0;
|
||||
const tick = (now: number) => {
|
||||
const state = latest.current;
|
||||
if (last) {
|
||||
const next = Math.min(
|
||||
planTimeline(state.plan).duration / SECOND,
|
||||
state.time + (now - last) / 1000,
|
||||
);
|
||||
state.onTime(next);
|
||||
if (next >= planTimeline(state.plan).duration / SECOND) {
|
||||
state.onPlaying(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
last = now;
|
||||
frame = requestAnimationFrame(tick);
|
||||
};
|
||||
const hide = () => {
|
||||
if (document.hidden) latest.current.onPlaying(false);
|
||||
};
|
||||
frame = requestAnimationFrame(tick);
|
||||
document.addEventListener("visibilitychange", hide);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener("visibilitychange", hide);
|
||||
};
|
||||
}, [playing]);
|
||||
useEffect(() => {
|
||||
for (const clip of timeline.clips) {
|
||||
const video = videos.current.get(clip.id);
|
||||
if (!video) continue;
|
||||
const active = current.some((c) => c.id === clip.id);
|
||||
if (!active) {
|
||||
video.pause();
|
||||
continue;
|
||||
}
|
||||
const target = clip.sourceIn / SECOND + (time - clip.start / SECOND) * clip.speed;
|
||||
if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.1)
|
||||
video.currentTime = target;
|
||||
video.playbackRate = clip.speed;
|
||||
const index = timeline.clips.findIndex((c) => c.id === clip.id),
|
||||
next = timeline.clips[index + 1];
|
||||
let gain = 1;
|
||||
if (clip.overlap && time * SECOND < clip.start + clip.overlap)
|
||||
gain = (time * SECOND - clip.start) / clip.overlap;
|
||||
if (next?.overlap && time * SECOND > next.start)
|
||||
gain = Math.min(gain, (clip.end - time * SECOND) / next.overlap);
|
||||
video.volume = Math.min(1, Math.max(0, clip.gain * gain));
|
||||
if (playing)
|
||||
void video.play().catch(() => {
|
||||
onPlaying(false);
|
||||
onError("预览播放失败,请检查素材链接或点击播放重试");
|
||||
});
|
||||
else video.pause();
|
||||
}
|
||||
const audio = music.current;
|
||||
if (audio && plan.music) {
|
||||
const target = time + plan.music.start / SECOND;
|
||||
if (Number.isFinite(audio.duration) && target >= audio.duration) {
|
||||
audio.pause();
|
||||
return;
|
||||
}
|
||||
if (Math.abs(audio.currentTime - target) > 0.15) audio.currentTime = target;
|
||||
audio.volume = Math.min(1, plan.music.gain);
|
||||
if (playing)
|
||||
void audio.play().catch(() => {
|
||||
onPlaying(false);
|
||||
onError("背景音乐无法播放");
|
||||
});
|
||||
else audio.pause();
|
||||
}
|
||||
}, [time, playing, plan]);
|
||||
return (
|
||||
<div
|
||||
className="relative mx-auto w-full max-w-4xl overflow-hidden bg-black"
|
||||
style={{ aspectRatio: plan.ratio.replace(":", "/") }}
|
||||
aria-label="剪辑实时预览"
|
||||
>
|
||||
{timeline.clips.map((clip) => {
|
||||
const asset = plan.assets.find((a) => a.id === clip.assetId)!;
|
||||
const active = current.some((c) => c.id === clip.id),
|
||||
incoming = clip.overlap && time * SECOND < clip.start + clip.overlap;
|
||||
return (
|
||||
<video
|
||||
key={clip.id}
|
||||
ref={(element) => {
|
||||
if (element) videos.current.set(clip.id, element);
|
||||
else videos.current.delete(clip.id);
|
||||
}}
|
||||
src={asset.url}
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
style={{
|
||||
visibility: active ? "visible" : "hidden",
|
||||
opacity: incoming ? Math.max(0, (time * SECOND - clip.start) / clip.overlap) : 1,
|
||||
}}
|
||||
onError={() => {
|
||||
onPlaying(false);
|
||||
onError(`${asset.name} 预览加载失败`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{plan.music ? <audio ref={music} src={plan.music.url} preload="metadata" /> : null}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-4 bottom-[5%] text-center text-[clamp(12px,2vw,22px)] text-white"
|
||||
style={{ textShadow: "-1px -1px 2px black,1px 1px 2px black" }}
|
||||
>
|
||||
{subs.map((s, i) => (
|
||||
<div key={i}>{s.text}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
910
components/canvas/smart-edit/SmartEditDialog.tsx
Normal file
910
components/canvas/smart-edit/SmartEditDialog.tsx
Normal file
@ -0,0 +1,910 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { X, Upload } from "lucide-react";
|
||||
import { EditPreview } from "./EditPreview";
|
||||
import {
|
||||
smartEditRequestSchema,
|
||||
planTimeline,
|
||||
parseSrt,
|
||||
subtitlesToSrt,
|
||||
compiledSubtitles,
|
||||
SECOND,
|
||||
type EditPlan,
|
||||
type SmartEditRequest,
|
||||
} from "@/lib/video-canvas/smart-edit/contract";
|
||||
import type { CanvasGenerationTask } from "@/lib/video-canvas/task-contract";
|
||||
type Asset = { id: string; url: string; name: string; subtitles: ReturnType<typeof parseSrt> };
|
||||
export function SmartEditDialog({
|
||||
initial,
|
||||
plans: serverPlans,
|
||||
planTaskId,
|
||||
sources,
|
||||
audios,
|
||||
preferredModelRef,
|
||||
tasks,
|
||||
onClose,
|
||||
onDraft,
|
||||
onPlan,
|
||||
onRender,
|
||||
}: {
|
||||
initial?: Record<string, any>;
|
||||
plans?: EditPlan[];
|
||||
planTaskId?: string;
|
||||
sources: Asset[];
|
||||
audios: { url: string; name: string }[];
|
||||
preferredModelRef?: string;
|
||||
tasks: CanvasGenerationTask[];
|
||||
onClose: () => void;
|
||||
onDraft: (draft: Record<string, unknown>) => void;
|
||||
onPlan: (params: SmartEditRequest) => Promise<unknown>;
|
||||
onRender: (plan: EditPlan) => Promise<unknown>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Record<string, any>>(() => ({
|
||||
assets: [],
|
||||
mode: "montage",
|
||||
prompt: "",
|
||||
targetDuration: 20,
|
||||
ratio: "16:9",
|
||||
resolution: "720p",
|
||||
semantic: true,
|
||||
removeSilence: false,
|
||||
transcribe: false,
|
||||
beatSync: false,
|
||||
beatsPerCut: "4",
|
||||
variants: 3,
|
||||
preferredModelRef,
|
||||
...initial,
|
||||
plans:
|
||||
initial?.planTaskId === planTaskId ? initial?.plans || serverPlans || [] : serverPlans || [],
|
||||
planTaskId,
|
||||
}));
|
||||
const latest = useRef(draft);
|
||||
latest.current = draft;
|
||||
const patch = (value: Record<string, unknown>) => {
|
||||
const next = { ...latest.current, ...value };
|
||||
latest.current = next;
|
||||
setDraft(next);
|
||||
onDraft(next);
|
||||
};
|
||||
const [selected, setSelected] = useState(0),
|
||||
[clipIndex, setClipIndex] = useState(0),
|
||||
[time, setTime] = useState(0),
|
||||
[playing, setPlaying] = useState(false),
|
||||
[busy, setBusy] = useState(false),
|
||||
[error, setError] = useState(""),
|
||||
[cap, setCap] = useState<any>(),
|
||||
[zoom, setZoom] = useState(1),
|
||||
[snap, setSnap] = useState(true);
|
||||
const applied = useRef(planTaskId);
|
||||
useEffect(() => {
|
||||
if (planTaskId && planTaskId !== applied.current) {
|
||||
applied.current = planTaskId;
|
||||
patch({ plans: serverPlans || [], planTaskId });
|
||||
setSelected(0);
|
||||
setTime(0);
|
||||
setPlaying(false);
|
||||
}
|
||||
}, [planTaskId, serverPlans]);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetch("/api/canvas/smart-edit/capabilities", { signal: controller.signal })
|
||||
.then(async (r) => {
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw Error(data.error);
|
||||
setCap(data);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!controller.signal.aborted) setError(e.message);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
const waiting = tasks.some((t) => t.status === "pending" || t.status === "running"),
|
||||
last = tasks.slice(-1)[0];
|
||||
const plan = draft.plans?.[selected] as EditPlan | undefined;
|
||||
let timeline: ReturnType<typeof planTimeline> | undefined,
|
||||
validation = "";
|
||||
try {
|
||||
if (plan) timeline = planTimeline(plan);
|
||||
} catch (e) {
|
||||
validation = (e as Error).message;
|
||||
}
|
||||
const changePlan = (next: EditPlan) => {
|
||||
setPlaying(false);
|
||||
patch({ plans: draft.plans.map((p: EditPlan, i: number) => (i === selected ? next : p)) });
|
||||
};
|
||||
const saveFile = (name: string, text: string) => {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
const upload = async (file: File, kind: "video" | "audio") => {
|
||||
if (file.size > 125 * 1024 * 1024) throw Error("单个素材不得超过 125 MiB");
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const response = await fetch("/api/canvas/media/upload", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(120000),
|
||||
});
|
||||
const result = await response.json();
|
||||
const url = result.url || result.data?.url;
|
||||
if (!response.ok || !url) throw Error(result.error || "上传失败");
|
||||
if (kind === "audio") patch({ music: { url, gain: 0.2, start: 0 } });
|
||||
else
|
||||
patch({
|
||||
assets: [
|
||||
...latest.current.assets,
|
||||
{ id: crypto.randomUUID(), name: file.name, url, subtitles: [] },
|
||||
],
|
||||
});
|
||||
};
|
||||
const clipEdit = (index: number, value: Record<string, unknown>) => {
|
||||
if (plan)
|
||||
changePlan({
|
||||
...plan,
|
||||
clips: plan.clips.map((c, i) => (i === index ? { ...c, ...value } : c)),
|
||||
});
|
||||
};
|
||||
const cut = (action: "split" | "left" | "right") => {
|
||||
if (!plan || !timeline) return;
|
||||
const clip = timeline.clips[clipIndex];
|
||||
if (!clip) return;
|
||||
const point = Math.round(clip.sourceIn + (time * SECOND - clip.start) * clip.speed);
|
||||
if (point - clip.sourceIn < 200000 || clip.sourceOut - point < 200000) {
|
||||
setError("请把播放头移到片段内部,并在两边至少保留 0.2 秒");
|
||||
return;
|
||||
}
|
||||
if (action === "split") {
|
||||
if (plan.clips.length >= 12) {
|
||||
setError("最多 12 个片段");
|
||||
return;
|
||||
}
|
||||
const left = { ...plan.clips[clipIndex], sourceOut: point },
|
||||
right = { ...plan.clips[clipIndex], id: crypto.randomUUID(), sourceIn: point };
|
||||
changePlan({
|
||||
...plan,
|
||||
clips: [...plan.clips.slice(0, clipIndex), left, right, ...plan.clips.slice(clipIndex + 1)],
|
||||
});
|
||||
} else clipEdit(clipIndex, action === "left" ? { sourceIn: point } : { sourceOut: point });
|
||||
};
|
||||
const button =
|
||||
"rounded border border-white/20 px-3 py-2 text-xs hover:bg-white/10 disabled:opacity-40",
|
||||
input = "rounded border border-white/15 bg-[#303030] px-2 py-1 text-sm";
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="智能剪辑"
|
||||
className="fixed inset-0 z-[160] flex flex-col bg-[#171717] text-white"
|
||||
>
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-white/15 px-5 py-3">
|
||||
<h2>智能剪辑 · {plan?.title || "从素材到成片"}</h2>
|
||||
<button type="button" aria-label="关闭智能剪辑" onClick={onClose}>
|
||||
<X />
|
||||
</button>
|
||||
</header>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[300px_minmax(0,1fr)]">
|
||||
<aside className="overflow-y-auto border-r border-white/15 p-4">
|
||||
<fieldset disabled={busy || waiting} className="space-y-4">
|
||||
<label className="block">
|
||||
剪辑类型
|
||||
<select
|
||||
aria-label="剪辑类型"
|
||||
className={`${input} mt-1 w-full`}
|
||||
value={draft.mode}
|
||||
onChange={(e) => patch({ mode: e.target.value })}
|
||||
>
|
||||
<option value="montage">素材混剪</option>
|
||||
<option value="talk">口播视频</option>
|
||||
<option value="explain">讲解视频</option>
|
||||
<option value="ads">批量广告</option>
|
||||
</select>
|
||||
</label>
|
||||
<div>
|
||||
<div className="mb-2 text-sm">素材({draft.assets.length}/6)</div>
|
||||
<select
|
||||
aria-label="添加画布视频"
|
||||
className={`${input} w-full`}
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const asset = sources.find((a) => a.id === e.target.value);
|
||||
if (asset && draft.assets.length < 6) patch({ assets: [...draft.assets, asset] });
|
||||
}}
|
||||
>
|
||||
<option value="">选择画布已有视频</option>
|
||||
{sources
|
||||
.filter((a) => !draft.assets.some((v: Asset) => v.id === a.id))
|
||||
.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className={`${button} mt-2 inline-flex cursor-pointer gap-1`}>
|
||||
<Upload size={14} />
|
||||
上传视频
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
e.target.value = "";
|
||||
if (files.length + draft.assets.length > 6) {
|
||||
setError("最多 6 个素材");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
for (const file of files) await upload(file, "video");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{draft.assets.map((a: Asset) => (
|
||||
<div key={a.id} className="mt-2 rounded border border-white/10 p-2 text-xs">
|
||||
<div className="break-all">{a.name}</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
patch({ assets: draft.assets.filter((x: Asset) => x.id !== a.id) })
|
||||
}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
<label className="cursor-pointer">
|
||||
导入 SRT
|
||||
<input
|
||||
type="file"
|
||||
accept=".srt"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
try {
|
||||
const subtitles = parseSrt(await file.text());
|
||||
patch({
|
||||
assets: latest.current.assets.map((x: Asset) =>
|
||||
x.id === a.id ? { ...x, subtitles } : x,
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<span>{a.subtitles?.length || 0} 条字幕</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<label className="block">
|
||||
剪辑要求
|
||||
<textarea
|
||||
aria-label="剪辑要求"
|
||||
className={`${input} mt-1 w-full`}
|
||||
rows={3}
|
||||
maxLength={3000}
|
||||
value={draft.prompt}
|
||||
onChange={(e) => patch({ prompt: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label>
|
||||
目标秒数
|
||||
<input
|
||||
aria-label="目标秒数"
|
||||
className={`${input} w-full`}
|
||||
type="number"
|
||||
min={3}
|
||||
max={90}
|
||||
value={draft.targetDuration}
|
||||
onChange={(e) => patch({ targetDuration: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
比例
|
||||
<select
|
||||
aria-label="成片比例"
|
||||
className={`${input} w-full`}
|
||||
value={draft.ratio}
|
||||
onChange={(e) => patch({ ratio: e.target.value })}
|
||||
>
|
||||
{["16:9", "9:16", "1:1"].map((v) => (
|
||||
<option key={v}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
分辨率
|
||||
<select
|
||||
aria-label="成片分辨率"
|
||||
className={`${input} w-full`}
|
||||
value={draft.resolution}
|
||||
onChange={(e) => patch({ resolution: e.target.value })}
|
||||
>
|
||||
<option>720p</option>
|
||||
<option>1080p</option>
|
||||
</select>
|
||||
</label>
|
||||
{draft.mode === "ads" ? (
|
||||
<label>
|
||||
版本数
|
||||
<select
|
||||
aria-label="广告版本数"
|
||||
className={`${input} w-full`}
|
||||
value={draft.variants}
|
||||
onChange={(e) => patch({ variants: Number(e.target.value) })}
|
||||
>
|
||||
{[1, 2, 3].map((v) => (
|
||||
<option key={v}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="flex gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.semantic}
|
||||
onChange={(e) => patch({ semantic: e.target.checked })}
|
||||
/>
|
||||
千问智能选片
|
||||
</label>
|
||||
{draft.semantic ? (
|
||||
<select
|
||||
aria-label="智能选片模型"
|
||||
className={`${input} w-full`}
|
||||
value={draft.preferredModelRef || ""}
|
||||
onChange={(e) => patch({ preferredModelRef: e.target.value })}
|
||||
>
|
||||
<option value="">默认千问3.8</option>
|
||||
{cap?.models?.map((m: any) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<label className="flex gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.removeSilence}
|
||||
onChange={(e) => patch({ removeSilence: e.target.checked })}
|
||||
/>
|
||||
去静音粗剪(保留语句余量)
|
||||
</label>
|
||||
<label className="flex gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!cap?.capabilities?.asr}
|
||||
checked={draft.transcribe}
|
||||
onChange={(e) => patch({ transcribe: e.target.checked })}
|
||||
/>
|
||||
自动语音转写
|
||||
</label>
|
||||
{cap && !cap.capabilities.asr ? (
|
||||
<p className="text-xs text-amber-200">
|
||||
语音识别模型尚未配置,可为素材导入 SRT 字幕。
|
||||
</p>
|
||||
) : null}
|
||||
<div>
|
||||
<label>
|
||||
背景音乐
|
||||
<select
|
||||
aria-label="背景音乐"
|
||||
className={`${input} mt-1 w-full`}
|
||||
value={draft.music?.url || ""}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
music: e.target.value
|
||||
? { url: e.target.value, gain: 0.2, start: 0 }
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">不添加音乐</option>
|
||||
{[
|
||||
...audios,
|
||||
...(draft.music && !audios.some((a) => a.url === draft.music.url)
|
||||
? [{ url: draft.music.url, name: "已上传音乐" }]
|
||||
: []),
|
||||
].map((a) => (
|
||||
<option key={a.url} value={a.url}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className={`${button} mt-2 inline-block cursor-pointer`}>
|
||||
上传音乐
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await upload(file, "audio");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.beatSync}
|
||||
disabled={!draft.music}
|
||||
onChange={(e) => patch({ beatSync: e.target.checked })}
|
||||
/>
|
||||
跟随音乐卡点
|
||||
</label>
|
||||
{draft.beatSync ? (
|
||||
<select
|
||||
aria-label="每次切镜拍数"
|
||||
className={input}
|
||||
value={draft.beatsPerCut}
|
||||
onChange={(e) => patch({ beatsPerCut: e.target.value })}
|
||||
>
|
||||
<option value="2">每两拍</option>
|
||||
<option value="4">每四拍</option>
|
||||
</select>
|
||||
) : null}
|
||||
{cap &&
|
||||
(!cap.capabilities.scenes || !cap.capabilities.ffmpeg || !cap.capabilities.ffprobe) ? (
|
||||
<p role="alert" className="text-sm text-amber-200">
|
||||
剪辑处理环境尚未准备好,请配置本地分析服务。
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`${button} w-full bg-white/10`}
|
||||
disabled={
|
||||
!draft.assets.length ||
|
||||
!cap?.capabilities.scenes ||
|
||||
!cap?.capabilities.ffmpeg ||
|
||||
!cap?.capabilities.ffprobe
|
||||
}
|
||||
onClick={async () => {
|
||||
setError("");
|
||||
setBusy(true);
|
||||
setPlaying(false);
|
||||
try {
|
||||
const params = smartEditRequestSchema.parse({ ...draft, operation: "plan" });
|
||||
await onPlan(params);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{waiting ? "处理中…" : "生成剪辑方案"}
|
||||
</button>
|
||||
</fieldset>
|
||||
</aside>
|
||||
<main className="min-w-0 overflow-y-auto p-4">
|
||||
{last ? (
|
||||
<p role="status" className="mb-3 text-sm">
|
||||
{last.status === "succeeded"
|
||||
? "任务完成"
|
||||
: last.status === "pending"
|
||||
? "排队中"
|
||||
: last.result?.smartEdit?.stage || last.status}
|
||||
{last.error ? `:${last.error}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{error || validation ? (
|
||||
<p role="alert" className="mb-3 rounded bg-red-950/50 p-3 text-sm">
|
||||
{error || validation}
|
||||
</p>
|
||||
) : null}
|
||||
{cap && !cap.capabilities.render ? (
|
||||
<p className="mb-3 text-sm text-amber-200">视频合成服务暂不可用,剪辑方案仍可保存。</p>
|
||||
) : null}
|
||||
{plan ? (
|
||||
<>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{draft.plans.map((p: EditPlan, i: number) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
className={`${button} ${selected === i ? "bg-white/15" : ""}`}
|
||||
onClick={() => {
|
||||
setSelected(i);
|
||||
setTime(0);
|
||||
setPlaying(false);
|
||||
setClipIndex(0);
|
||||
}}
|
||||
>
|
||||
{p.title}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={!timeline || busy || waiting || !cap?.capabilities?.render}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setPlaying(false);
|
||||
try {
|
||||
await onRender(plan);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
导出到画布
|
||||
</button>
|
||||
{draft.plans.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={busy || waiting || !timeline || !cap?.capabilities?.render}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
for (const p of draft.plans) await onRender(p);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
导出全部版本
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => saveFile("剪辑方案.json", JSON.stringify(plan, null, 2))}
|
||||
>
|
||||
下载剪辑方案
|
||||
</button>
|
||||
{timeline ? (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => saveFile("字幕.srt", subtitlesToSrt(compiledSubtitles(plan)))}
|
||||
>
|
||||
下载字幕
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{timeline ? (
|
||||
<>
|
||||
<EditPreview
|
||||
plan={plan}
|
||||
time={time}
|
||||
playing={playing}
|
||||
onTime={setTime}
|
||||
onPlaying={setPlaying}
|
||||
onError={setError}
|
||||
/>
|
||||
<div className="my-3 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => {
|
||||
if (time >= timeline!.duration / SECOND) setTime(0);
|
||||
setPlaying(!playing);
|
||||
}}
|
||||
>
|
||||
{playing ? "暂停预览" : "播放预览"}
|
||||
</button>
|
||||
<span className="text-sm">
|
||||
{time.toFixed(2)} / {(timeline.duration / SECOND).toFixed(2)} 秒
|
||||
</span>
|
||||
<label className="text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={snap}
|
||||
onChange={(e) => setSnap(e.target.checked)}
|
||||
/>
|
||||
吸附切点
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
缩放{" "}
|
||||
<select
|
||||
aria-label="剪辑时间轴缩放"
|
||||
className={input}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
>
|
||||
{[1, 2, 4].map((v) => (
|
||||
<option key={v}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<div style={{ minWidth: `${zoom * 100}%` }}>
|
||||
<input
|
||||
aria-label="剪辑播放头"
|
||||
type="range"
|
||||
className="w-full"
|
||||
min={0}
|
||||
max={Math.max(0, timeline.duration / SECOND - 0.001)}
|
||||
step={1 / 30}
|
||||
value={time}
|
||||
onChange={(e) => {
|
||||
let value = Number(e.target.value);
|
||||
if (snap) {
|
||||
const near = timeline!.clips
|
||||
.map((c) => c.start / SECOND)
|
||||
.find((t) => Math.abs(t - value) < 0.12);
|
||||
if (near !== undefined) value = near;
|
||||
}
|
||||
setPlaying(false);
|
||||
setTime(value);
|
||||
}}
|
||||
/>
|
||||
<div className="flex h-12">
|
||||
{timeline.clips.map((c, i) => (
|
||||
<button
|
||||
type="button"
|
||||
className={`min-w-0 overflow-hidden border p-1 text-xs ${i === clipIndex ? "border-amber-300 bg-amber-400/20" : "border-white/20 bg-white/5"}`}
|
||||
style={{ width: `${(c.duration / timeline!.duration) * 100}%` }}
|
||||
key={c.id}
|
||||
onClick={() => {
|
||||
setClipIndex(i);
|
||||
setTime(c.start / SECOND);
|
||||
setPlaying(false);
|
||||
}}
|
||||
>
|
||||
{i + 1} · {c.label || "片段"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<fieldset disabled={busy || waiting} className="mt-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(["split", "left", "right"] as const).map((v, i) => (
|
||||
<button type="button" key={v} className={button} onClick={() => cut(v)}>
|
||||
{["分割所选片段", "向左裁剪", "向右裁剪"][i]}
|
||||
</button>
|
||||
))}
|
||||
<label className="text-sm">
|
||||
转场{" "}
|
||||
<select
|
||||
aria-label="剪辑转场"
|
||||
className={input}
|
||||
value={plan.transition}
|
||||
onChange={(e) => changePlan({ ...plan, transition: Number(e.target.value) })}
|
||||
>
|
||||
<option value={0}>直接切换</option>
|
||||
<option value={0.25}>叠化 0.25 秒</option>
|
||||
<option value={0.5}>叠化 0.5 秒</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={plan.subtitles}
|
||||
onChange={(e) => changePlan({ ...plan, subtitles: e.target.checked })}
|
||||
/>
|
||||
字幕轨道
|
||||
</label>
|
||||
{plan.music ? (
|
||||
<>
|
||||
<label className="text-sm">
|
||||
音乐音量{" "}
|
||||
<input
|
||||
aria-label="音乐音量"
|
||||
type="number"
|
||||
className={`${input} w-16`}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={plan.music.gain}
|
||||
onChange={(e) =>
|
||||
changePlan({
|
||||
...plan,
|
||||
music: { ...plan.music!, gain: Number(e.target.value) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
音乐起点(秒){" "}
|
||||
<input
|
||||
aria-label="音乐起点"
|
||||
type="number"
|
||||
className={`${input} w-20`}
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={plan.music.start / SECOND}
|
||||
onChange={(e) =>
|
||||
changePlan({
|
||||
...plan,
|
||||
music: {
|
||||
...plan.music!,
|
||||
start: Math.round(Number(e.target.value) * SECOND),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
{["片段", "入点(秒)", "出点(秒)", "速度", "原声音量", "顺序"].map(
|
||||
(t) => (
|
||||
<th className="px-2 py-2" key={t}>
|
||||
{t}
|
||||
</th>
|
||||
),
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plan.clips.map((clip, i) => (
|
||||
<tr key={clip.id} className={i === clipIndex ? "bg-white/10" : ""}>
|
||||
<td className="max-w-48 px-2 py-2">
|
||||
<button type="button" onClick={() => setClipIndex(i)}>
|
||||
{i + 1} · {plan.assets.find((a) => a.id === clip.assetId)?.name}
|
||||
</button>
|
||||
</td>
|
||||
{(["sourceIn", "sourceOut", "speed", "gain"] as const).map((key, j) => (
|
||||
<td className="px-2 py-2" key={key}>
|
||||
<input
|
||||
aria-label={`片段${i + 1}${["入点", "出点", "速度", "音量"][j]}`}
|
||||
type="number"
|
||||
className={`${input} w-24`}
|
||||
min={key === "speed" ? 0.5 : 0}
|
||||
max={key === "speed" ? 2 : key === "gain" ? 1 : 180}
|
||||
step={j < 2 ? 0.1 : 0.1}
|
||||
value={j < 2 ? clip[key] / SECOND : clip[key]}
|
||||
onChange={(e) =>
|
||||
clipEdit(i, {
|
||||
[key]:
|
||||
j < 2
|
||||
? Math.round(Number(e.target.value) * SECOND)
|
||||
: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="whitespace-nowrap px-2 py-2">
|
||||
{[-1, 1].map((delta) => (
|
||||
<button
|
||||
type="button"
|
||||
key={delta}
|
||||
className={button}
|
||||
disabled={i + delta < 0 || i + delta >= plan.clips.length}
|
||||
onClick={() => {
|
||||
const clips = [...plan.clips];
|
||||
[clips[i], clips[i + delta]] = [clips[i + delta], clips[i]];
|
||||
changePlan({ ...plan, clips });
|
||||
setClipIndex(i + delta);
|
||||
}}
|
||||
>
|
||||
{delta < 0 ? "上移" : "下移"}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={plan.clips.length === 1}
|
||||
onClick={() => {
|
||||
changePlan({
|
||||
...plan,
|
||||
clips: plan.clips.filter((_, j) => j !== i),
|
||||
});
|
||||
setClipIndex(0);
|
||||
setTime(0);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm">编辑原始字幕</summary>
|
||||
{plan.assets.flatMap((asset) =>
|
||||
asset.subtitles.map((s, i) => (
|
||||
<label
|
||||
className="mt-2 flex items-center gap-2 text-xs"
|
||||
key={`${asset.id}-${i}`}
|
||||
>
|
||||
<span>
|
||||
{asset.name} {(s.start / SECOND).toFixed(1)}s
|
||||
</span>
|
||||
<input
|
||||
aria-label={`${asset.name}字幕${i + 1}`}
|
||||
className={`${input} flex-1`}
|
||||
value={s.text}
|
||||
maxLength={500}
|
||||
onChange={(e) =>
|
||||
changePlan({
|
||||
...plan,
|
||||
assets: plan.assets.map((a) =>
|
||||
a.id === asset.id
|
||||
? {
|
||||
...a,
|
||||
subtitles: a.subtitles.map((old, j) =>
|
||||
j === i ? { ...old, text: e.target.value } : old,
|
||||
),
|
||||
}
|
||||
: a,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)),
|
||||
)}
|
||||
</details>
|
||||
</fieldset>
|
||||
{plan.warnings.map((w, i) => (
|
||||
<p className="mt-2 text-xs text-amber-200" key={i}>
|
||||
{w}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="p-12 text-center text-white/50">
|
||||
添加素材后生成剪辑方案,在这里预览和调整时间轴。
|
||||
</div>
|
||||
)}
|
||||
{tasks
|
||||
.filter(
|
||||
(t) =>
|
||||
t.status === "succeeded" &&
|
||||
t.request.params.operation === "render" &&
|
||||
t.result?.url,
|
||||
)
|
||||
.map((t) => (
|
||||
<section key={t.id} className="mt-5 rounded border border-white/15 p-3">
|
||||
<div className="mb-2 text-sm">{t.request.node.title}</div>
|
||||
<video controls src={String(t.result!.url)} className="max-h-72 w-full" />
|
||||
<a
|
||||
className={`${button} mt-2 inline-block`}
|
||||
href={String(t.result!.url)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
download
|
||||
>
|
||||
下载成片
|
||||
</a>
|
||||
</section>
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
docs/VIP98K排查-2026-09-11.md
Normal file
84
docs/VIP98K排查-2026-09-11.md
Normal file
@ -0,0 +1,84 @@
|
||||
# VIP98K 图片参数、参考图限制与详情替换状态修复
|
||||
|
||||
2026-09-11,北京时间。运行修复已上线至 `ai.yydd.ai`。服务器代码版本 `b26e2650`,构建 ID `bEps6MRbyVkKLfBQTUChg`;本地对应提交 `aaa65026`。
|
||||
|
||||
## 实测结论
|
||||
|
||||
读取[服务商公开目录](https://api.vip98k.com/api/v2/catalog/models)及当前账户授权列表,测试其中 9 个生图模型。不含 OCR、抠图、压缩等处理工具,也不包含其他服务商。共 77 次尺寸、比例及质量调用:64 次取得结果图,13 次接口失败;另完成 1 次超过 10MiB 原图继续编辑的完整参考图处理调用。出图成功不等于尺寸或比例符合要求。
|
||||
|
||||
[实测图片与全部记录](./qa-vip98k-0911/index.html)提供原图、实际像素、耗时、请求编号和 JSON;这些大体积验收附件保存在本地,未纳入 Git。测试使用合成蓝 A / 红 B 卡片,验证接口与返回参数,不代表复杂商品细节一致性的验收。
|
||||
|
||||
| 模型 | 真实结果 | 本次处理 |
|
||||
| --- | --- | --- |
|
||||
| gpt-image-2-vip | 方图 1K=1024²、2K=2048²、4K=2880²;横图亦成功。2K 覆盖 9 个固定比例 | 恢复 1K/2K/4K 与比例;同步目录缺字段不再覆盖已验证能力 |
|
||||
| gpt-image-2.5 | 请求 1024²、2048²、2880² 都返回约 1254²;2K、4K 档位字符串也只返回约 1672×940 横图。9:16、4:5、5:4 成功 | 恢复比例,保留真实可用的 1K 档;不虚报 2K/4K |
|
||||
| gpt-image-2.5-flare | 三档有实图;2K 的 2736×1536 会变成 3840×2160。4:5、5:4 即使显式传 aspect_ratio,仍变成 3:4、4:3 | 修正跨档边界为 2720×1536,实收 2048×1152;保留 7 个已验证固定比例 |
|
||||
| nano-banana-2 | 方图三档 1024²/2048²/4096²;4K 横图 5504×3072 | 保留三档和比例 |
|
||||
| nano-banana-pro | 方图三档 1024²/2048²/4096²;4K 横图 5504×3072 | 保留三档和比例 |
|
||||
| gpt-image-2 | multipart edits 方图 2K 成功;初测超大尺寸遇到 502/503,调整后 2K、4K 横图成功 | 记录原生 edits 证据;网站尚未启用该 multipart 线路,未偷偷改为其他接口或模型 |
|
||||
| gpt-image-2.5-sunburst | 三档成功;2K 横图同样存在升到 4K 的边界现象 | 保持网站原有隐藏状态 |
|
||||
| nano-banana-2-lite | 双参考与无参考各一次均 HTTP 400 | 保持不可选 |
|
||||
| nano-banana-fast | 双参考与无参考各一次均 HTTP 400 | 保持不可选 |
|
||||
|
||||
覆盖方式:各模型基础调用;可成功的主要线路补测三档方图及高分辨率横图;VIP/Flare 补测 2K 的全部原有固定比例,并对异常做对照。不是每个模型、每个比例、每个档位的完整笛卡尔积测试。
|
||||
|
||||
普通 GPT Image 2.5 的 2K/4K 不是“没公开所以推断不支持”,而是实际请求已接受、实际像素未提高。VIP 与 Flare 的 4K 方图是 2880²;不能把所有模型的 4K 都解释为 4096²。
|
||||
|
||||
## 为什么界面连 1K 都消失
|
||||
|
||||
目录缺少尺寸和比例数据时,原同步规则回退到“1K、auto”;此外,简洁参数面板把比例和分辨率放进同一数组,并按 `aspectRatio:imageSize` 去重,导致“auto”覆盖了“1K”按钮。
|
||||
|
||||
已拆成“比例”和“分辨率”两组,修正各自选中状态,并在收起后的按钮上显示当前分辨率。按验证结果更新了现有 5 个 VIP98K 模型的尺寸、比例能力,保留模型价格、启停状态和既有业务配置。Flare 的 4:5、5:4 因反复偏离请求未继续展示。
|
||||
|
||||
## 10MiB 只约束送入服务商的参考副本
|
||||
|
||||
网站上传上限是 20MiB;用户报错描述的是服务商单张参考图 10MiB。这不限制生成结果原图,也不会为了 10MiB 改写结果原件。
|
||||
|
||||
实际闭环:
|
||||
|
||||
- 将本次成功生成的 10,622,997 字节结果原图保存在对象存储。
|
||||
- 使用修复后的真实服务商适配器,把它作为下一次生成的参考图。
|
||||
- 生成 JPEG 参考副本 451,639 字节;准备阶段约 17.5 秒;完整测试约 83.2 秒并成功返回新图。
|
||||
- 下载原件核对 SHA-256,内容完全未变。
|
||||
|
||||
统一覆盖外部图片地址、Data URL 和无默认 S3 配置分支;压缩失败停止提交,不再透传原始字节。压缩后检查字节上限,新缓存命名空间隔离历史未校验对象,缓存 HEAD 校验大小。仅参考副本沿用原有最长边 1920 策略,完整保留画面;结果原图归档流程未增加 10MiB 限制。
|
||||
|
||||
## 张华的三次详情替换与页面一直处理中
|
||||
|
||||
会话 `0f1c5be0-4522-4300-abcb-a93e17a60001`,三次都重跑“拼合切片 2”,实际使用 `gpt-image-2.5-flare`,每次提交 2 张参考图。
|
||||
|
||||
| 北京时间 | 总耗时 | 前置识别 | 生图调用阶段 | 结果 |
|
||||
| --- | ---: | ---: | ---: | --- |
|
||||
| 13:24:30–13:25:07 | 36.483s | 27.070s | 8.773s | HTTP 400 |
|
||||
| 13:32:06–13:33:14 | 67.919s | 14.824s | 52.337s | 两次 502 后 HTTP 400 |
|
||||
| 13:34:33–13:34:56 | 23.114s | 13.750s | 8.305s | HTTP 400 |
|
||||
|
||||
第二次有 15 秒和 30 秒重试等待,共 45 秒。参考图准备分别 394ms、2ms、0ms,不是上传大图造成这三次等待。
|
||||
|
||||
这些记录中的图片是失败后展示的参考图。原代码把回退参考图保存进 GeneratedImage 并递增成功数,造成“日志里有图片”的误导。现在失败保留清楚提示,但不再记作成功生成;没有删除历史三条记录。
|
||||
|
||||
页面另有独立缺陷:
|
||||
|
||||
1. 工作区没有消费后台 `data-appendMessage` 结果事件。
|
||||
2. 历史同步只比较消息 ID,可能忽略同一消息的内容更新。
|
||||
3. 进行中状态依赖流状态或结果数量,重跑旧卡片、并行乱序以及失败附带参考图时会判断失误。
|
||||
|
||||
已补齐结果事件处理、按请求 ID/卡片 ID 判定结束、内容同步、失败结束状态。切回页面、网络恢复和 pageshow 触发历史同步;所有目标卡片结束后主动关闭遗留流连接并清除本地“处理中”。旧的本地待处理记录也能从历史恢复。
|
||||
|
||||
没有证据证明张华当时一定切换过浏览器。后台标签页可能延迟前端刷新,但不能据此代替上述已确认的代码原因。
|
||||
|
||||
## 服务商 400 仍有未确认因素
|
||||
|
||||
原三次日志没有实际 size、quality 与服务商请求编号,无法追溯并确定是哪一个字段导致拒绝;也没有证据把用户贴出的 10MiB 错误与这三次绑定。
|
||||
|
||||
修复已记录尺寸、比例、质量和 `x-request-id`。上线后另一次 VIP 调用出现 400,但同质量合成测试成功。Flare 后续的高、中、自动质量都出现过 400;同一路线此前大量自动质量调用成功,中质量亦有成功。因此没有把“高质量不支持”当成定论,也未据此隐藏质量选项。对照请求号及结果保存在实测记录中,可交给服务商定位。不能承诺已经消除服务商自身的拒绝或波动。
|
||||
|
||||
## 上线与验证
|
||||
|
||||
- 42 项相关单元/回归测试通过;包含真实大于 10MiB 图片压缩、损坏数据拒绝、模型路由、像素边界及历史恢复。
|
||||
- 浏览器使用真实 AI SDK 验证:结果乱序、失败附图、流保持打开、恢复历史后的状态结束;真实参数组件验证 4K/16:9 切换和仅 1K 时按钮不丢失。
|
||||
- TypeScript 与生产构建通过。候选版本首页/登录页 HTTP 200,受保护模型/聊天 API 返回预期 401,风格化页返回预期认证跳转;生产域名检查通过。
|
||||
- 先将新请求切到候选实例,等待原主进程连接归零后更新主实例,再将新请求切回主实例。保留旧静态资源,避免已打开页面加载旧分块失败。
|
||||
- 回滚构建:服务器 `backups/next-before-vip98k-0911`;模型配置备份:`backups/vip98k-sizing-0911-1789109819739.json`;Nginx 原配置:`backups/vip98k-nginx-0911.conf`。
|
||||
|
||||
已经打开的页面需要刷新一次载入新前端。之后切回浏览器会主动补同步,无须靠反复刷新判断是否完成。
|
||||
BIN
docs/assets/video-canvas-audit-2026-09-17/local-director.png
Normal file
BIN
docs/assets/video-canvas-audit-2026-09-17/local-director.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
docs/assets/video-canvas-audit-2026-09-17/reference-director.png
Normal file
BIN
docs/assets/video-canvas-audit-2026-09-17/reference-director.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
@ -22,3 +22,10 @@
|
||||
- 使用现场实际 GLB 和交互清单:整装模型 23/23 配件匹配,拆解前后 Canvas 截图不同;包装模型可加载,包装层全部匹配。真实文件仅保存在本地 QA 目录,未提交仓库。
|
||||
- GLTFLoader 原始节点名回归测试覆盖两个实例、普通名字和不存在的节点;限流测试覆盖 600 次上限、对话预算隔离和另一用户隔离。
|
||||
- 27 项相关测试通过;TypeScript 检查、Next.js 生产构建及 git diff --check 通过。构建有既存 HEIC 动态 require 警告。
|
||||
|
||||
|
||||
开发分支补充记录:
|
||||
|
||||
## 发布
|
||||
2026-09-14 已发布至 45.192.99.48 / ai.yydd.ai。生产代码提交 `f3cded51`,构建 ID `kGGW4LcGZLR8YnJF06qkH`。先在 3126 验证,再将正式 3125 服务切换至新构建;保留旧构建 `.next-pre-claw-0914`。线上登录页和新 JS 返回 200,未登录访问会话附件仍返回 401。
|
||||
发布时已有的 Claw 上游任务继续通过持久化状态恢复,正式服务切换后记录仍在更新。修复分支及生产集成分支均已推送 Git。
|
||||
|
||||
@ -22,3 +22,9 @@
|
||||
`--source-ref=54c857d7` 可复现旧查看器因外部请求挂起而空白;`--model-file=<local GLB>` 可检查本地真实模型,不上传文件。
|
||||
|
||||
15 项 `tests/routes/openclaw-3d-artifacts.test.ts` 测试通过;TypeScript 检查通过。浏览器测试未调用生产生成功能、未修改业务数据,真实模型文件不入库。
|
||||
|
||||
|
||||
开发分支补充记录:
|
||||
|
||||
## 发布
|
||||
生产构建通过,修复发布到 48 服务器:代码提交 `73593051`,构建 `0wDzz4WyOuL9um6ei6V_k`,目录 `.next-claw-3d-loading-0915`。线上登录页、新运行时及两种查看器资源均返回 200,匿名历史接口仍返回 401。通过临时实例承接流量,等待旧实例请求结束后切换主实例;上一版构建保留用于回滚。
|
||||
|
||||
@ -16,3 +16,9 @@
|
||||
`node scripts/verify-claw-history-ui.mjs` 使用 Chrome、项目实际 React/AI SDK/SWR、从源文件按原始顺序提取的两个历史 effect,覆盖:缓存往返、Claw 进行中加载历史、用户消息去重、跨会话迟到响应隔离、普通流式输出保护、窗口返回刷新、断网重连刷新及失败后手动重试。
|
||||
|
||||
`node scripts/verify-claw-history-ui.mjs --source-ref=c024ae31` 可复现旧版缓存往返后消息列表为空的断言失败。测试使用合成消息和本地 HTTP 服务,不发送用户对话、不调用生图服务、不修改数据库。
|
||||
|
||||
|
||||
开发分支补充记录:
|
||||
|
||||
## 发布验证
|
||||
8 个浏览器场景、141 项相关测试及 TypeScript/生产构建通过。修复发布到 48 服务器,代码提交 `3bf0fec0`,构建 `Hqpoo4Axj8R_mMeZiR838`;保留上一版表格修复构建用于回滚。线上登录页和新前端资源返回 200,未登录访问历史接口仍返回 401。通过临时实例承接流量,待原实例请求完成后切换,避免中断既有生成任务。
|
||||
|
||||
35
docs/video-canvas-aliyun-test-2026-09-16.md
Normal file
35
docs/video-canvas-aliyun-test-2026-09-16.md
Normal file
@ -0,0 +1,35 @@
|
||||
# 阿里云万相视频测试(2026-09-16)
|
||||
|
||||
按“先用阿里云的视频测试”执行。沿用红色咖啡杯参考图与推进镜头提示词,真实调用阿里云百炼北京线路;只测试 `wan2.6-i2v-flash` 首帧生视频,未改线上默认模型,未部署。
|
||||
|
||||
## 实际结果
|
||||
|
||||
| 环节 | 结果 | 说明 |
|
||||
|---|---|---|
|
||||
| 原“千问视频”凭据 | 401 / invalid_api_key | 该配置下没有模型;不是已可用的视频线路 |
|
||||
| 现有“阿里云”官方凭据 | 可用 | 用现有配置进行测试,没有展示或提交密钥 |
|
||||
| 阿里云直接请求 | 成功,41.888 秒 | 任务 `ba336392-6ddb-41f0-8db5-33af2ab41396`,包含 10 秒轮询间隔;不是精确服务商计算时长 |
|
||||
| 本站 `/api/canvas/generate/video` | 成功,39.166 秒 | 独立新任务 `593dced8-36e0-4a9a-8b3b-ff90abd603ff`;实际使用万相,未走 Veo |
|
||||
| 成片文件 | 通过解码检查 | H264、1280×720、5.000 秒、1,118,864 字节;按请求生成无音轨视频 |
|
||||
| 画布自动转存、刷新播放 | 本轮未验收 | 本地数据库隧道出现 CONNECT_TIMEOUT,画布读取超时;恢复后刷新时浏览器连接关闭。不能沿用上一轮 Veo 的浏览器通过结果作为万相本轮通过证据 |
|
||||
| 画面效果 | 尚不达标 | 2 秒抽帧出现大小两个杯子叠影;提示词要求保持单个杯子,不能把接口成功当成产品一致性达标 |
|
||||
|
||||
## 本轮接入
|
||||
|
||||
新增独立万相画布适配器,调用官方 `video-synthesis` 创建异步任务,再用任务 ID 查询;生成成功交给已有画布转存流程。不改旧聊天视频 Provider 的行为。
|
||||
|
||||
模型筛选仅开放已接入的 `qianwen-video / wan2.6-i2v-flash`。配置采用首帧模式、720P/1080P、2–15 秒整数、随参考图比例。单图精确传入 `img_url`;显式传入分辨率、时长和音频开关,不再依赖旧 Provider 的默认参数。首尾帧、多参考、外部音频、其他万相版本不因此标为已支持。
|
||||
|
||||
本轮相关回归共 35 项通过(34 项批次通过,随后新增 1 项错误处理检查并重跑 6 项适配器测试通过),源码 TypeScript 检查通过。覆盖提交参数、只读轮询、任务失败、无产物不成功、未实现模型不出现在候选中。未运行完整生产构建。
|
||||
|
||||
官方参数依据:[阿里云万相首帧 API 文档](https://help.aliyun.com/zh/model-studio/legacy-image-to-video-api-reference/)。本轮真实请求只覆盖 5 秒、720P、无声,其他官方支持参数没有逐个付费生成。
|
||||
|
||||
## 隔离范围与证据
|
||||
|
||||
本地工作树在 `codex/video-canvas-parity-0916`。数据库专用 schema `codex_wan_20260916` 只覆盖测试用 AiProvider/AiModel,复用现有官方凭据;生产 public 模型与服务商配置未修改。使用专用 QA 账号和测试画布。
|
||||
|
||||
原始证据位于 `.qa/video-aliyun/`:`direct.jsonl`、`canvas-result.json`、`media-probe.json`、`wan-canvas.mp4`、`wan-frame.png`、`tests.log`、`typecheck.log`。临时签名地址、账号凭据和原始环境文件不提交 Git。
|
||||
|
||||
阿里云任务在当前请求内轮询,275 秒内未完成会带任务 ID 报超时;尚未增加持久化任务队列或刷新后续查。当前通过范围是官方直连与本站完整生成 API,不包含浏览器一键生成到刷新恢复的完整闭环。
|
||||
|
||||
收尾已禁用专用测试账号并撤销会话,删除本轮隔离模型 schema、临时凭据,停止本轮开发服务及数据库隧道。未改生产服务商 Key 或模型默认项。
|
||||
40
docs/video-canvas-audio-director-2026-09-17.md
Normal file
40
docs/video-canvas-audio-director-2026-09-17.md
Normal file
@ -0,0 +1,40 @@
|
||||
# 阿里云音频与导演台扩展实施记录
|
||||
|
||||
日期:2026-09-17。此记录接续 video-canvas-task-repairs-2026-09-17.md。
|
||||
|
||||
## 已完成代码
|
||||
|
||||
- 语音/音乐节点从仅上传改为官方模型选择、文本、音色、语言、格式、指令和歌曲参数。控件严格跟随所选模型能力,千问3 TTS 不发送其不支持的数值音调/速度参数。
|
||||
- 阿里云 HTTP 适配:qwen3-tts-flash、qwen3-tts-instruct-flash、qwen-audio-3.0-tts-flash/plus、fun-music-v1。北京限定模型不能错用国际服务地址;使用原服务商密钥,不给浏览器返回密钥。
|
||||
- 音频加入持久任务队列。先记录供应商原文件,再下载校验 WAV/MP3 并归档;转存失败只恢复下载,网络受理状态不明不会自动再次生成。
|
||||
- 导演台 AI 识图入口:调用当前视觉理解模型,把参考图转为人物/立方体/球体和机位组成的可编辑简化布景。结果独立保存,允许显式插入或替换原对象。不是精细纹理/网格重建,也没有凭空生成 GLB。
|
||||
- 对象轨道包含位置、旋转、缩放、可见性、启用开关、线性/缓入缓出插值、逐帧时间编辑/删除、自动记录、秒/30fps 显示单位、时间轴缩放。旋转按最短弧插值;对象删除清理轨道;修改总时长同步缩放轨道。
|
||||
- 相机与对象共用播放头和导出时间;跟随相机采样运动后的目标对象。纯对象动画也可预览和导出。场景骨骼动作序列、声音轨道、跨机位自动切镜尚未实现。
|
||||
- 修正浏览器 crypto.randomUUID 失去调用上下文导致记录帧/插入布景报 Illegal invocation;修正视频编码器立即停止可能丢掉末帧,预热首帧并留出末帧呈现时间。
|
||||
|
||||
## 48 服务器配置
|
||||
|
||||
已使用 `scripts/configure-aliyun-canvas-audio.cjs --apply` 写入现有阿里云官方北京服务商,保留其他模型和全局默认设置;变更前模型记录备份在应用目录 `.model-config-backups/`(不含服务商密钥)。
|
||||
|
||||
- qwen3-tts-instruct-flash:启用,音频页面优先顺序 900。
|
||||
- qwen3-tts-flash:启用,顺序 901。
|
||||
- fun-music-v1:已建配置,禁用,标记 activationRequired,等待账号邀测权限确认。
|
||||
- Qwen Audio 3.0:适配已写,当前 Key 的只读 models 目录未列出,未盲目启用。
|
||||
- 千问3.8既有视觉理解配置保留。
|
||||
|
||||
只读调用官方模型目录返回 HTTP 200,包含千问3 TTS/指令版;未发起真实付费语音/音乐/识图生成。模型目录可见性不能代替生成效果验收。
|
||||
|
||||
[千问 TTS 官方文档](https://help.aliyun.com/zh/model-studio/qwen-tts-api);[新音频 HTTP 文档](https://help.aliyun.com/zh/model-studio/cosyvoice-tts-http-api);[Fun-Music 官方文档](https://help.aliyun.com/zh/model-studio/fun-music-api)(邀测、北京地域、临时文件 24 小时)。
|
||||
|
||||
## 验证
|
||||
|
||||
- helpers 全套 475 项通过;追加总时长/删除对象轨道用例后导演台 24 项通过,合计覆盖 476 项。
|
||||
- TypeScript `tsc --noEmit --incremental false` 通过。
|
||||
- `scripts/qa/video-canvas-audio.cjs` 使用实际服务适配器和本地替身:请求参数、归档前检查点、存储失败后零重复 POST、链接过期、服务商错误、租约丢失均通过。
|
||||
- `scripts/qa/video-canvas-task-db.cjs` 实际临时 PostgreSQL 验证通过:并发幂等、独占领取、恢复与失效 worker 隔离。
|
||||
- 本地浏览器调用实际 React 组件和 Three.js:音频能力控件、未开通音乐拦截、对象播放头/关键帧编辑/帧单位、独立对象动画视频导出、重建结果插入/替换通过,无浏览器运行错误。模型与上传返回使用本地替身。
|
||||
- 真实浏览器录制文件经 ffprobe 确认为 VP9、1280×720;设置 2 秒,文件约 1.974 秒。提取首尾帧确认对象实际移动。MediaRecorder 属于实时录制,受浏览器帧调度影响,不能宣称离线逐帧精确编码。
|
||||
|
||||
参考站及开源自动剪辑算法详见 `video-canvas-smart-edit-research-2026-09-17.md`。该部分为已完成研究和实施方案,尚未接入完整自动混剪服务。
|
||||
|
||||
部署状态:本次生产环境仅更新上述模型配置;应用代码在开发分支完成,未发布到生产应用。
|
||||
64
docs/video-canvas-completion-2026-09-16.md
Normal file
64
docs/video-canvas-completion-2026-09-16.md
Normal file
@ -0,0 +1,64 @@
|
||||
# 视频画布继续完善与复测(2026-09-16)
|
||||
|
||||
> 后续第三轮进展见 [脚本、参考图与视频转存回归](video-canvas-followup-2026-09-16.md)。
|
||||
|
||||
本记录接续 [上一轮真实测试](video-canvas-real-test-2026-09-16.md)。独立工作树本地验证,沿用真实服务商和数据库配置,使用专用测试账号;没有启用模拟接口,没有修改真实用户画布或默认模型。
|
||||
|
||||
## 本轮完成的功能
|
||||
|
||||
| 功能 | 与 LibTV 对照的缺口 | 本轮实现与验证 |
|
||||
|---|---|---|
|
||||
| 视频提示词优化 | 原入口只提示后端待接入 | 新增真实文字模型接口;默认沿用管理员配置的官方千问 3.8,可选择已有文字模型,预览编辑后应用;真实优化 HTTP 200 |
|
||||
| 视频提示词翻译 | 原入口只提示后端待接入 | 中英文双向翻译;保留素材引用;真实翻译 HTTP 200,浏览器填回通过 |
|
||||
| 提示词结果编辑 | 应用后旧文字与新结果叠加 | 修复 contentEditable 与 React 更新冲突;浏览器实测应用后只剩新译文 |
|
||||
| 弹窗交互 | 缺少完整模态交互 | 使用现有 Dialog 组件,焦点约束、Escape 关闭、取消请求、失败保留原文、禁止空结果应用 |
|
||||
| 视频时段剪辑 | 只记录范围,不输出成片 | 按源视频实际时长定位,FFmpeg 导出独立 MP4,保留音轨,创建关联结果节点;真实 8 秒片源导出 4 秒新片通过 |
|
||||
| 视频画面裁剪 | 上轮仅处理函数通过 | 本轮完整下载、处理、上传 HTTP API 通过,输出 640×360、8 秒新片 |
|
||||
| 视频音轨拆分 | 上轮仅处理函数通过 | 完整 API 输出独立 AAC 音频和无音轨 MP4;不冒充人声/背景音分离 |
|
||||
| 本地音视频上传 | 原来只存 blob 地址,刷新失效 | 新增真实上传接口和媒体探测,保存对象存储地址;MP4/M4A 上传通过,伪装 MP4 被拒绝;最多 125 MiB、10 分钟 |
|
||||
| 上传状态恢复 | 中断后可能一直等待 | 显示上传中/失败,恢复旧 blob 或未完成上传时明确要求重新上传 |
|
||||
| 脚本/画面分析长时间等待 | 旧 SDK 参数被类型断言掩盖,缺少调用时限 | 改用 maxOutputTokens,脚本预算 12000、视频分析 2200、其他分析 1600;150 秒推理时限、175 秒页面请求时限、关闭 SDK 自动重试;空白/截断/非 JSON 结果不再冒充结构化成功 |
|
||||
| 项目加载失败 | 失败后还可能继续编辑未就绪画布 | 加载超时和失败显示重试,加载成功前阻止编辑,避免保存覆盖 |
|
||||
|
||||
## 真实调用结果
|
||||
|
||||
本地开发编译、数据库隧道和接口前置处理均计入 HTTP 耗时,不代表生产模型纯推理时间。
|
||||
|
||||
| 测试 | 结果 | 完整 HTTP 耗时 / 产物 |
|
||||
|---|---|---|
|
||||
| 官方千问 3.8 翻译 | 通过 | 39.325 秒;保留 @主体1,红杯/白桌/8 秒限制完整 |
|
||||
| 官方千问 3.8 优化 | 通过 | 68.437 秒;返回可直接使用的视频提示词 |
|
||||
| 官方千问 3.8 图片理解复测 | 通过 | 85.597 秒,HTTP 200;正确识别红杯、右侧杯柄、咖啡、灰白背景,无虚构人物 |
|
||||
| 官方千问 3.8 脚本分析复测 | 未通过 | 103.003 秒,HTTP 500;连接被对端关闭,未完成脚本生成 |
|
||||
| 选择 Agnes 1.5 进行翻译/优化 | 失败 | HTTP 502;不替换用户原文,不改变默认模型 |
|
||||
| 误选视频模型做提示词处理 | 正确拒绝 | HTTP 400,要求文字模型 |
|
||||
| Veo 3.1 Fast 文生视频 | 通过 | 151.007 秒;8 秒、1280×720、H264 + AAC,1,005,090 字节,已解码检查红杯画面 |
|
||||
| Veo 首帧视频复测 | 未通过 | 164.342 秒;本站到服务商连接失败 `fetch failed`,不据此判定模型不支持 |
|
||||
| Veo 首尾帧复测 | 未通过 | 15.942 秒;输入图片可达性检查失败,HTTP 400,未提交上游生成 |
|
||||
| Veo 多参考复测 | 未通过 | 83.545 秒;上游拒绝 HTTP 503,请求 ID `3b068421-5c82-423b-a05b-6a557ab5c689` |
|
||||
| 时段剪辑 | 通过 | 21.837 秒;4 秒、1280×720、H264 + AAC,748,275 字节 |
|
||||
| 画面裁剪 | 通过 | 17.115 秒;8 秒、640×360、H264 + AAC,897,501 字节 |
|
||||
| 音视频拆分 | 通过 | 27.781 秒;静音 MP4 1,526,056 字节,AAC 197,583 字节 |
|
||||
| 视频上传 | 通过 | 33.266 秒,返回真实对象存储地址及 8 秒时长 |
|
||||
| 音频上传 | 通过 | 22.951 秒,返回 M4A 地址及 8 秒时长 |
|
||||
| 无效 MP4 上传 | 正确拒绝 | HTTP 400,文件内容校验失败 |
|
||||
|
||||
文生视频请求走自动匹配,响应确认实际使用 `veo-3.1-fast`。此次成功更新了上一轮该模式失败的状态,但不证明其他模式或每次调用均成功。生成接口本次返回服务商地址,其长期留存尚未验收;与本轮修复的用户上传文件持久化是两个环节。
|
||||
|
||||
本机代理对媒体域名使用保留网段 DNS。完整媒体 API 测试仅在开发进程对两个已知媒体域名使用经服务器解析的公网地址;生产安全下载器的 IP 校验和 SSRF 防护没有放宽。
|
||||
|
||||
## 页面回归与边界
|
||||
|
||||
- 官方千问翻译弹窗成功显示结果,应用后只保留新译文;保存重试成功后刷新,重新选择节点仍为同一译文。证据 `prompt-after-refresh.txt/png`。
|
||||
- 本地冷编译和接口前置检查曾使保存超时,页面显示失败并允许重试;点击重试后已保存。未把本地耗时当作生产性能结论。
|
||||
- 浏览器上传自动化被文件选择器事件超时阻塞,无法完整验收“从页面选文件到刷新后播放”;真实上传 HTTP 接口已单独通过。不能将接口通过写成整个页面流程通过。
|
||||
- 58 项定向回归通过:6 项新工具契约、12 项模型/参数/媒体安全、4 项保存队列、36 项 VIP98K 相关回归。
|
||||
- 源码 TypeScript 检查通过;未宣称完成生产构建,临时 Next 生成目录不纳入源码检查。
|
||||
|
||||
## 仍未完成
|
||||
|
||||
音频驱动视频没有已确认可用模型;视频超清、去字幕、专业人声分离、AutoLink、智能剪辑及逐帧功能未完成全量对齐。布局与参考站相近,但本轮以真实功能闭环为主,不能称为全站像素级复刻。
|
||||
|
||||
服务器已确认存在 `/usr/bin/ffmpeg` 与 `/usr/bin/ffprobe`。本轮代码未部署生产。原始证据保留在本地 `.qa/video-completion/`,包含 API 响应、ffprobe 信息、实际产物和回归日志;带临时媒体签名的原始响应不提交 Git。
|
||||
|
||||
收尾:两个专用测试账号已停用并撤销活跃会话;临时环境凭据已删除;本地开发服务、数据库隧道和本轮浏览器标签已关闭。开发工具自动修改的 next-env.d.ts / tsconfig.json 已还原,主工作区未修改。
|
||||
823
docs/video-canvas-control-scan-2026-09-17.json
Normal file
823
docs/video-canvas-control-scan-2026-09-17.json
Normal file
@ -0,0 +1,823 @@
|
||||
{
|
||||
"file": "components/canvas/LiblibVideoCanvasReplica.tsx",
|
||||
"root": "LiblibVideoCanvasReplica",
|
||||
"method": "Conservative identifier reachability; candidates need manual review, not runtime coverage.",
|
||||
"reachableDeclarations": [
|
||||
"LiblibVideoCanvasReplica",
|
||||
"createConfiguredToolCatalog",
|
||||
"functionRequiresMedia",
|
||||
"getConfiguredToolType",
|
||||
"getCanvasModelRef",
|
||||
"resolveSiteName",
|
||||
"DEFAULT_SITE_NAME",
|
||||
"DEFAULT_VIEWPORT",
|
||||
"ASSET_MANAGER_DEFAULT_WIDTH",
|
||||
"VIDEO_ROLE_AGREEMENT_STORAGE_KEY",
|
||||
"isVideoMediaNode",
|
||||
"getDraftString",
|
||||
"getNodeMediaUrl",
|
||||
"normalizeCanvasTitle",
|
||||
"restoreNodes",
|
||||
"normalizeRestoredNode",
|
||||
"isCharacterThreeViewNode",
|
||||
"getNodeFunctionKey",
|
||||
"getNodeParams",
|
||||
"CHARACTER_THREE_VIEW_FUNCTION_KEY",
|
||||
"isCharacterThreeViewOption",
|
||||
"getOfficialImagePresetFunctionKey",
|
||||
"STORY_BEAT_GRID_FUNCTION_KEY",
|
||||
"CONTINUOUS_STORYBOARD_GRID_FUNCTION_KEY",
|
||||
"CINEMATIC_LIGHTING_CORRECTION_FUNCTION_KEY",
|
||||
"FRAME_PREDICTION_PLUS_3S_FUNCTION_KEY",
|
||||
"FRAME_PREDICTION_MINUS_5S_FUNCTION_KEY",
|
||||
"MULTI_VIEW_NINE_GRID_FUNCTION_KEY",
|
||||
"getPresetMode",
|
||||
"normalizeTextResourceNode",
|
||||
"extractTrailingNumber",
|
||||
"OFFICIAL_TEXT_RESOURCE_WIDTH",
|
||||
"OFFICIAL_TEXT_RESOURCE_HEIGHT",
|
||||
"OFFICIAL_NINE_GRID_WIDTH",
|
||||
"OFFICIAL_NINE_GRID_HEIGHT",
|
||||
"getDefaultFunctionPrompt",
|
||||
"resolveOfficialImagePresetFunctionKey",
|
||||
"getPresetGridLayout",
|
||||
"getDraftNumber",
|
||||
"getTargetModuleKind",
|
||||
"inferLegacyTargetModuleKind",
|
||||
"normalizeTargetModuleNode",
|
||||
"createTargetModuleNode",
|
||||
"makeId",
|
||||
"shouldResetTargetModuleTitle",
|
||||
"restoreEdges",
|
||||
"clampCanvasZoom",
|
||||
"CANVAS_MAX_ZOOM",
|
||||
"CANVAS_MIN_ZOOM",
|
||||
"serializeNodes",
|
||||
"serializeEdges",
|
||||
"createMediaResourceNode",
|
||||
"DIRECTOR_CAMERA_SNAPSHOT_NODE_OFFSET_X",
|
||||
"createToolboxTemplateGroupNode",
|
||||
"TOOLBOX_TEMPLATE_GROUP_FRAME",
|
||||
"FIRST_FRAME_IMAGE_URL",
|
||||
"createTextResourceNode",
|
||||
"SCRIPT_VIDEO_REFERENCE_URL",
|
||||
"TOOLBOX_TEMPLATE_VIEWPORT",
|
||||
"createToolNode",
|
||||
"getToolType",
|
||||
"TOOL_TYPE_META",
|
||||
"getToolLabel",
|
||||
"getToolFunctionKey",
|
||||
"getConfiguredFunction",
|
||||
"getToolRoute",
|
||||
"resolveNodeReferenceImages",
|
||||
"getNodeReferenceMediaUrl",
|
||||
"getNodeGridSplitState",
|
||||
"normalizeGridSplitState",
|
||||
"createEqualGridRects",
|
||||
"normalizeGridRect",
|
||||
"getNodeGridSplitSourceUrl",
|
||||
"getNodeSourceMediaUrl",
|
||||
"createGridSplitReferenceAsset",
|
||||
"cropRectToBlobUrlSource",
|
||||
"loadCanvasImage",
|
||||
"requiresMedia",
|
||||
"getToolRules",
|
||||
"composeFunctionPrompt",
|
||||
"getFunctionModel",
|
||||
"normalizeModelRef",
|
||||
"normalizeVideoGenerationMode",
|
||||
"selectVideoReferenceImages",
|
||||
"isWideImageGenerationFunction",
|
||||
"composeMultiViewNineGridPrompt",
|
||||
"composeConfiguredImagePresetPrompt",
|
||||
"isOfficialImagePresetFunctionKey",
|
||||
"isPresetGridFunction",
|
||||
"getImageGenerationBillingFeature",
|
||||
"getFirstGeneratedImageUrl",
|
||||
"getGridCellVisual",
|
||||
"NINE_GRID_CELL_VISUALS",
|
||||
"getToolReferenceModuleTemplates",
|
||||
"getToolReferenceMediaSpec",
|
||||
"AUDIO_SOURCE_URL",
|
||||
"CHARACTER_IMAGE_URL",
|
||||
"VIDEO_FIRST_FRAME_SOURCE_URL",
|
||||
"VIDEO_FIRST_LAST_FIRST_URL",
|
||||
"VIDEO_FIRST_LAST_LAST_URL",
|
||||
"AUDIO_REFERENCE_IMAGE_URL",
|
||||
"HD_IMAGE_SOURCE_URL",
|
||||
"OFFICIAL_NODE_GAP",
|
||||
"createUploadedMediaNode",
|
||||
"MATERIAL_STYLE_SCREEN_ANCHOR",
|
||||
"createMaterialStyleNode",
|
||||
"MATERIAL_STYLE_NODE_SIZE",
|
||||
"STYLE_MARKET_THUMBNAIL_URL",
|
||||
"isVideoEffectMaterialNode",
|
||||
"createMaterialEffectNode",
|
||||
"isVideoRoleMaterialNode",
|
||||
"createMaterialRoleNode",
|
||||
"ROLE_LIBRARY_NODE_LAYOUT",
|
||||
"ROLE_LIBRARY_APPLY_VIEWPORT",
|
||||
"TEXT_TO_VIDEO_PROMPT",
|
||||
"TEXT_TO_VIDEO_GENERATE_PROMPT",
|
||||
"IMAGE_TO_PROMPT_PROMPT",
|
||||
"TEXT_TO_MUSIC_PROMPT",
|
||||
"AUDIO_TO_VIDEO_PROMPT",
|
||||
"VIDEO_FIRST_FRAME_PROMPT",
|
||||
"VIDEO_FIRST_LAST_PROMPT",
|
||||
"STORY_SCRIPT_TEXT",
|
||||
"SCRIPT_CHARACTER_PROMPT",
|
||||
"SCRIPT_CHARACTER_ONE_URL",
|
||||
"SCRIPT_CHARACTER_TWO_URL",
|
||||
"getNodeLocalEditRegions",
|
||||
"LOCAL_EDIT_MAX_REGIONS",
|
||||
"normalizeLocalEditRegion",
|
||||
"LOCAL_EDIT_COLORS",
|
||||
"normalizeGridOption",
|
||||
"clearGridSplitDraftValues",
|
||||
"createNineGridCompositeNode",
|
||||
"withReindexedLocalEditRegions",
|
||||
"LOCAL_EDIT_MAX_POLL_ATTEMPTS",
|
||||
"wait",
|
||||
"ensureRemoteCanvasImageUrl",
|
||||
"getGridSplitCellLabel",
|
||||
"normalizeVideoDurationValue",
|
||||
"getVideoCanvasMagicMarks",
|
||||
"normalizeVideoCanvasMagicMarks",
|
||||
"getVideoFunctionKeyForMode",
|
||||
"normalizeVideoRatioValue",
|
||||
"normalizeVideoResolutionValue",
|
||||
"formatVideoRatioLabel",
|
||||
"formatVideoResolutionLabel",
|
||||
"formatVideoDurationLabel",
|
||||
"STORY_SCRIPT_PROMPT",
|
||||
"getStoryScriptRows",
|
||||
"normalizeStoryScriptRow",
|
||||
"formatStoryShot",
|
||||
"buildStoryVideoPrompt",
|
||||
"normalizeVideoClipRange",
|
||||
"normalizeVideoHdResolutionOption",
|
||||
"createVideoHdUpscaleNode",
|
||||
"VIDEO_CROP_RECT",
|
||||
"createVideoCropResultNode",
|
||||
"getVideoNodePreviewUrl",
|
||||
"getVideoNodeDurationSeconds",
|
||||
"VIDEO_CROP_OUTPUT_LABEL",
|
||||
"createVideoStoryAnalysisNode",
|
||||
"buildVideoAnalysisRows",
|
||||
"formatVideoStoryNumber",
|
||||
"cleanVideoStoryText",
|
||||
"formatVideoStoryShot",
|
||||
"STARTER_CARDS",
|
||||
"createStarterWorkflow",
|
||||
"createPresetScriptNode",
|
||||
"makePresetEdges",
|
||||
"createPresetMediaNode",
|
||||
"CHARACTER_THREE_VIEW_URL",
|
||||
"PRESET_WORKFLOW_CENTER",
|
||||
"createInputNode",
|
||||
"createSimpleNode",
|
||||
"capturedTools",
|
||||
"arrangeNodesForPreview",
|
||||
"clampAssetManagerWidth",
|
||||
"ASSET_MANAGER_MAX_WIDTH",
|
||||
"ASSET_MANAGER_MIN_WIDTH",
|
||||
"isOfficialImageControlNode",
|
||||
"getPresetControlMode",
|
||||
"ReferenceSelectionToolbar",
|
||||
"VideoCanvasMarkSelectionToolbar",
|
||||
"CancelGenerationConfirmDialog",
|
||||
"CANVAS_EXTENT",
|
||||
"CloneEdges",
|
||||
"getCanvasEdgePath",
|
||||
"getCanvasEdgeMidpoint",
|
||||
"EdgeFlowSegments",
|
||||
"sampleEdgePath",
|
||||
"parseSimpleEdgePath",
|
||||
"getPointAtSampledLength",
|
||||
"CloneCanvasNode",
|
||||
"ToolboxTemplateGroupCanvasNode",
|
||||
"MaterialStyleCanvasNode",
|
||||
"preventNativeImageDrag",
|
||||
"CanvasNodeShell",
|
||||
"TargetModuleCanvasNode",
|
||||
"TargetModuleHeroGraphic",
|
||||
"ModuleActionIcon",
|
||||
"PureTextResourceCanvasNode",
|
||||
"VideoStoryCanvasNode",
|
||||
"getVideoStoryRows",
|
||||
"normalizeVideoStoryRow",
|
||||
"VideoAnalysisGlyph",
|
||||
"VideoSubtitleErasePendingCanvasNode",
|
||||
"VideoHdUpscaleCanvasNode",
|
||||
"PresetMediaCanvasNode",
|
||||
"normalizeGridCropTile",
|
||||
"normalizeGridCellVisual",
|
||||
"OfficialAudioCard",
|
||||
"formatAudioTimestamp",
|
||||
"GenerationPendingPreview",
|
||||
"OfficialVideoResultCard",
|
||||
"formatVideoTimestamp",
|
||||
"NineGridCompositePreview",
|
||||
"GridCellImage",
|
||||
"CroppedGridTileImage",
|
||||
"LocalEditOverlay",
|
||||
"getContainedImageRect",
|
||||
"GridSplitSelectionOverlay",
|
||||
"PresetScriptCanvasNode",
|
||||
"GridTilesGlyph",
|
||||
"VideoCanvasMarkSourceOverlay",
|
||||
"PointerBadge",
|
||||
"CanvasCommandPalette",
|
||||
"ADD_NODE_PRIMARY_MODULES",
|
||||
"ADD_NODE_MODULES",
|
||||
"CanvasAddMenuButton",
|
||||
"CanvasAddBadge",
|
||||
"CanvasAddSubmenu",
|
||||
"SCRIPT_SUBMENU_OPTIONS",
|
||||
"ASSET_LIBRARY_SUBMENU_OPTIONS",
|
||||
"NodeContextMenu",
|
||||
"ConnectionAddMenu",
|
||||
"CONNECTION_ADD_MODULES",
|
||||
"PresetWorkflowControls",
|
||||
"GridSplitActionToolbar",
|
||||
"OFFICIAL_IMAGE_TOOLBAR_SCALE",
|
||||
"WorkflowGlyph",
|
||||
"SceneToolIcon",
|
||||
"CharacterPresetToolbar",
|
||||
"SceneTool",
|
||||
"ImageToolbarActionGlyph",
|
||||
"SceneToolbarPopover",
|
||||
"OFFICIAL_DROPDOWN_SCALE",
|
||||
"NineGridPresetGlyph",
|
||||
"ImageToolMenuGlyph",
|
||||
"CustomGridPicker",
|
||||
"PromptComposer",
|
||||
"resolveVideoCanvasBillingDisplay",
|
||||
"getVideoCanvasFallbackBillingDisplay",
|
||||
"getCanvasModelOptionByRef",
|
||||
"PROMPT_COMPOSER_BASE_HEIGHT",
|
||||
"OFFICIAL_COMPOSER_SCALE",
|
||||
"PROMPT_COMPOSER_BASE_WIDTH",
|
||||
"PromptActionButton",
|
||||
"ComposerReferencePopover",
|
||||
"ComposerSlashPopover",
|
||||
"SLASH_IMAGE_ITEMS",
|
||||
"ComposerFocusPopover",
|
||||
"ComposerPopover",
|
||||
"filterCanvasModelsByKind",
|
||||
"ComposerControl",
|
||||
"getCanvasModelDisplayName",
|
||||
"getFunctionModelValue",
|
||||
"PresetFloatingToolbar",
|
||||
"PresetComposerPanel",
|
||||
"PRESET_COMPOSER_BASE_HEIGHT",
|
||||
"PRESET_MEDIA_TOOLBAR_ITEMS",
|
||||
"AudioToVideoComposerPanel",
|
||||
"VideoModelPopover",
|
||||
"getVideoComposerModelLabel",
|
||||
"VideoParameterEditor",
|
||||
"VideoParameterRow",
|
||||
"VideoRatioOptions",
|
||||
"VideoSegmentedOptions",
|
||||
"VIDEO_AUDIO_OPTIONS",
|
||||
"VIDEO_COUNT_OPTIONS",
|
||||
"VideoMotionPresetPopover",
|
||||
"VideoEffectPresetPopover",
|
||||
"VIDEO_EFFECT_PRESETS",
|
||||
"VideoRoleConsentPopover",
|
||||
"formatVideoParameterSummary",
|
||||
"OfficialVideoMediaToolbar",
|
||||
"OfficialVideoToolbarButton",
|
||||
"VideoHdSettingsPopover",
|
||||
"VideoToolbarMenuPopover",
|
||||
"VideoToolbarMenuItem",
|
||||
"VideoCropSelectionOverlay",
|
||||
"getVideoCardOverlayRect",
|
||||
"clampUnit",
|
||||
"VideoInlineActionBar",
|
||||
"LightningGlyph",
|
||||
"getVideoCropSelectionLabel",
|
||||
"VideoSubtitleEraseRegionOverlay",
|
||||
"normalizeSubtitleEraseRegion",
|
||||
"VideoSubtitleEraseRegionActionBar",
|
||||
"VideoRegionBarIconButton",
|
||||
"VideoClipEditorBar",
|
||||
"VIDEO_CLIP_FRAME_COUNT",
|
||||
"formatClipDurationLabel",
|
||||
"TextTargetModuleComposer",
|
||||
"VideoTargetModuleComposer",
|
||||
"inferVideoComposerDefaultTab",
|
||||
"normalizeComposerBoolean",
|
||||
"resolveVideoComposerMode",
|
||||
"getVideoModeIcon",
|
||||
"getVideoComposerModelShortLabel",
|
||||
"VideoModePopover",
|
||||
"VideoMotionLibraryPanel",
|
||||
"VIDEO_MOTION_FAVORITES_STORAGE_KEY",
|
||||
"VIDEO_MOTION_CUSTOM_STORAGE_KEY",
|
||||
"AudioTargetModuleComposer",
|
||||
"StoryScriptWorkflowDialog",
|
||||
"getStoryWorkflowAssetGroups",
|
||||
"downloadStoryScriptRows",
|
||||
"sanitizeDownloadFilename",
|
||||
"TopBar",
|
||||
"StylizedBrandLogo",
|
||||
"formatCreditValue",
|
||||
"LeftToolbar",
|
||||
"TOOLBAR_ITEMS",
|
||||
"getToolbarButtonLabel",
|
||||
"AssetGlyph",
|
||||
"ZoomDock",
|
||||
"MagnetGlyph",
|
||||
"ZoomDockButton",
|
||||
"ArrangeCanvasGlyph",
|
||||
"CanvasOverviewPopover",
|
||||
"EdgeVisibilityGlyph",
|
||||
"CANVAS_ZOOM_STEP",
|
||||
"ArrangeCanvasConfirm",
|
||||
"AddModulePanel",
|
||||
"WorkflowPanel",
|
||||
"TOOLBOX_TEMPLATE_PRESETS",
|
||||
"capturedWorkflows",
|
||||
"InspectorPanel",
|
||||
"getToolProperties",
|
||||
"getInspectorTitle",
|
||||
"getToolModes",
|
||||
"PropertyField",
|
||||
"getPropertyLabel",
|
||||
"getOptions",
|
||||
"getDefaultValue",
|
||||
"getOptionValue",
|
||||
"ToggleRow",
|
||||
"getOptionLabel",
|
||||
"AssetLibraryLauncherPanel",
|
||||
"AssetLibraryLauncherRow",
|
||||
"AssetMarketPanel",
|
||||
"EFFECT_MARKET_CARDS",
|
||||
"STYLE_MARKET_CARDS",
|
||||
"EFFECT_MARKET_SEARCH_CARDS",
|
||||
"STYLE_MARKET_SEARCH_CARDS",
|
||||
"STYLE_MARKET_CATEGORIES",
|
||||
"EFFECT_MARKET_THUMBNAIL_URL",
|
||||
"HistoryAssetsPanel",
|
||||
"HISTORY_ASSET_ZOOM_LEVELS",
|
||||
"HistoryBatchActionButton",
|
||||
"HistorySelectionCheckbox",
|
||||
"HistoryAssetPickerModal",
|
||||
"WatermarkRulesModal",
|
||||
"CanvasAssetManagerPanel",
|
||||
"ASSET_MANAGER_FILTERS",
|
||||
"ShortcutPanel",
|
||||
"TutorialHelpPanel",
|
||||
"VIDEO_CANVAS_TUTORIAL_URL",
|
||||
"RoleLibraryPanel",
|
||||
"ROLE_LIBRARY_FILTER_GROUPS",
|
||||
"VideoRoleLibraryPanel",
|
||||
"VideoRoleAgreementModal",
|
||||
"LibModal",
|
||||
"SupportPanel",
|
||||
"SkillsAccessModal",
|
||||
"SharePopover",
|
||||
"getSiteShowcaseName",
|
||||
"StoreGlyph",
|
||||
"PublishModal",
|
||||
"PUBLISH_MODAL_BG_URL",
|
||||
"UploadBox",
|
||||
"MemberStoreModal",
|
||||
"CreditPackGrid",
|
||||
"EmptyState",
|
||||
"formatCurrencyAmount",
|
||||
"VipPlanGrid",
|
||||
"BillingHistoryGrid",
|
||||
"formatBillingDate",
|
||||
"getBillingStatusClass",
|
||||
"getBillingStatusLabel",
|
||||
"NotificationsPopover",
|
||||
"AccountPopover",
|
||||
"TARGET_AVATAR",
|
||||
"AccountMetricCard",
|
||||
"AccountMenuButton",
|
||||
"LogoutGlyph",
|
||||
"ImageNodePreview",
|
||||
"DirectorStudioDialog",
|
||||
"DirectorTutorialFollowBar",
|
||||
"MultiAngleEditorDialog",
|
||||
"AngleSlider"
|
||||
],
|
||||
"unreachableFunctions": [
|
||||
"createWorkflowNode",
|
||||
"createHistoryDemoAssetNode",
|
||||
"createVideoSubtitleEraseNode",
|
||||
"buildStorySceneText",
|
||||
"getPersistedVideoAnalysisState",
|
||||
"isCanvasImageModel",
|
||||
"isCanvasVideoModel",
|
||||
"buildGridRectsFromLines",
|
||||
"getLineScore",
|
||||
"findGridDividerLines",
|
||||
"detectGridSplitFromImage",
|
||||
"ArrowGlyph",
|
||||
"MusicStarterIcon",
|
||||
"DiamondGlyph",
|
||||
"VideoMediaToolbar",
|
||||
"VideoToolbarActionButton",
|
||||
"VideoToolbarSubmenu",
|
||||
"VideoAnalysisFloatingPanel",
|
||||
"StoryScriptScene",
|
||||
"FirstFrameVideoScene",
|
||||
"AudioToVideoScene",
|
||||
"SceneConnector",
|
||||
"StoryTextPanel",
|
||||
"ScriptGeneratorPanel",
|
||||
"PresetMediaPanel",
|
||||
"VideoResultPanel",
|
||||
"AudioResourcePanel",
|
||||
"CharacterThreeViewScene",
|
||||
"CharacterImagePanel",
|
||||
"VideoAdvancedSwitchRow",
|
||||
"DirectorRatioPreview",
|
||||
"getNextDirectorCameraName",
|
||||
"formatDirectorCameraShotTitle",
|
||||
"getNextDirectorRoleName",
|
||||
"DirectorConsole3DDialog",
|
||||
"DirectorPanoramaHistoryDialog",
|
||||
"DirectorPanoramaAiDialog",
|
||||
"DirectorRoleInspectorPanel",
|
||||
"DirectorAiImportDialog",
|
||||
"DirectorAxisInputs",
|
||||
"AssetLibraryPanel"
|
||||
],
|
||||
"candidates": [
|
||||
{
|
||||
"owner": "LiblibVideoCanvasReplica",
|
||||
"line": 10996,
|
||||
"tag": "PresetWorkflowControls",
|
||||
"handler": "",
|
||||
"snippet": "(\n <PresetWorkflowControls\n anchor={selectedPresetAnchor}\n membershipSummary={membershipSummary}\n mode={selectedPresetMode ?? \"character-three-view\"}\n functionConfig={videoCanvasFunctionConfig}\n modelOptions={canvasModelOptions}\n nodes={nodes}\n onClearGridSplitSelection={() => clearNodeGridSplitSelection(selectedPresetControlNode.id)}\n onGridSplitCreateNodes={() => createGridSplitImageNodes(selectedPresetControlNode.id)}\n onGridSplitUpscale={(scaleFactor) => upscaleSelectedGridSplit(selectedPresetControlNode.id, scaleFactor)}\n onComposerSubmit={(p"
|
||||
},
|
||||
{
|
||||
"owner": "LiblibVideoCanvasReplica",
|
||||
"line": 11038,
|
||||
"tag": "VideoArchiveButton",
|
||||
"handler": "",
|
||||
"snippet": "return <VideoArchiveButton key={nodeId} automaticBusy={meta.storageStatus === \"archiving\"} url={sourceUrl} onSaved={url => setNodes(current => current.map(node =>\n node.id === nodeId && getNodeMediaUrl(node) === sourceUrl ? { ...node, draftValues: {\n ...node.draftValues, mediaUrl: url, url: [url], videoMeta: { ...meta, storageStatus: \"archived\", permanentVideoUrl: url },\n } } : node))} />;"
|
||||
},
|
||||
{
|
||||
"owner": "ZoomDock",
|
||||
"line": 12045,
|
||||
"tag": "button",
|
||||
"handler": "",
|
||||
"snippet": "<button className=\"flex h-7 w-10 items-center justify-center rounded-lg bg-white/10 transition-colors hover:bg-white/14\" type=\"button\">\n <MagnetGlyph />\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "VideoSubtitleEraseRegionActionBar",
|
||||
"line": 13371,
|
||||
"tag": "button",
|
||||
"handler": "",
|
||||
"snippet": "<button\n aria-label=\"框选区域\"\n className=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] bg-white/10 text-white transition\"\n type=\"button\"\n >\n <BoxSelect className=\"h-4 w-4\" />\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "VideoClipEditorBar",
|
||||
"line": 13808,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => toast.info(\"视频音轨编辑后端待接入。\")}",
|
||||
"snippet": "<button\n className=\"flex h-10 w-10 items-center justify-center rounded-[10px] text-white/44 transition hover:bg-white/8 hover:text-white/76\"\n onClick={() => toast.info(\"视频音轨编辑后端待接入。\")}\n type=\"button\"\n >\n <Music2 className=\"h-[18px] w-[18px]\" strokeWidth={2} />\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "TextTargetModuleComposer",
|
||||
"line": 14184,
|
||||
"tag": "ComposerControl",
|
||||
"handler": "onClick={() => toast.info(\"翻译接口待接入。\")}",
|
||||
"snippet": "<div className=\"absolute bottom-5 left-5 right-[84px] flex items-center gap-6 overflow-hidden text-[17px] text-white/78\">\n <ComposerControl\n active={menu === \"model\"}\n label={getCanvasModelDisplayName(selectedModel, modelOptions)}\n icon=\"⌘\"\n dropdown\n onClick={() => setMenu((current) => current === \"model\" ? null : \"model\")}\n />\n <div className=\"min-w-0 flex-1\" />\n <ComposerControl label=\"文\" onClick={() => toast.info(\"翻译接口待接入。\")} />\n <ComposerControl\n label={billingDisplay.label}\n icon=\"⚡\"\n muted\n onC"
|
||||
},
|
||||
{
|
||||
"owner": "TextTargetModuleComposer",
|
||||
"line": 14185,
|
||||
"tag": "ComposerControl",
|
||||
"handler": "onClick={() => toast.info(billingDisplay.message)}",
|
||||
"snippet": "<div className=\"absolute bottom-5 left-5 right-[84px] flex items-center gap-6 overflow-hidden text-[17px] text-white/78\">\n <ComposerControl\n active={menu === \"model\"}\n label={getCanvasModelDisplayName(selectedModel, modelOptions)}\n icon=\"⌘\"\n dropdown\n onClick={() => setMenu((current) => current === \"model\" ? null : \"model\")}\n />\n <div className=\"min-w-0 flex-1\" />\n <ComposerControl label=\"文\" onClick={() => toast.info(\"翻译接口待接入。\")} />\n <ComposerControl\n label={billingDisplay.label}\n icon=\"⚡\"\n muted\n onC"
|
||||
},
|
||||
{
|
||||
"owner": "AudioToVideoComposerPanel",
|
||||
"line": 15442,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => toast.info(\"展开音频生视频编辑器待接入。\")}",
|
||||
"snippet": "<button className=\"absolute right-6 top-5 text-[32px] text-white/38 hover:text-white/70\" onClick={() => toast.info(\"展开音频生视频编辑器待接入。\")} type=\"button\">↗</button>"
|
||||
},
|
||||
{
|
||||
"owner": "AudioToVideoComposerPanel",
|
||||
"line": 15445,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => toast.info(`${tab}模式后端待接入。`)}",
|
||||
"snippet": "<button\n className={cn(\n \"h-[38px] rounded-[8px] border border-white/10 px-4 text-[18px] text-white/48\",\n index === 1 && \"bg-white/8 text-white\"\n )}\n key={tab}\n onClick={() => toast.info(`${tab}模式后端待接入。`)}\n type=\"button\"\n >\n {tab}\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "AudioToVideoComposerPanel",
|
||||
"line": 15466,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => {\n if (key === \"reference\") {\n toast.info(\"音频和图片参考已连接,可继续补充更多素材。\");\n return;\n }\n if (key === \"mark\") {\n appendPromptSnippet(\"添加人物与构图标记\");\n return;\n }\n setAssistMenu((current) => current === key ? null : (key as typeof assistMenu));\n setMenu(null);\n }}",
|
||||
"snippet": "<button\n className={cn(\n \"flex h-[72px] w-[92px] flex-col items-center justify-center gap-1 rounded-[8px] border border-white/10 text-[16px] text-white/50 transition hover:bg-white/[0.07] hover:text-white/80\",\n assistMenu === key && \"border-white/22 bg-white/10 text-white\"\n )}\n key={label}\n onClick={() => {\n if (key === \"reference\") {\n toast.info(\"音频和图片参考已连接,可继续补充更多素材。\");\n return;\n }\n if (key === \"mark\") {\n appendPromptSnippet(\"添加人物与构图标记\");\n return;\n "
|
||||
},
|
||||
{
|
||||
"owner": "AudioToVideoComposerPanel",
|
||||
"line": 15510,
|
||||
"tag": "ComposerControl",
|
||||
"handler": "onClick={() => toast.info(\"翻译接口待接入。\")}",
|
||||
"snippet": "<div className=\"absolute bottom-5 left-5 right-[86px] flex items-center gap-7 overflow-hidden text-[22px] text-white/82\">\n <ComposerControl active={menu === \"model\"} label={getVideoComposerModelLabel(settings, modelOptions)} icon=\"▥\" dropdown onClick={() => setMenu((current) => current === \"model\" ? null : \"model\")} />\n <span className=\"rounded-[5px] bg-[#f6c85f] px-1.5 py-0.5 text-[13px] font-bold text-[#2b2314]\">VIP</span>\n <ComposerControl active={menu === \"settings\"} label={formatVideoParameterSummary(settings)} icon=\"▭\" dropdown onClick={() => setMenu((current) => current === \"settings\" ? null : \"settings\")} />"
|
||||
},
|
||||
{
|
||||
"owner": "AudioToVideoComposerPanel",
|
||||
"line": 15511,
|
||||
"tag": "ComposerControl",
|
||||
"handler": "onClick={() => toast.info(\"高级设置后端待接入。\")}",
|
||||
"snippet": "<div className=\"absolute bottom-5 left-5 right-[86px] flex items-center gap-7 overflow-hidden text-[22px] text-white/82\">\n <ComposerControl active={menu === \"model\"} label={getVideoComposerModelLabel(settings, modelOptions)} icon=\"▥\" dropdown onClick={() => setMenu((current) => current === \"model\" ? null : \"model\")} />\n <span className=\"rounded-[5px] bg-[#f6c85f] px-1.5 py-0.5 text-[13px] font-bold text-[#2b2314]\">VIP</span>\n <ComposerControl active={menu === \"settings\"} label={formatVideoParameterSummary(settings)} icon=\"▭\" dropdown onClick={() => setMenu((current) => current === \"settings\" ? null : \"settings\")} />"
|
||||
},
|
||||
{
|
||||
"owner": "AudioToVideoComposerPanel",
|
||||
"line": 15512,
|
||||
"tag": "ComposerControl",
|
||||
"handler": "onClick={() => toast.info(billingDisplay.message)}",
|
||||
"snippet": "<div className=\"absolute bottom-5 left-5 right-[86px] flex items-center gap-7 overflow-hidden text-[22px] text-white/82\">\n <ComposerControl active={menu === \"model\"} label={getVideoComposerModelLabel(settings, modelOptions)} icon=\"▥\" dropdown onClick={() => setMenu((current) => current === \"model\" ? null : \"model\")} />\n <span className=\"rounded-[5px] bg-[#f6c85f] px-1.5 py-0.5 text-[13px] font-bold text-[#2b2314]\">VIP</span>\n <ComposerControl active={menu === \"settings\"} label={formatVideoParameterSummary(settings)} icon=\"▭\" dropdown onClick={() => setMenu((current) => current === \"settings\" ? null : \"settings\")} />"
|
||||
},
|
||||
{
|
||||
"owner": "AudioToVideoComposerPanel",
|
||||
"line": 15519,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => {\n if (onSubmit) {\n onSubmit(prompt, settings);\n return;\n }\n toast.info(\"音频生视频生成接口待接入。\");\n }}",
|
||||
"snippet": "<button\n className=\"absolute bottom-5 right-5 flex h-14 w-14 items-center justify-center rounded-[14px] bg-white text-[34px] leading-none text-black hover:bg-white/90\"\n onClick={() => {\n if (onSubmit) {\n onSubmit(prompt, settings);\n return;\n }\n toast.info(\"音频生视频生成接口待接入。\");\n }}\n type=\"button\"\n >\n ↑\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "PresetFloatingToolbar",
|
||||
"line": 15941,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => {\n if (onClick) {\n onClick();\n return;\n }\n if (message) toast.info(message);\n }}",
|
||||
"snippet": "<button\n className={cn(\n \"flex items-center justify-center rounded-[8px] hover:bg-white/8 hover:text-white\",\n compact ? \"h-8 text-[14px] font-medium\" : \"h-10 gap-2 rounded-[10px] px-3 text-[15px]\"\n )}\n key={label}\n onClick={() => {\n if (onClick) {\n onClick();\n return;\n }\n if (message) toast.info(message);\n }}\n style={compact ? { width: compactWidth } : undefined}\n type=\"button\"\n >\n {!compact ? (\n <span className=\"flex h-5 w-5 items-center ju"
|
||||
},
|
||||
{
|
||||
"owner": "PresetComposerPanel",
|
||||
"line": 16227,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => toast.info(\"展开提示词编辑器待接入。\")}",
|
||||
"snippet": "<button className=\"absolute right-5 top-5 text-[26px] text-white/45\" onClick={() => toast.info(\"展开提示词编辑器待接入。\")} type=\"button\">↗</button>"
|
||||
},
|
||||
{
|
||||
"owner": "PresetComposerPanel",
|
||||
"line": 16246,
|
||||
"tag": "ComposerControl",
|
||||
"handler": "onClick={() => toast.info(\"镜头配置接口待接入。\")}",
|
||||
"snippet": "<div className=\"absolute bottom-5 left-5 right-[84px] flex items-center gap-6 overflow-hidden text-[17px] text-white/78\">\n\t <ComposerControl active={menu === \"model\"} label={getCanvasModelDisplayName(settings.model, modelOptions)} icon=\"◉\" dropdown onClick={() => setMenu((current) => current === \"model\" ? null : \"model\")} />\n\t <ComposerControl active={menu === \"ratio\"} label={settings.ratio} icon=\"▭\" dropdown onClick={() => setMenu((current) => current === \"ratio\" ? null : \"ratio\")} />\n\t <ComposerControl active={menu === \"resolution\"} label={settings.resolution} dropdown onClick={() => setMenu((current) => current === \"re"
|
||||
},
|
||||
{
|
||||
"owner": "PresetComposerPanel",
|
||||
"line": 16247,
|
||||
"tag": "ComposerControl",
|
||||
"handler": "onClick={() => toast.info(\"当前仅支持单次生成 1 个结果。\")}",
|
||||
"snippet": "<div className=\"absolute bottom-5 left-5 right-[84px] flex items-center gap-6 overflow-hidden text-[17px] text-white/78\">\n\t <ComposerControl active={menu === \"model\"} label={getCanvasModelDisplayName(settings.model, modelOptions)} icon=\"◉\" dropdown onClick={() => setMenu((current) => current === \"model\" ? null : \"model\")} />\n\t <ComposerControl active={menu === \"ratio\"} label={settings.ratio} icon=\"▭\" dropdown onClick={() => setMenu((current) => current === \"ratio\" ? null : \"ratio\")} />\n\t <ComposerControl active={menu === \"resolution\"} label={settings.resolution} dropdown onClick={() => setMenu((current) => current === \"re"
|
||||
},
|
||||
{
|
||||
"owner": "PresetComposerPanel",
|
||||
"line": 16248,
|
||||
"tag": "ComposerControl",
|
||||
"handler": "onClick={() => toast.info(billingDisplay.message)}",
|
||||
"snippet": "<div className=\"absolute bottom-5 left-5 right-[84px] flex items-center gap-6 overflow-hidden text-[17px] text-white/78\">\n\t <ComposerControl active={menu === \"model\"} label={getCanvasModelDisplayName(settings.model, modelOptions)} icon=\"◉\" dropdown onClick={() => setMenu((current) => current === \"model\" ? null : \"model\")} />\n\t <ComposerControl active={menu === \"ratio\"} label={settings.ratio} icon=\"▭\" dropdown onClick={() => setMenu((current) => current === \"ratio\" ? null : \"ratio\")} />\n\t <ComposerControl active={menu === \"resolution\"} label={settings.resolution} dropdown onClick={() => setMenu((current) => current === \"re"
|
||||
},
|
||||
{
|
||||
"owner": "PresetComposerPanel",
|
||||
"line": 16255,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => {\n if (onSubmit) {\n onSubmit(draftPrompt, settings);\n return;\n }\n toast.info(submitMessage);\n }}",
|
||||
"snippet": "<button\n\t className=\"absolute bottom-5 right-5 flex h-11 w-11 items-center justify-center rounded-[12px] bg-white/76 text-[24px] text-black hover:bg-white\"\n\t onClick={() => {\n if (onSubmit) {\n onSubmit(draftPrompt, settings);\n return;\n }\n toast.info(submitMessage);\n }}\n\t type=\"button\"\n\t >\n\t ↑\n\t </button>"
|
||||
},
|
||||
{
|
||||
"owner": "PromptComposer",
|
||||
"line": 16874,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => toast.info(\"展开提示词编辑器待接入。\")}",
|
||||
"snippet": "<button className=\"absolute right-5 top-5 text-[26px] text-white/45\" onClick={() => toast.info(\"展开提示词编辑器待接入。\")} type=\"button\">↗</button>"
|
||||
},
|
||||
{
|
||||
"owner": "PromptComposer",
|
||||
"line": 16958,
|
||||
"tag": "ComposerControl",
|
||||
"handler": "onClick={() => toast.info(billingDisplay.message)}",
|
||||
"snippet": "<div className=\"absolute bottom-5 left-5 right-[84px] flex items-center gap-6 overflow-hidden text-[18px] text-white/78\">\n <ComposerControl active={menu === \"model\"} label={getCanvasModelDisplayName(settings.model, modelOptions)} icon=\"◉\" dropdown onClick={() => setMenu((current) => current === \"model\" ? null : \"model\")} />\n <ComposerControl active={menu === \"ratio\"} label={settings.ratio} icon=\"▭\" dropdown onClick={() => setMenu((current) => current === \"ratio\" ? null : \"ratio\")} />\n <ComposerControl active={menu === \"resolution\"} label={settings.resolution} dropdown onClick={() => setMenu((current) => current === "
|
||||
},
|
||||
{
|
||||
"owner": "PromptComposer",
|
||||
"line": 16965,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() =>\n onSubmit\n ? onSubmit({\n prompt,\n references,\n settings,\n slashScene,\n })\n : toast.info(\"角色三视图生成接口待接入。\")\n }",
|
||||
"snippet": "<button\n className=\"absolute bottom-5 right-5 flex h-11 w-11 items-center justify-center rounded-[12px] bg-white/76 text-[24px] text-black hover:bg-white\"\n onClick={() =>\n onSubmit\n ? onSubmit({\n prompt,\n references,\n settings,\n slashScene,\n })\n : toast.info(\"角色三视图生成接口待接入。\")\n }\n type=\"button\"\n >\n ↑\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "VideoMotionLibraryPanel",
|
||||
"line": 17618,
|
||||
"tag": "button",
|
||||
"handler": "",
|
||||
"snippet": "<button\n aria-label=\"搜索\"\n className=\"flex size-6 shrink-0 items-center justify-center text-white/42 hover:text-white/75\"\n type=\"submit\"\n >\n <Search className=\"size-4\" />\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "CloneCanvasNode",
|
||||
"line": 19132,
|
||||
"tag": "button",
|
||||
"handler": "",
|
||||
"snippet": "<button className=\"relative mt-3 h-8 rounded-[8px] border border-[#dfe3ff] bg-white px-4 text-[11px] text-[#5161ff]\" type=\"button\">\n 选择文件\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "PresetMediaCanvasNode",
|
||||
"line": 21735,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => toast.info(\"视频全屏预览待接入。\")}",
|
||||
"snippet": "<button\n className=\"absolute right-3 top-3 rounded-[8px] bg-white/8 px-3 py-2 text-[12px] text-white/72 hover:bg-white/12\"\n onClick={() => toast.info(\"视频全屏预览待接入。\")}\n type=\"button\"\n >\n ↗\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "PresetMediaCanvasNode",
|
||||
"line": 21810,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => toast.info(`${node.title}素材替换接口待接入。`)}",
|
||||
"snippet": "<button\n className=\"absolute right-3 top-3 flex h-10 w-10 items-center justify-center rounded-[8px] bg-black/56 text-white/90\"\n onClick={() => toast.info(`${node.title}素材替换接口待接入。`)}\n type=\"button\"\n >\n ↥\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "PresetScriptCanvasNode",
|
||||
"line": 22476,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => toast.info(\"编辑剧本接口待接入。\")}",
|
||||
"snippet": "<button className=\"rounded-[8px] px-2 py-1 text-[#4f5cff] hover:bg-black/5\" onClick={() => toast.info(\"编辑剧本接口待接入。\")} type=\"button\">\n 编辑\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "AssetMarketPanel",
|
||||
"line": 22917,
|
||||
"tag": "button",
|
||||
"handler": "",
|
||||
"snippet": "<button className=\"flex h-7 w-[54px] items-center justify-center rounded-[8px] bg-white/10 text-[13px] text-white\" type=\"button\">\n 全部\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "HistoryAssetPickerModal",
|
||||
"line": 23177,
|
||||
"tag": "button",
|
||||
"handler": "",
|
||||
"snippet": "<button className=\"flex h-8 w-8 items-center justify-center rounded-lg text-white/36 transition hover:bg-white/8 hover:text-white\" type=\"button\">\n <ChevronRight className=\"h-4 w-4 rotate-180\" />\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "HistoryAssetPickerModal",
|
||||
"line": 23181,
|
||||
"tag": "button",
|
||||
"handler": "",
|
||||
"snippet": "<button className=\"flex h-8 w-8 items-center justify-center rounded-lg text-white/36 transition hover:bg-white/8 hover:text-white\" type=\"button\">\n <ChevronRight className=\"h-4 w-4\" />\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "WorkflowPanel",
|
||||
"line": 24486,
|
||||
"tag": "button",
|
||||
"handler": "",
|
||||
"snippet": "<button\n className=\"cursor-pointer rounded-md border-none bg-transparent py-1 pr-2 pl-0 text-sm font-medium text-white transition-colors\"\n type=\"button\"\n >\n 我的工具箱\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "WorkflowPanel",
|
||||
"line": 24492,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => toast.info(\"工具箱模板会将参考图、提示词和成品节点应用到画布。\")}",
|
||||
"snippet": "<button\n aria-label=\"工具箱模板说明\"\n className=\"-ml-1.5 flex size-4 cursor-help items-center justify-center rounded-full border-none bg-transparent p-0 text-white/70 opacity-50 transition-opacity hover:opacity-80\"\n onClick={() => toast.info(\"工具箱模板会将参考图、提示词和成品节点应用到画布。\")}\n type=\"button\"\n >\n <HelpCircle className=\"h-3.5 w-3.5\" />\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "WorkflowPanel",
|
||||
"line": 24501,
|
||||
"tag": "button",
|
||||
"handler": "",
|
||||
"snippet": "<button\n className=\"cursor-pointer rounded-md border-none bg-transparent px-2 py-1 text-sm font-medium text-white/55 transition-colors hover:text-white/80\"\n type=\"button\"\n >\n 周星驰经典名场面\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "InspectorPanel",
|
||||
"line": 24620,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => (onAddReference ? onAddReference(node) : toast.info(\"上传/素材选择接口待接入。\"))}",
|
||||
"snippet": "<button\n className=\"flex h-[74px] w-full items-center justify-center rounded-[8px] border border-white/10 bg-white/5 text-[14px] text-white/52 hover:bg-white/8 hover:text-white\"\n onClick={() => (onAddReference ? onAddReference(node) : toast.info(\"上传/素材选择接口待接入。\"))}\n type=\"button\"\n >\n <Plus className=\"mr-2 h-4 w-4\" />\n {referenceCount > 0 ? `继续添加素材 · 已连入 ${referenceCount}` : \"添加素材\"}\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "InspectorPanel",
|
||||
"line": 24655,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => (onGenerate ? onGenerate(node) : toast.info(\"生成接口待接入。\"))}",
|
||||
"snippet": "<button\n className=\"h-[64px] w-full rounded-[8px] bg-white font-bold text-[16px] text-black hover:bg-white/90 disabled:cursor-not-allowed disabled:bg-white/35\"\n disabled={isGenerating || (selectableRoute && (!compatibleModels.length || unavailableSelection))}\n onClick={() => (onGenerate ? onGenerate(node) : toast.info(\"生成接口待接入。\"))}\n type=\"button\"\n >\n {isGenerating ? \"生成中...\" : \"生成\"}\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "PropertyField",
|
||||
"line": 24751,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => toast.info(`${label}选择器待接入。`)}",
|
||||
"snippet": "<button\n className=\"h-10 w-full rounded-[8px] border border-white/10 bg-black/18 px-3 text-left text-[13px] text-white/46 hover:border-white/20 hover:text-white\"\n onClick={() => toast.info(`${label}选择器待接入。`)}\n type=\"button\"\n >\n 选择{label}\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "SkillsAccessModal",
|
||||
"line": 24963,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() =>\n toast.info(\"安装包位于 integrations/openclaw,可通过 STDIO MCP 接入。\")\n }",
|
||||
"snippet": "<button\n className=\"ml-1 text-[#2f8bff] underline-offset-2 hover:underline\"\n onClick={() =>\n toast.info(\"安装包位于 integrations/openclaw,可通过 STDIO MCP 接入。\")\n }\n type=\"button\"\n >\n 接口文档\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "SkillsAccessModal",
|
||||
"line": 24998,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => {\n void navigator.clipboard?.writeText(createdToken);\n toast.success(\"Access Key 已复制\");\n }}",
|
||||
"snippet": "<button\n className=\"flex h-8 shrink-0 items-center gap-1 rounded-[6px] bg-white px-2 text-[11px] font-medium text-black\"\n onClick={() => {\n void navigator.clipboard?.writeText(createdToken);\n toast.success(\"Access Key 已复制\");\n }}\n type=\"button\"\n >\n <Copy className=\"h-3.5 w-3.5\" />\n 复制\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "VipPlanGrid",
|
||||
"line": 25468,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => {\n if (isCurrent) return;\n toast.info(\"会员订阅支付尚未接入,当前请优先使用积分充值。\");\n }}",
|
||||
"snippet": "<button\n className={cn(\n \"mt-4 h-9 w-full rounded-[8px] text-[13px] font-semibold transition-colors\",\n isCurrent\n ? \"cursor-default bg-white/12 text-white/38\"\n : \"bg-white text-black hover:bg-white/90\"\n )}\n disabled={isCurrent}\n onClick={() => {\n if (isCurrent) return;\n toast.info(\"会员订阅支付尚未接入,当前请优先使用积分充值。\");\n }}\n type=\"button\"\n >\n {isCurrent ? \"当前套餐\" : isFree ? \"默认套餐\" : \"即将接入\"}\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "NotificationsPopover",
|
||||
"line": 25654,
|
||||
"tag": "button",
|
||||
"handler": "",
|
||||
"snippet": "<button\n className={cn(\n \"h-7 rounded-[8px] px-3 text-[12px] transition-colors\",\n index === 0 ? \"bg-white text-black\" : \"bg-white/6 text-white/56 hover:bg-white/10 hover:text-white\"\n )}\n key={item}\n type=\"button\"\n >\n {item}\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "NotificationsPopover",
|
||||
"line": 25671,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => toast.info(`${title}接口待接入。`)}",
|
||||
"snippet": "<button\n className=\"mb-2 w-full rounded-[10px] border border-white/8 bg-white/5 p-3 text-left hover:bg-white/8\"\n key={title}\n onClick={() => toast.info(`${title}接口待接入。`)}\n type=\"button\"\n >\n <span className=\"block text-[13px] font-semibold text-white/82\">{title}</span>\n <span className=\"mt-1 block text-[12px] leading-5 text-white/42\">{description}</span>\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "AccountPopover",
|
||||
"line": 25727,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => {\n void navigator.clipboard?.writeText(displayEmail);\n toast.success(\"邮箱复制成功\");\n }}",
|
||||
"snippet": "<button\n className=\"rounded-[6px] px-1 text-white/42 hover:bg-white/8 hover:text-white\"\n onClick={() => {\n void navigator.clipboard?.writeText(displayEmail);\n toast.success(\"邮箱复制成功\");\n }}\n type=\"button\"\n >\n <Copy className=\"h-3 w-3\" />\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "AccountPopover",
|
||||
"line": 25779,
|
||||
"tag": "AccountMenuButton",
|
||||
"handler": "onClick={() => toast.info(\"个人中心入口待接入。\")}",
|
||||
"snippet": "<div className=\"mt-4 space-y-1\">\n <AccountMenuButton\n icon={<StoreGlyph />}\n label=\"积分与会员中心\"\n onClick={() => {\n onClose();\n onOpenMember();\n }}\n />\n <AccountMenuButton icon={<UserRound className=\"h-[18px] w-[18px]\" />} label=\"个人中心\" onClick={() => toast.info(\"个人中心入口待接入。\")} />\n <AccountMenuButton icon={<Plus className=\"h-[18px] w-[18px]\" />} label=\"创建团队\" onClick={() => toast.info(\"创建团队接口待接入。\")} />\n <AccountMenuButton icon={<FileText className=\"h-[18px] w-[18px]\" />} label=\"订阅与开发票\" onClick={() => toast.info(\"订阅"
|
||||
},
|
||||
{
|
||||
"owner": "AccountPopover",
|
||||
"line": 25780,
|
||||
"tag": "AccountMenuButton",
|
||||
"handler": "onClick={() => toast.info(\"创建团队接口待接入。\")}",
|
||||
"snippet": "<div className=\"mt-4 space-y-1\">\n <AccountMenuButton\n icon={<StoreGlyph />}\n label=\"积分与会员中心\"\n onClick={() => {\n onClose();\n onOpenMember();\n }}\n />\n <AccountMenuButton icon={<UserRound className=\"h-[18px] w-[18px]\" />} label=\"个人中心\" onClick={() => toast.info(\"个人中心入口待接入。\")} />\n <AccountMenuButton icon={<Plus className=\"h-[18px] w-[18px]\" />} label=\"创建团队\" onClick={() => toast.info(\"创建团队接口待接入。\")} />\n <AccountMenuButton icon={<FileText className=\"h-[18px] w-[18px]\" />} label=\"订阅与开发票\" onClick={() => toast.info(\"订阅"
|
||||
},
|
||||
{
|
||||
"owner": "AccountPopover",
|
||||
"line": 25781,
|
||||
"tag": "AccountMenuButton",
|
||||
"handler": "onClick={() => toast.info(\"订阅与开发票入口待接入。\")}",
|
||||
"snippet": "<div className=\"mt-4 space-y-1\">\n <AccountMenuButton\n icon={<StoreGlyph />}\n label=\"积分与会员中心\"\n onClick={() => {\n onClose();\n onOpenMember();\n }}\n />\n <AccountMenuButton icon={<UserRound className=\"h-[18px] w-[18px]\" />} label=\"个人中心\" onClick={() => toast.info(\"个人中心入口待接入。\")} />\n <AccountMenuButton icon={<Plus className=\"h-[18px] w-[18px]\" />} label=\"创建团队\" onClick={() => toast.info(\"创建团队接口待接入。\")} />\n <AccountMenuButton icon={<FileText className=\"h-[18px] w-[18px]\" />} label=\"订阅与开发票\" onClick={() => toast.info(\"订阅"
|
||||
},
|
||||
{
|
||||
"owner": "AccountPopover",
|
||||
"line": 25782,
|
||||
"tag": "AccountMenuButton",
|
||||
"handler": "onClick={() => toast.info(\"账号设置入口待接入。\")}",
|
||||
"snippet": "<div className=\"mt-4 space-y-1\">\n <AccountMenuButton\n icon={<StoreGlyph />}\n label=\"积分与会员中心\"\n onClick={() => {\n onClose();\n onOpenMember();\n }}\n />\n <AccountMenuButton icon={<UserRound className=\"h-[18px] w-[18px]\" />} label=\"个人中心\" onClick={() => toast.info(\"个人中心入口待接入。\")} />\n <AccountMenuButton icon={<Plus className=\"h-[18px] w-[18px]\" />} label=\"创建团队\" onClick={() => toast.info(\"创建团队接口待接入。\")} />\n <AccountMenuButton icon={<FileText className=\"h-[18px] w-[18px]\" />} label=\"订阅与开发票\" onClick={() => toast.info(\"订阅"
|
||||
},
|
||||
{
|
||||
"owner": "AccountPopover",
|
||||
"line": 25783,
|
||||
"tag": "AccountMenuButton",
|
||||
"handler": "onClick={() => toast.info(\"退出登录接口待接入。\")}",
|
||||
"snippet": "<div className=\"mt-4 space-y-1\">\n <AccountMenuButton\n icon={<StoreGlyph />}\n label=\"积分与会员中心\"\n onClick={() => {\n onClose();\n onOpenMember();\n }}\n />\n <AccountMenuButton icon={<UserRound className=\"h-[18px] w-[18px]\" />} label=\"个人中心\" onClick={() => toast.info(\"个人中心入口待接入。\")} />\n <AccountMenuButton icon={<Plus className=\"h-[18px] w-[18px]\" />} label=\"创建团队\" onClick={() => toast.info(\"创建团队接口待接入。\")} />\n <AccountMenuButton icon={<FileText className=\"h-[18px] w-[18px]\" />} label=\"订阅与开发票\" onClick={() => toast.info(\"订阅"
|
||||
},
|
||||
{
|
||||
"owner": "AccountMetricCard",
|
||||
"line": 25805,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => toast.info(`${label}接口待接入。`)}",
|
||||
"snippet": "<button\n className={cn(\n \"rounded-[12px] border border-white/8 bg-white/[0.04] px-4 py-3 text-left hover:bg-white/[0.07]\",\n className\n )}\n onClick={() => toast.info(`${label}接口待接入。`)}\n type=\"button\"\n >\n <span className={cn(\"block text-[12px]\", accent ? \"text-[#09caf5]\" : \"text-white/45\")}>{label}</span>\n <span className=\"mt-1 flex items-baseline gap-1\">\n <span className={cn(\"font-['PlusJakartaSans'] text-[18px] font-bold\", accent ? \"text-[#09caf5]\" : \"text-white/88\")}>{value}</span>\n <span className=\"text-[12px] text-white/44\">{suffix}</span>\n </span>\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "SupportPanel",
|
||||
"line": 25859,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => toast.info(`${title}入口待接入。`)}",
|
||||
"snippet": "<button\n className=\"w-full rounded-[8px] border border-white/8 bg-white/5 p-3 text-left hover:bg-white/8\"\n key={title}\n onClick={() => toast.info(`${title}入口待接入。`)}\n type=\"button\"\n >\n <div className=\"text-[13px] font-semibold text-white/82\">{title}</div>\n <div className=\"mt-1 text-[12px] text-white/42\">{description}</div>\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "PublishModal",
|
||||
"line": 26112,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => toast.info(\"发布接口待接入。\")}",
|
||||
"snippet": "<button\n className=\"cursor-pointer rounded-lg bg-[#478EFF] px-3 py-2 text-[13px] text-white transition-colors hover:bg-[#2563EB]\"\n onClick={() => toast.info(\"发布接口待接入。\")}\n type=\"button\"\n >\n 发布并投稿\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "PublishModal",
|
||||
"line": 26125,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => toast.info(\"服务协议入口待接入。\")}",
|
||||
"snippet": "<button\n className=\"text-[#478EFF] hover:underline\"\n onClick={() => toast.info(\"服务协议入口待接入。\")}\n type=\"button\"\n >\n 《{siteName}创作许可服务协议》\n </button>"
|
||||
},
|
||||
{
|
||||
"owner": "UploadBox",
|
||||
"line": 26142,
|
||||
"tag": "button",
|
||||
"handler": "onClick={() => toast.info(`${label}接口待接入。`)}",
|
||||
"snippet": "<button\n className=\"flex h-[120px] flex-1 flex-col items-center justify-center rounded-[8px] border border-white/8 bg-white/12 text-white/72 transition-colors hover:bg-white/16 hover:text-white\"\n onClick={() => toast.info(`${label}接口待接入。`)}\n type=\"button\"\n >\n <Plus className=\"h-5 w-5\" />\n <span className=\"mt-2 text-[13px]\">{label}</span>\n {hint ? <span className=\"mt-2 text-[12px] text-white/42\">{hint}</span> : null}\n </button>"
|
||||
}
|
||||
]
|
||||
}
|
||||
1938
docs/video-canvas-deep-parity-2026-09-17.json
Normal file
1938
docs/video-canvas-deep-parity-2026-09-17.json
Normal file
File diff suppressed because it is too large
Load Diff
434
docs/video-canvas-deep-parity-2026-09-17.md
Normal file
434
docs/video-canvas-deep-parity-2026-09-17.md
Normal file
@ -0,0 +1,434 @@
|
||||
# 视频画布逐层功能对照记录 — 2026-09-17
|
||||
|
||||
> 后续修复见 [逐项修复和验证记录](video-canvas-repairs-2026-09-17.md)。下方为修复前基线,尤其 05-15 的截帧判断已在修复记录中纠正;不能将基线结论作为最新状态。
|
||||
|
||||
本轮按页面 → 模块 → 菜单 → 子菜单/参数 → 实际处理代码逐层核对,共记录 **231 个检查点、10 个分组**。结论:当前本地版本的基础生成、视频处理和相机运镜已有真实能力,但离参考站完整功能与像素级对齐仍有明显差距;旧文档中“已完成/已对齐”的泛化表述不能作为验收结论。
|
||||
|
||||
## 范围和证据口径
|
||||
|
||||
- 参考站:[LibTV 画布](https://www.liblib.tv/canvas),2026-09-17 当次登录可见界面。模型名、价格、入口随账号与站点更新变化,本记录是现场快照。
|
||||
- 我方:分支 `codex/video-canvas-parity-0916`,功能基线 `d5593ac3`。比较的是本地实现;本轮**未部署到 48 服务器、未重启服务**。不能据此推断线上已具备最近两轮修复。
|
||||
- 参考站实际展开了导演台、姿势、机位、群众、全景、识图导入、时间轴、路径、新脚本、图片编辑、视频、音频/音色、故事板、工具箱、素材/角色/历史/资产库、Agent、分享及快捷键。上传了一段我方合成的 3 秒无音轨 MP4,以查看视频后期面板;**未提交本轮参考站付费生成、未发布分享、未签协议**。
|
||||
- 我方本轮阅读活动入口与处理函数,并在浏览器重新打开真实 `DirectorStudioDialog`。导演台使用本地组件验收页,存储适配器是 localStorage;这不是本轮全站端到端验收。组件页使用已有构建 CSS,字体请求有回退,因此截图不能用于字体级像素验收。
|
||||
- “实测”引用本轮显示或前两轮真实测试证据,详见 [真实串联记录](video-canvas-director-chain-2026-09-16.md) 和 [运镜测试记录](video-canvas-director-motion-2026-09-17.md)。其他“接通”表示代码存在真实执行链,**不表示参考站效果一致或本轮真实调用成功**。
|
||||
- 参考原始 UI 文本与截图保存在 `.qa/video-audit-0917/`,不提交含项目上下文的原始采集。下方每组列出采集文件名线索和代码定位;只有不含个人素材的导演台对比图随文档提交。
|
||||
|
||||
| 分类 | 检查点数 | 含义 |
|
||||
|---|---:|---|
|
||||
| 实测 | 23 | 已有实际显示/产物证据,仍以具体测试范围为限 |
|
||||
| 接通 | 35 | 代码链路存在,本轮未重新调用所有模型 |
|
||||
| 部分 | 60 | 已有部分能力,但交互、参数、流程或服务不等价 |
|
||||
| 缺失 | 70 | 缺该活动入口、处理链或专用服务 |
|
||||
| 占位 | 38 | 可见控件/状态与真实执行不相符,应优先纠正 |
|
||||
| 待验 | 5 | 参考或我方结果层未得到足够现场证据 |
|
||||
|
||||
这些数量是“检查点”而非独立功能数量,也不是完成率。一个功能会拆成参数、按钮、持久化、结果等多项;不要用“模型可用”代替整套流程验收。
|
||||
|
||||
## 需要优先处理的具体问题
|
||||
|
||||
1. **脚本假完成**:示例角色/场景/道具混入真实工作流;资产进度直接显示完成;3/3 写死;新增/行操作/单镜重新合成存在空处理或只弹成功提示。
|
||||
2. **音频生成壳界面**:模型名称硬编码,选模型仅关菜单,生成按钮没有请求,调节值是静态 div,音色列表和分页也是固定数据。
|
||||
3. **历史记录不是真实历史**:当前节点不足时补示例图/视频,固定显示前 8 项,批量下载只有等待动画,删除仅隐藏当前组件。
|
||||
4. **视频参数没有全部生效**:2/4 个仍只请求一次;联网搜索和自动校验开关保存了状态但没有相应执行,不能展示成生效能力。
|
||||
5. **多角度预设不影响参数**:点击“鱼眼/俯拍/背面”等只改变 tab;生成仍使用旧数值。应让预设修改数值/提示词,并纳入提交。
|
||||
6. **3D 导演台尚未等价**:基础人偶与真实相机运镜可用;骨骼姿势、角色多轨、目标跟随、自由路径、全景与识图重建缺失。底部时间轴的布局也没有复刻。
|
||||
7. **图片参数及参考兜底仍有问题**:主图片 composer 未把清晰度传入生成请求;多张请求只提取第一张结果;无参考图时仍可能回退示例图片。需按入口修复,不能只补选项外观。
|
||||
8. **参考站已更新后期工具**:现有旧版工具栏未覆盖片段重拍、续写、主体消除、补帧和慢放。超分、去字幕、人声分离仍需专用服务。
|
||||
|
||||
P0 指误导性成功、假参数、假历史或无法执行的活跃按钮;P1 指核心闭环与关键能力差异;P2 指布局、便利性或扩展能力。本轮只建立对照记录,没有把这些新发现冒充成已修复。本轮添加的参考站测试节点和空分组已清理,原有两张图片及连线保留。
|
||||
|
||||
## 实际界面对照
|
||||
|
||||
相同浏览器视口 1280×720;比较布局与可见功能,不计算不具可比性的整屏像素差异(场景内容与选中对象不同)。
|
||||
|
||||
| 参考站导演台 | 我方当前真实导演台 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
- 参考站左侧工具栏及场景区约 280px,右侧属性区约 280px;我方左侧约 210px、右侧约 260px,布局和工具顺序不同。
|
||||
- 参考站底部为多轨时间尺,角色和机位分轨;我方轨迹编辑在右侧下方,720px 高度首屏不可见。
|
||||
- 参考站是有骨骼关节的人体;我方默认是基础几何人偶。真实渲染成功不能据此视为角色能力对齐。
|
||||
- 我方 FOV 可显示很长的小数,旋转输入使用弧度;参考站显示简短角度值。需要统一格式、单位和控件尺寸。
|
||||
- 顶部、空节点、音频参数、素材市场等尚未做逐状态截图像素验收;此前局部测过的间距不能外推整个页面已像素级复刻。
|
||||
|
||||
## 逐项检查表
|
||||
|
||||
### 01 画布、节点和项目
|
||||
|
||||
参考证据:添加节点、右键、快捷键、故事板、资产管理现场展开。
|
||||
|
||||
代码定位:`components/canvas/LiblibVideoCanvasReplica.tsx:752、10180、11644、18712、23916;hooks/use-video-canvas-persistence.ts`。
|
||||
|
||||
| ID | 功能路径 | 参考站现场 | 我方当前 | 判定 | 优先级 |
|
||||
|---|---|---|---|---|---|
|
||||
| 01-01 | 项目/标题 | 顶部编辑项目名称 | 已接标题编辑与保存 | 接通 | P2 |
|
||||
| 01-02 | 项目/多画布切换 | 画布下拉入口 | 顶部无对应切换;侧栏“画布 1”固定按钮 | 缺失 | P1 |
|
||||
| 01-03 | 视图/工作流 | 节点及连线画布 | 已有节点、连线及媒体结果 | 接通 | P2 |
|
||||
| 01-04 | 视图/故事板 | 按文本、图片、视频分类的可排序卡片 | 活动 TopBar 无故事板切换和相应视图 | 缺失 | P1 |
|
||||
| 01-05 | 故事板/关键元素 | 关键元素页签与详情入口 | 没有对应故事板资源组织层 | 缺失 | P1 |
|
||||
| 01-06 | 故事板/音频 | 单独音频页签与音轨卡片 | 没有对应故事板音轨区 | 缺失 | P1 |
|
||||
| 01-07 | 故事板/对话引用 | 卡片“对话”添加到 Agent | 没有这套故事板到 Agent 引用链路 | 缺失 | P1 |
|
||||
| 01-08 | 故事板/剪辑 | 视频区有剪辑入口 | 已有单视频裁剪,不等同故事板剪辑 | 部分 | P1 |
|
||||
| 01-09 | 添加/基本节点 | 文本、图片、视频、音频、导演台、脚本 | 均有基本入口 | 接通 | P2 |
|
||||
| 01-10 | 添加/智能剪辑 | Beta 独立节点 | 本地仍是“视频合成”空节点 | 缺失 | P1 |
|
||||
| 01-11 | 添加/逐帧拉片 | SD 2.5 独立节点 | 只有视频解析,缺独立节点 | 缺失 | P1 |
|
||||
| 01-12 | 添加/脚本版本 | 新版和旧版二级菜单 | 已有两个入口,深层能力见脚本组 | 部分 | P1 |
|
||||
| 01-13 | 添加/搜索 | 搜索画布节点 | 命令菜单与侧栏搜索存在,交互范围不同 | 部分 | P2 |
|
||||
| 01-14 | 画布/平移缩放 | 选择、抓手、Space、比例输入、适屏 | 平移、滚轮、比例和适屏已有实现 | 接通 | P2 |
|
||||
| 01-15 | 画布/小地图 | 切换小地图 | 已有可点击概览 | 接通 | P2 |
|
||||
| 01-16 | 画布/网格吸附 | 网格吸附开关 | 已有吸附开关及坐标处理 | 接通 | P2 |
|
||||
| 01-17 | 画布/连线显隐 | 隐藏节点连线 | 已有显隐和连接点交互 | 接通 | P2 |
|
||||
| 01-18 | 画布/整理 | 自动整理节点 | 已有整理预览、保留和恢复 | 接通 | P2 |
|
||||
| 01-19 | 画布/分组解组 | 快捷键面板含成组、解组、合并分镜组 | 有预设分组外观,未找到通用分组编辑链路 | 部分 | P1 |
|
||||
| 01-20 | 画布/撤销重做 | 快捷键列出全局撤销与重做 | 局部字幕框有撤销;不等于全画布操作历史 | 部分 | P1 |
|
||||
| 01-21 | 右键/评级 | 1–5 级评分 | 活动右键无评级 | 缺失 | P2 |
|
||||
| 01-22 | 右键/复制粘贴 | 复制节点、创建副本、复制图片、粘贴 | 复制节点有实现;复制到剪贴板仅名称 | 部分 | P1 |
|
||||
| 01-23 | 右键/资产沉淀 | 保存到我的资产、创建主体 | 活动菜单无这两个业务动作 | 缺失 | P1 |
|
||||
| 01-24 | 右键/全景预览 | 进入全景预览 | 生成全景图不等于球面预览 | 缺失 | P2 |
|
||||
| 01-25 | 右键/素材校验 | Seedance 合规校验入口 | 本地输入数量校验不是该素材校验服务 | 缺失 | P2 |
|
||||
| 01-26 | 快捷键/一致性 | G、D、Tab、V、H、撤销重做等 | 活动监听主要 Space、L、删除、Esc,保存另有监听 | 部分 | P1 |
|
||||
| 01-27 | 持久化/完成结果恢复 | 参考画布刷新后新增节点仍在 | 本地上一轮真实链路刷新恢复通过 | 实测 | P1 |
|
||||
| 01-28 | 持久化/后台与刷新中任务 | 本轮未提交参考站付费生成 | 保存快照存在;未完成任务断线恢复需专项实测 | 待验 | P1 |
|
||||
|
||||
### 02 文本和提示词
|
||||
|
||||
参考证据:文本节点入口、视频编辑区现场;参考站生成未执行。
|
||||
|
||||
代码定位:`components/canvas/LiblibVideoCanvasReplica.tsx:6257、13969、14662;app/api/canvas/prompt-tools/route.ts;lib/video-canvas/prompt-tools.ts`。
|
||||
|
||||
| ID | 功能路径 | 参考站现场 | 我方当前 | 判定 | 优先级 |
|
||||
|---|---|---|---|---|---|
|
||||
| 02-01 | 文本/手工编写 | 自己编写内容 | 创建文本资源节点并可编辑 | 接通 | P2 |
|
||||
| 02-02 | 文本/文生视频 | 文本连到视频 | 能建立节点关系,但快捷入口带示例提示词 | 部分 | P1 |
|
||||
| 02-03 | 文本/图片反推 | 图片反推提示词 | 有视觉分析链路;快捷入口仍插入示例图片 | 部分 | P1 |
|
||||
| 02-04 | 文本/文字生音乐 | 文字生音乐入口 | 音频生成面板无真实提交,不能算完成 | 占位 | P0 |
|
||||
| 02-05 | 提示词/优化 | 视频编辑区优化入口 | 官方千问 3.8 已有真实返回记录 | 实测 | P2 |
|
||||
| 02-06 | 提示词/翻译 | 视频编辑区翻译入口 | 中英翻译有实际返回、预览与应用 | 实测 | P2 |
|
||||
| 02-07 | 提示词/@引用 | 富文本中引用素材 | 已有图片、角色、标记引用;不等同完整 AutoLink | 部分 | P1 |
|
||||
| 02-08 | 提示词/AutoLink | 图片、视频均有智能引用开关 | 没有与参考站等价的自动素材关联执行层 | 缺失 | P1 |
|
||||
| 02-09 | 提示词/联网搜索 | 视频高级设置开关 | 只保存 searchEnabled,视频请求未传入执行 | 占位 | P0 |
|
||||
| 02-10 | 提示词/自动校验素材 | 视频高级设置开关 | 有基本输入约束,但开关未驱动独立素材校验 | 占位 | P0 |
|
||||
| 02-11 | 提示词/文本模型显示 | 参考站显示其自有模型名 | 主服务用我方模型;局部文案仍写 GVLM 等固定名 | 部分 | P1 |
|
||||
|
||||
### 03 图片生成与编辑
|
||||
|
||||
参考证据:image-toolbar、parameters、grid、angle、lighting、element-edit、layer-split 现场展开。
|
||||
|
||||
代码定位:`components/canvas/LiblibVideoCanvasReplica.tsx:3285、6570、6676、6928、15765、16892;lib/video-canvas/config.ts`。
|
||||
|
||||
| ID | 功能路径 | 参考站现场 | 我方当前 | 判定 | 优先级 |
|
||||
|---|---|---|---|---|---|
|
||||
| 03-01 | 图片/模型选择 | 参考站 Lib Image 等目录 | 主图片入口匹配我方图片/图片编辑模型 | 接通 | P1 |
|
||||
| 03-02 | 图片/1K 2K 4K | Lib Image 2.5 Pro 参数有三档 | 已检查的主图片 composer 提交未传 settings.resolution,选择未落实 | 占位 | P0 |
|
||||
| 03-03 | 图片/画质 | 低、标准、高、超高、极致五档 | 主面板无相同五档的完整参数闭环 | 部分 | P2 |
|
||||
| 03-04 | 图片/透明背景 | 自动、保留、透明 | 未看到相同背景模式控件及请求映射 | 缺失 | P2 |
|
||||
| 03-05 | 图片/比例 | 13 种比例,本次见参数清单 | 已有比例选择;各线路支持范围需逐一配置 | 部分 | P1 |
|
||||
| 03-06 | 图片/数量 | 1、2、4 张 | 主 composer 提交 imageCount,但结果仅取 getFirstGeneratedImageUrl | 部分 | P0 |
|
||||
| 03-07 | 图片/替换素材 | 替换与历史资源入口 | 本地上传和历史资源选取;历史含示例数据问题 | 部分 | P1 |
|
||||
| 03-08 | 图片/人像调节 | 人像质感预设节点 | 活动工具栏无对应完整调节功能 | 缺失 | P1 |
|
||||
| 03-09 | 图片/情绪调节 | 人像二级菜单有情绪调节 | 未发现对应活动入口与执行链路 | 缺失 | P1 |
|
||||
| 03-10 | 图片/全景生成 | 全景工具入口 | 以参考图加提示词调用图片模型 | 接通 | P2 |
|
||||
| 03-11 | 图片/全景沉浸预览 | 图片右键有全景预览 | 本地普通图片展示,缺球面交互预览 | 缺失 | P2 |
|
||||
| 03-12 | 多角度/七种预设 | 自定义、鱼眼、倾斜、俯拍、仰拍、全景俯拍、背面 | 按钮只改 activeTab,未改变提交角度参数 | 占位 | P0 |
|
||||
| 03-13 | 多角度/环绕俯仰缩放 | 三个滑块和重置 | 有数值状态并提交 angle 参数 | 接通 | P1 |
|
||||
| 03-14 | 多角度/预览 | 球面方向操作区域 | 本地中心仍是静态参考图,不能当生成后视角预览 | 部分 | P2 |
|
||||
| 03-15 | 打光/智能模式 | 智能开关 | 本地主要是预设提示词调用 | 缺失 | P1 |
|
||||
| 03-16 | 打光/亮度颜色 | 亮度百分比、颜色选择 | 缺专用参数面板与映射 | 缺失 | P1 |
|
||||
| 03-17 | 打光/六方向 | 左、顶、右、前、底、后 | 本地“打光”通用生成,未复刻这些参数 | 缺失 | P1 |
|
||||
| 03-18 | 打光/轮廓光 | 轮廓光开关、重置 | 无等价细项 | 缺失 | P2 |
|
||||
| 03-19 | 高清/专用模型 | Topazlabs、模型子选项 | 我方增强或通用图片线路,不能声称等效 Topaz | 部分 | P1 |
|
||||
| 03-20 | 高清/倍率 | 2、4、6 倍 | 本地没有同一组倍率的完整规格约束 | 部分 | P1 |
|
||||
| 03-21 | 宫格预设/11 类 | 九宫格二级菜单共 11 项 | 均已定义对应功能提示词与路由 | 接通 | P1 |
|
||||
| 03-22 | 宫格预设/禁用状态 | 本轮未操作参考后台开关 | 本地部分分支 shouldGenerate=false 仍标 complete | 占位 | P0 |
|
||||
| 03-23 | 宫格切分/固定规格 | 2×2、3×3、4×4、5×5 | 本地四组切分与结果节点 | 接通 | P2 |
|
||||
| 03-24 | 宫格切分/自定义 | 5×5 选择器 | 本地自定义 rows/columns 与网格框 | 接通 | P2 |
|
||||
| 03-25 | 宫格切分/边界与选中 | 网格分区后可选格 | 本地边界识别、单格/批量输出;本轮不重跑算法 | 接通 | P2 |
|
||||
| 03-26 | 元素编辑/点选 | 点选对象 | 本地局部区域编辑,缺同等点选分割能力 | 部分 | P1 |
|
||||
| 03-27 | 元素编辑/框选 | 框选区域加编辑内容 | 已有局部框选和真实编辑 API | 接通 | P1 |
|
||||
| 03-28 | 元素编辑/画笔 | 画笔选择与撤销 | 本地矩形区域不能代替任意蒙版笔刷 | 部分 | P1 |
|
||||
| 03-29 | 图层分离/物体拆分 | 框选、提示词、撤销重做、拆分 | 本地未找到对应多图层分离节点链路 | 缺失 | P1 |
|
||||
| 03-30 | 图片/扩图重绘擦除抠图 | 本轮当前工具栏未逐个看到旧入口 | 我方已有各 route 配置;本轮参考覆盖不足 | 待验 | P2 |
|
||||
| 03-31 | 图片/下载预览 | 图片工具栏及资源下载入口 | 本地下载辅助函数与预览存在 | 接通 | P2 |
|
||||
| 03-32 | 图片/小屏弹窗 | 本轮浏览器 1280×720 | 本地多角度固定 1200×768,存在纵向溢出风险 | 部分 | P1 |
|
||||
| 03-33 | 图片/空参考输入 | 参考站说明可直接文字生图 | 主 composer 无参考时 sourceUrl 回退 CHARACTER_IMAGE_URL 并传给生成 | 占位 | P0 |
|
||||
|
||||
### 04 视频生成和模型匹配
|
||||
|
||||
参考证据:video-models、modes、parameters、motion-library、effects 现场展开。
|
||||
|
||||
代码定位:`components/canvas/LiblibVideoCanvasReplica.tsx:7800、8105、8175、14662、17392、17570;lib/video-canvas/model-matching.ts;lib/ai/providers/aliyun-canvas-video.ts`。
|
||||
|
||||
| ID | 功能路径 | 参考站现场 | 我方当前 | 判定 | 优先级 |
|
||||
|---|---|---|---|---|---|
|
||||
| 04-01 | 视频/文生视频 | 独立输入模式 | VIP98K Veo 3.1 Fast 已产出真实视频 | 实测 | P1 |
|
||||
| 04-02 | 视频/首帧生成 | 图生视频入口 | 阿里云 Wan 2.6 i2v flash 真实链路通过 | 实测 | P1 |
|
||||
| 04-03 | 视频/首尾帧 | 首尾两张模式 | 输入契约和路由已接,历史实测遇上游失败 | 部分 | P1 |
|
||||
| 04-04 | 视频/多图参考 | 图片参考模式 | 已接契约;不代表所有模型线路成功 | 部分 | P1 |
|
||||
| 04-05 | 视频/全能参考 | 图片、视频、音频等参考能力因模型变化 | 本地主模式以图片为主,未等价覆盖全模态参考 | 部分 | P1 |
|
||||
| 04-06 | 视频/5 分钟长视频 | 空节点有 5 分钟入口 | 无对应长视频编排节点 | 缺失 | P1 |
|
||||
| 04-07 | 视频/模型目录 | 现场展开 35 条视频模型按钮 | 使用我方可执行适配器目录,数量不作为复刻标准 | 部分 | P1 |
|
||||
| 04-08 | 视频/按功能过滤模型 | 模式、参考输入决定可选能力 | 本地按 supportedModes 和 provider adapter 过滤 | 接通 | P1 |
|
||||
| 04-09 | 视频/无兼容模型 | 参考站不适用模式可禁用 | 本地显示暂无可用模型并拦截 | 接通 | P1 |
|
||||
| 04-10 | 视频/比例规格 | Auto、16:9、4:3、1:1、3:4、9:16、21:9 | 动态读取模型能力;阿里首帧比例跟随输入 | 接通 | P1 |
|
||||
| 04-11 | 视频/清晰度 | 现场选定模型显示 480P、720P、1080P、4K | 仅展示我方线路支持清晰度,不能盲加 4K | 接通 | P1 |
|
||||
| 04-12 | 视频/时长 | 滑条和数字输入,按模型变化 | 本地能力驱动离散按钮,交互不一样 | 部分 | P2 |
|
||||
| 04-13 | 视频/生成音频 | 开启、关闭 | 主视频和阿里链路已传 generateAudio | 实测 | P1 |
|
||||
| 04-14 | 视频/生成数量 | 1、2、4 个 | count 保存到 settings,但只执行一次 generateVideo | 占位 | P0 |
|
||||
| 04-15 | 视频/参考选择 | 画布素材引用 | 本地实际参考选择、首尾图数量验证 | 接通 | P1 |
|
||||
| 04-16 | 视频/标记引用 | 标记对象并插入提示词 | 本地有识别和标记服务;效果本轮未对拍 | 接通 | P1 |
|
||||
| 04-17 | 视频/运镜库 | 23 种运镜、收藏、我的运镜、搜索 | 本地配置预设、搜索与自定义已有;收藏仅 localStorage | 部分 | P2 |
|
||||
| 04-18 | 视频/运镜引用 | 选择后插入运镜 token | 本地转语义提示词;不等同相机轨迹精确控制 | 部分 | P1 |
|
||||
| 04-19 | 视频/角色素材 | 角色库和合规素材入口 | 本地配置角色描述、图片与参考拼接 | 部分 | P1 |
|
||||
| 04-20 | 视频/特效库 | 特效广场、收藏、最近使用、商用信息 | 本地配置特效/提示词,收藏等交互不完整 | 部分 | P1 |
|
||||
| 04-21 | 视频/生成任务恢复 | 本轮未执行参考站计费生成 | 快照能保存,但异步任务跨刷新完成恢复尚未本轮验证 | 待验 | P1 |
|
||||
| 04-22 | 视频/结果归档 | 可管理生成结果 | 我方真实视频归档链路通过,长耗时包含归档阶段 | 实测 | P1 |
|
||||
|
||||
### 05 视频后期、智能剪辑和拉片
|
||||
|
||||
参考证据:上传本地无音轨合成测试 MP4 后展开后期工具;未提交付费处理。
|
||||
|
||||
代码定位:`components/canvas/LiblibVideoCanvasReplica.tsx:763、8700、8760、8851、12600;app/api/canvas/video/process/route.ts;lib/video-canvas/media-processing.ts`。
|
||||
|
||||
| ID | 功能路径 | 参考站现场 | 我方当前 | 判定 | 优先级 |
|
||||
|---|---|---|---|---|---|
|
||||
| 05-01 | 后期/工具栏结构 | 高清、片段重拍、逐帧拉片、去字幕、音视频分离、主体消除、截帧 | 本地仍以剪辑、裁剪、高清、解析、去字幕、音频分离为主 | 部分 | P1 |
|
||||
| 05-02 | 后期/时间裁剪 | 参考本站新版剪辑入口与旧版工具栏不同 | 我方 FFmpeg 真实裁剪产物通过 | 实测 | P1 |
|
||||
| 05-03 | 后期/空间裁剪 | 本轮未在新工具栏看到旧裁剪入口 | 我方框选和 FFmpeg 真实裁剪通过 | 实测 | P1 |
|
||||
| 05-04 | 后期/视频高清 | Topazlabs 节点,1080P、2K、4K | 明确报未接超分服务 | 缺失 | P1 |
|
||||
| 05-05 | 后期/补帧 | 高质量补帧、不补帧 | 无专用帧插值处理 | 缺失 | P1 |
|
||||
| 05-06 | 后期/慢放 | 1x、2x、3x、5x | 无对应慢放处理管线 | 缺失 | P2 |
|
||||
| 05-07 | 后期/片段重拍 | 要求视频不少于 4 秒 | 本地未见局部视频重拍适配器 | 缺失 | P1 |
|
||||
| 05-08 | 后期/智能续写 | 片段重拍二级菜单,本次禁用 | 本地无续写适配;参考禁用条件未全部确认 | 缺失 | P1 |
|
||||
| 05-09 | 后期/智能去字幕 | 智能擦除生成器 | 我方明确未接处理服务 | 缺失 | P1 |
|
||||
| 05-10 | 后期/框选去字幕 | 本轮参考新面板未展开同名分支 | 本地有区域框选和撤销,但不产生去字幕视频 | 部分 | P1 |
|
||||
| 05-11 | 后期/音视频分离 | 无音轨片段禁用并说明原因 | 我方已有真实静音视频和独立 M4A 结果 | 实测 | P1 |
|
||||
| 05-12 | 后期/人声提取 | 分离菜单独立项 | 专用分离模型未接通 | 缺失 | P1 |
|
||||
| 05-13 | 后期/背景音提取 | 分离菜单独立项 | 专用分离模型未接通 | 缺失 | P1 |
|
||||
| 05-14 | 后期/主体消除 | 点选、框选、画笔、橡皮,最多 4 个 | 无对应视频对象消除流程 | 缺失 | P1 |
|
||||
| 05-15 | 后期/截取帧 | 当前帧、首帧、尾帧 | 已有视频解码截图节点链路 | 接通 | P2 |
|
||||
| 05-16 | 后期/截图规格显示 | 上传片段显示真实宽高 | 本地截帧结果 outputSizeLabel 固定 1280×720,需改实际值 | 部分 | P1 |
|
||||
| 05-17 | 智能剪辑/四种模板 | 讲解视频、批量广告、口播视频、素材混剪 | 本地“视频合成”无这些模板 | 缺失 | P1 |
|
||||
| 05-18 | 智能剪辑/讲解模板 | 创建音频参考连线和 60s 生成面板 | 没有等价编排流程 | 缺失 | P1 |
|
||||
| 05-19 | 智能剪辑/多素材编排 | 要求连接视频素材并操作 | 本地合成节点无真实拼接处理 | 占位 | P0 |
|
||||
| 05-20 | 逐帧拉片/输入信息 | 上传/替换、时长、分辨率 | 本地通用视频解析不等同专用拉片节点 | 部分 | P1 |
|
||||
| 05-21 | 逐帧拉片/拆解维度 | 分镜、动态、音乐三维度 | 没有逐维度可选执行链路 | 缺失 | P1 |
|
||||
| 05-22 | 逐帧拉片/结果继续创作 | 需提交分析后才能观察下游 | 参考付费结果本轮未生成,不能评为已对齐 | 待验 | P1 |
|
||||
|
||||
### 06 脚本的三层流程
|
||||
|
||||
参考证据:新版脚本→自己编写→确认镜头→准备资产→新增角色→AI 生成。
|
||||
|
||||
代码定位:`components/canvas/LiblibVideoCanvasReplica.tsx:2722、2792、2887、8280、8392、9170、11203;lib/video-canvas/story-script.ts`。
|
||||
|
||||
| ID | 功能路径 | 参考站现场 | 我方当前 | 判定 | 优先级 |
|
||||
|---|---|---|---|---|---|
|
||||
| 06-01 | 脚本/模型生成 | 剧本、角色生成分镜脚本 | 调用 workflow analyze 解析真实结构化脚本 | 接通 | P1 |
|
||||
| 06-02 | 脚本/手工编写 | 可以从空镜头开始 | 本地入口与展示有,但完整行编辑未接 | 部分 | P1 |
|
||||
| 06-03 | 确认镜头/字段 | 时长、画面、景别、光影、对白、音效、运镜、提示词 | 本地能展示相同字段 | 接通 | P2 |
|
||||
| 06-04 | 确认镜头/单元格编辑 | 字段弹窗可编辑 | 本地表格主要只读 div | 缺失 | P1 |
|
||||
| 06-05 | 确认镜头/添加镜头 | 添加镜头按钮 | 按钮无 onClick | 占位 | P0 |
|
||||
| 06-06 | 确认镜头/拖动排序 | 行拖拽排序 | 本地没有行拖拽数据更新 | 缺失 | P1 |
|
||||
| 06-07 | 确认镜头/行操作 | 每行更多操作 | 本地 ⋯ 按钮没有处理 | 占位 | P0 |
|
||||
| 06-08 | 准备资产/角色场景道具 | 三类资产分别新增 | 本地有三分类展示 | 部分 | P1 |
|
||||
| 06-09 | 准备资产/真实来源 | 从当前画布、上传、个人资产库、AI 生成 | 本地资产提取后混入固定示例图片与人物 | 占位 | P0 |
|
||||
| 06-10 | 准备资产/新增 | 新增角色、场景、道具 | 本地新增按钮无处理 | 占位 | P0 |
|
||||
| 06-11 | 准备资产/替换清除删除 | 资源管理流程 | 本地悬浮菜单 pointer-events-none,没有真实处理 | 占位 | P0 |
|
||||
| 06-12 | 准备资产/资产生成参数 | 模型、质量、清晰度、比例 | 本地缺每项资产的模型与生成参数编辑器 | 缺失 | P1 |
|
||||
| 06-13 | 准备资产/完成状态 | 参考按实际进度显示,空资产为“暂无资产” | 本地直接显示 assetCount/assetCount 已生成 | 占位 | P0 |
|
||||
| 06-14 | 合成提示词/完成门槛 | 0/3 状态阻止直接批量视频 | 本地固定写 3/3 完成 | 占位 | P0 |
|
||||
| 06-15 | 合成提示词/单镜合成 | 每镜最终提示词查看入口 | 本地“重新合成”仅 toast.success | 占位 | P0 |
|
||||
| 06-16 | 合成提示词/全部合成 | 独立第三步流程 | 本地 onRun 调用整节点 runNode,不是独立资产感知合成 | 部分 | P1 |
|
||||
| 06-17 | 合成提示词/风格来源 | 应来自当前工作流选定资产/风格 | 本地固定追加“盛唐·国风二次元” | 占位 | P0 |
|
||||
| 06-18 | 输出/批量分镜 | 批量生成分镜 | 本地创建分镜节点并执行生成,未本轮整批实测 | 接通 | P1 |
|
||||
| 06-19 | 输出/批量视频 | 完成前序再批量生成 | 本地可建视频节点,但前置资产真实性与门槛不完整 | 部分 | P1 |
|
||||
| 06-20 | 输出/导出 | 脚本下载入口 | 本地有脚本行导出 | 接通 | P2 |
|
||||
| 06-21 | 脚本/旧版 | 参考有旧版菜单 | 本轮只展开版本入口,未逐层复核旧版结果页 | 待验 | P2 |
|
||||
|
||||
### 07 音频、配音和音色
|
||||
|
||||
参考证据:audio-models、parameters、tts、voice-library、voice-filter 现场展开。
|
||||
|
||||
代码定位:`components/canvas/LiblibVideoCanvasReplica.tsx:14086、14150、14200、14336、9190;lib/video-canvas/model-matching.ts:66`。
|
||||
|
||||
| ID | 功能路径 | 参考站现场 | 我方当前 | 判定 | 优先级 |
|
||||
|---|---|---|---|---|---|
|
||||
| 07-01 | 音频/模型选择 | 6 条音频模型分属通用音频、语音、音乐 | 本地同名静态数组,点击只关闭菜单 | 占位 | P0 |
|
||||
| 07-02 | 音频/生成提交 | 文本输入后生成音频 | 活动生成音频按钮无处理函数 | 占位 | P0 |
|
||||
| 07-03 | 音频/草稿恢复 | 本轮未专项刷新参考音频草稿 | 本地文本只在组件 useState,未写回节点 | 占位 | P0 |
|
||||
| 07-04 | 音频/停顿语气 | 停顿与语气词工具 | 本地可插文本 token,尚无音频模型解释 | 部分 | P1 |
|
||||
| 07-05 | 音频/翻译 | 音频输入区翻译入口 | 本地按钮无 onClick | 占位 | P0 |
|
||||
| 07-06 | 音频/语速声调音量 | 可调滑块和输入 | 本地是 div 画的固定进度条 | 占位 | P0 |
|
||||
| 07-07 | 音频/音高强度音色 | 三个可调值 | 本地固定值外观 | 占位 | P0 |
|
||||
| 07-08 | 音频/音效 | 无、空旷回音、礼堂广播、电话失真、电音 | 按钮无参数更新 | 占位 | P0 |
|
||||
| 07-09 | 音频/重置 | 一键重置 | 本地按钮无处理 | 占位 | P0 |
|
||||
| 07-10 | 音频/语言 | Seed Audio 中文、英文 | 本地没有 Seed Audio 真实参数模式 | 缺失 | P1 |
|
||||
| 07-11 | 音频/采样率 | 8k、16k、24k、48k | 没有对应请求参数 | 缺失 | P1 |
|
||||
| 07-12 | 音频/格式 | wav、mp3、pcm、ogg_opus | 没有对应输出适配 | 缺失 | P1 |
|
||||
| 07-13 | 音色/列表 | 服务端音色列表,现场显示 327 条 | 本地仅少量静态行,页数和总数硬编码 | 占位 | P0 |
|
||||
| 07-14 | 音色/试听选择 | 播放、选择当前音色 | 本地按钮无试听与选中处理 | 占位 | P0 |
|
||||
| 07-15 | 音色/搜索筛选 | 语言、口音、性别、年龄 | 本地搜索不筛选、筛选按钮无处理 | 占位 | P0 |
|
||||
| 07-16 | 音色/收藏分页 | 我的音色、收藏、分页 | 本地按钮无真实数据动作 | 占位 | P0 |
|
||||
| 07-17 | 音色/克隆 | 克隆新音色入口 | 本地按钮无处理;参考未继续提交克隆 | 占位 | P0 |
|
||||
| 07-18 | 音乐/生成 | Eleven Music、Mureka 分类模型 | 本地没有音乐生成适配闭环 | 缺失 | P1 |
|
||||
| 07-19 | 音频生视频/模型匹配 | 音频驱动视频独立入口 | 仅 AtlasCloud reference_audios 可选,未确认现有可用线路 | 部分 | P1 |
|
||||
| 07-20 | 音频/播放已上传音轨 | 真实播放已有音轨 | 上一轮已经修成真实 audio 控件 | 实测 | P2 |
|
||||
|
||||
### 08 3D 导演台:场景与角色
|
||||
|
||||
参考证据:director-role、pose、crowd、camera-properties、ai-import 现场;本地组件浏览器复核。
|
||||
|
||||
代码定位:`components/canvas/director/DirectorStudioDialog.tsx;DirectorViewport.tsx;lib/video-canvas/director-scene.ts;director-glb.ts`。
|
||||
|
||||
| ID | 功能路径 | 参考站现场 | 我方当前 | 判定 | 优先级 |
|
||||
|---|---|---|---|---|---|
|
||||
| 08-01 | 场景/真实渲染 | 真实 3D 场景与模型 | 本地 Three.js 真实渲染;本轮再次显示正常 | 实测 | P1 |
|
||||
| 08-02 | 场景/视角 | 导演与机位视角 | 本地两种视角 | 实测 | P1 |
|
||||
| 08-03 | 场景/对象搜索 | 搜索场景对象 | 本地只有对象列表 | 缺失 | P2 |
|
||||
| 08-04 | 场景/显隐锁定 | 每对象隐藏、锁定 | 本地没有相同对象状态控制 | 缺失 | P1 |
|
||||
| 08-05 | 场景/重命名 | 角色、相机可编辑名称 | 本地主要显示名称,缺同等重命名控件 | 部分 | P2 |
|
||||
| 08-06 | 场景/移动旋转缩放 | 变换工具与轴向输入 | 本地已有三种变换轴和数值输入 | 实测 | P1 |
|
||||
| 08-07 | 场景/旋转单位 | 参考显示角度 | 本地对象旋转是弧度,直接复刻仍有使用差异 | 部分 | P2 |
|
||||
| 08-08 | 场景/统一缩放 | 独立统一缩放控件 | 本地只有 XYZ 缩放输入 | 缺失 | P2 |
|
||||
| 08-09 | 场景/颜色 | 颜色选择与十六进制输入 | 本地对象已有颜色数据,但无同等编辑 UI | 缺失 | P2 |
|
||||
| 08-10 | 角色/八种体型 | 标准男、标准女、健硕、纤细、少年、儿童、宽厚、二头身 | 本地基础几何人偶,非这套骨骼角色 | 缺失 | P1 |
|
||||
| 08-11 | 角色/二十种姿势 | 站立到看手机,共 20 项 | 本地没有骨骼姿势库 | 缺失 | P1 |
|
||||
| 08-12 | 角色/身体躯干头部 | 前倾、转身、侧倾、扭转、点头、歪头 | 本地无关节参数数据和 UI | 缺失 | P1 |
|
||||
| 08-13 | 角色/肩肘髋膝 | 左右关节独立滑条 | 本地无骨骼关节控制 | 缺失 | P1 |
|
||||
| 08-14 | 角色/群众阵列 | 行数、列数、间距及人数 | 本地没有阵列编辑 | 缺失 | P1 |
|
||||
| 08-15 | 角色/本地模型 | 参考有本地上传入口,格式限额未提交确认 | 本地 self-contained GLB ≤25MiB;真实 GLB 加载验证过 | 部分 | P1 |
|
||||
| 08-16 | 角色/几何模型 | 参考几何模型入口 | 本地立方体与球体可用,类型范围未完全对齐 | 部分 | P2 |
|
||||
| 08-17 | 全景/上传背景 | 全景本地上传 | 本地没有全景贴图入口 | 缺失 | P1 |
|
||||
| 08-18 | 全景/历史记录 | 全景历史选择 | 本地没有对应历史资产 | 缺失 | P2 |
|
||||
| 08-19 | 全景/AI 生成 | 提示词、参考图、后台生成说明 | 本地没有全景生成导入闭环 | 缺失 | P1 |
|
||||
| 08-20 | AI 导入/图源选择 | 本地、历史、拖拽 | 本地按钮明确禁用 | 缺失 | P1 |
|
||||
| 08-21 | AI 导入/插入与覆盖 | 站位参考层插入或覆盖场景 | 本地没有场景识别重建服务 | 缺失 | P1 |
|
||||
| 08-22 | AI 导入/关闭继续任务 | 面板写明关闭不打断识图 | 本地尚无该任务执行和恢复层 | 缺失 | P1 |
|
||||
|
||||
### 09 3D 导演台:机位、轨迹、输出
|
||||
|
||||
参考证据:director-cameras、timeline、path、camera-properties 现场;本地真实 MP4 与组件验证。
|
||||
|
||||
代码定位:`components/canvas/director/DirectorMotionPanel.tsx;DirectorStudioDialog.tsx;lib/video-canvas/director-motion.ts;director-recording.ts`。
|
||||
|
||||
| ID | 功能路径 | 参考站现场 | 我方当前 | 判定 | 优先级 |
|
||||
|---|---|---|---|---|---|
|
||||
| 09-01 | 机位/预设 | 当前视角加 14 个预设 | 本地只有正面、侧面、俯视 3 个预设 | 部分 | P1 |
|
||||
| 09-02 | 机位/切换 | 机位选择和切换视角 | 本地已有多机位场景数据和选择 | 接通 | P1 |
|
||||
| 09-03 | 机位/位置与注视坐标 | XYZ 位置、注视坐标 | 本地均有输入并影响真实画面 | 实测 | P1 |
|
||||
| 09-04 | 机位/手动旋转 | XYZ 欧拉角,含荷兰角 | 本地以 position/target 为主,缺相机 roll 等控制 | 缺失 | P1 |
|
||||
| 09-05 | 机位/跟随对象 | 可选场景角色跟随 | 本地固定目标坐标,不会跟随运动角色 | 缺失 | P1 |
|
||||
| 09-06 | 机位/注视对象 | 手动坐标、手动旋转、角色 | 本地仅注视点坐标 | 部分 | P1 |
|
||||
| 09-07 | 机位/FOV | 滑块和数值 | 本地数值与关键帧 FOV 已真实验证 | 实测 | P1 |
|
||||
| 09-08 | 机位/画幅比例 | 工具栏独立画幅入口 | 本地六种固定比例,真实横竖屏输出通过 | 实测 | P1 |
|
||||
| 09-09 | 时间轴/布局 | 底部横向时间尺与轨道 | 本地右侧竖向控制面板,720 高度需滚动 | 部分 | P1 |
|
||||
| 09-10 | 时间轴/播放暂停跳转 | 播放头、循环、时间输入 | 本地播放暂停、seek、循环和起点已有 | 实测 | P1 |
|
||||
| 09-11 | 时间轴/总时长 | 参考默认 10 秒 | 本地 2–15 秒,修改时长缩放关键帧 | 接通 | P2 |
|
||||
| 09-12 | 时间轴/单位与缩放 | 秒/ms 切换、时间线缩放、最小化 | 本地秒制单滑块,缺这些编辑视图 | 缺失 | P2 |
|
||||
| 09-13 | 时间轴/自动帧 | 自动帧开关 | 本地手动记录,不自动录入对象变化 | 缺失 | P1 |
|
||||
| 09-14 | 时间轴/多轨 | 主机位及角色轨道 | 本地单个相机轨迹,没有角色动画轨道 | 缺失 | P1 |
|
||||
| 09-15 | 时间轴/属性轨 | 位置等属性可展开,逐属性加帧 | 本地每帧整体保存位置、target、FOV | 部分 | P1 |
|
||||
| 09-16 | 关键帧/编辑删除 | 逐帧编辑与导航 | 本地时间、姿态更新、删除、校验已有 | 实测 | P1 |
|
||||
| 09-17 | 轨迹/圆环直线矩形 | 可为对象创建三种路径 | 本地推进、拉远、90° 环绕、横移四预设,语义范围不同 | 部分 | P1 |
|
||||
| 09-18 | 轨迹/铅笔钢笔 | 自由绘制路径入口 | 本地没有路径绘制和控制点工具 | 缺失 | P1 |
|
||||
| 09-19 | 轨迹/变换与朝向 | 路径位置、旋转、缩放、沿路径朝向 | 本地可视化路径但不能作为独立对象变换 | 缺失 | P1 |
|
||||
| 09-20 | 轨迹/查看全路径 | 轨迹与关键帧标记 | 本地有相机路径线和总览 | 实测 | P2 |
|
||||
| 09-21 | 轨迹/平滑插值 | 参考本轮未逐类验证插值算法 | 本地 linear/smoothstep 有数值测试,不能声称同算法 | 部分 | P2 |
|
||||
| 09-22 | 轨迹/JSON 导入导出 | 参考本轮未见此入口 | 本地新增能力,校验异常和保存重载通过 | 实测 | P2 |
|
||||
| 09-23 | 输出/机位截图 | 截图与截图列表 | 本地实际 WebGL 截图并回画布 | 实测 | P1 |
|
||||
| 09-24 | 输出/运镜视频 | 导出视频到画布 | 本地 H.264 MP4 实际横竖屏、时长、帧数已验证 | 实测 | P1 |
|
||||
| 09-25 | 输出/AI 生视频 | 导演场景可进入后续生成 | 本地发送首帧加语义运镜,不能承诺 AI 精确复现轨迹 | 部分 | P1 |
|
||||
| 09-26 | 输出/取消与切后台 | 参考本轮未真实录制验证 | 本地取消已测;后台事件测试通过,真实切浏览器仍待验 | 部分 | P1 |
|
||||
|
||||
### 10 素材、历史、模板与公共入口
|
||||
|
||||
参考证据:toolbox、style-market、effects、roles、history、personal-assets、agent、share-menu。
|
||||
|
||||
代码定位:`components/canvas/LiblibVideoCanvasReplica.tsx:838、17570、22900、23173、23351、23916、24390、25273、26199;lib/video-canvas/config.ts`。
|
||||
|
||||
| ID | 功能路径 | 参考站现场 | 我方当前 | 判定 | 优先级 |
|
||||
|---|---|---|---|---|---|
|
||||
| 10-01 | 模板/工具箱 | 多种电商、运镜、转场预设卡片 | 本地有限预设可添加;不是参考站完整目录 | 部分 | P2 |
|
||||
| 10-02 | 模板/我的工具箱 | 个人工具箱入口 | 本地缺同等服务端个人模板管理 | 缺失 | P2 |
|
||||
| 10-03 | 风格/广场分类 | 10 分类,搜索、仅商用、模型筛选 | 本地静态卡片/类别,不能当在线市场 | 部分 | P1 |
|
||||
| 10-04 | 风格/收藏最近 | 我的收藏、最近使用、详情 | 本地收藏按钮只阻止事件,缺持久收藏/详情闭环 | 占位 | P0 |
|
||||
| 10-05 | 特效/收藏最近 | 特效广场、收藏、最近使用 | 本地部分只是切页,不是真实个人特效记录 | 部分 | P1 |
|
||||
| 10-06 | 角色库/筛选 | 性别、年龄、种族、时代、文化、体型、发色 | 本地这些维度及筛选有实现 | 接通 | P2 |
|
||||
| 10-07 | 角色库/四类图片 | 全身、脸部、表情、三视图 | 本地可创建对应角色图片节点 | 接通 | P1 |
|
||||
| 10-08 | 运镜库/自定义收藏 | 广场、收藏、我的运镜 | 本地可自定义、上传预览,但只保存当前浏览器 | 部分 | P2 |
|
||||
| 10-09 | 历史/全部与本画布 | 明确两个来源过滤 | 本地只由当前 nodes 组装,无跨画布真实查询 | 占位 | P0 |
|
||||
| 10-10 | 历史/示例混入 | 参考显示服务端历史记录 | 本地不足 8 项自动补示例图片,无视频时补示例视频 | 占位 | P0 |
|
||||
| 10-11 | 历史/分页数量 | 参考滚动加载 | 本地 visibleNodes 固定 slice(0,8),无后续翻页 | 占位 | P0 |
|
||||
| 10-12 | 历史/日期与排序 | 按日期、时间顺序 | 本地仅反转数组,非真实历史时间查询 | 部分 | P1 |
|
||||
| 10-13 | 历史/批量下载 | 批量下载按钮 | 本地只设 pending,15 秒后清除,没有下载执行 | 占位 | P0 |
|
||||
| 10-14 | 历史/删除 | 删除资源操作 | 本地仅组件 deletedHistoryIds 隐藏,并提示成功 | 占位 | P0 |
|
||||
| 10-15 | 历史/评级保存资产 | 评级、保存到资产、添加画布 | 本地仅部分使用节点;无评级/资产归档业务 | 部分 | P1 |
|
||||
| 10-16 | 资产管理/节点定位 | 搜索、筛选、定位与菜单 | 本地定位、搜索、重命名、复制已有 | 接通 | P2 |
|
||||
| 10-17 | 资产管理/列表宫格 | 展示设置、分组展开收起 | 本地列表为主,缺相同展示切换 | 部分 | P2 |
|
||||
| 10-18 | 资产管理/个人与Agent | 两个资产空间 | 本地资产页固定“暂无资产” | 占位 | P0 |
|
||||
| 10-19 | 资产管理/文件夹上传 | 新建文件夹、上传、默认分类 | 本地没有对应个人资产文件管理 | 缺失 | P1 |
|
||||
| 10-20 | 分享/公开链接 | 说明持链接者可查看并复制 | 本地只复制当前 URL;分享权限未建立且活动顶部无入口 | 缺失 | P1 |
|
||||
| 10-21 | 发布/发布作品 | 参考发布入口,本轮未发布 | 本地发布函数只提示待接入且活动顶部未暴露 | 缺失 | P2 |
|
||||
| 10-22 | Agent/对话及引用 | 独立面板、历史、附件、模型、@资源 | 本地活动 TopBar 无同等 Agent 入口;不能拿其他 Claw 页面抵充 | 缺失 | P1 |
|
||||
| 10-23 | Agent/Skills | Skill 模板与选择入口 | 本地 skills 弹窗不是等价 Agent 执行闭环 | 缺失 | P1 |
|
||||
| 10-24 | Agent/生成权限与通知 | 自动生成开关、通知/声音设置 | 本地没有同等视频画布 Agent 设置层 | 缺失 | P2 |
|
||||
| 10-25 | 帮助/快捷键教程 | 有快捷键和教程入口 | 本地有面板,但列出的操作需与实现逐项一致 | 部分 | P2 |
|
||||
| 10-26 | 账户/积分 | 参考积分余额与会员入口 | 本地从本站会员接口取余额,应保留自有体系 | 接通 | P2 |
|
||||
|
||||
## 已展开菜单的完整选项记录
|
||||
|
||||
### 图片预设与规格
|
||||
|
||||
- 宫格预设 11 项:多机位九宫格、剧情推演四宫格、角色脸部三视图、角色设定图、场景设定图、产品设定图、25 宫格连贯分镜、电影级光影校正、角色三视图、画面推演 3 秒后、画面推演 5 秒前。
|
||||
- 图片比例 13 项:1:1、1:2、2:1、9:16、16:9、3:4、4:3、3:2、2:3、5:4、4:5、21:9、9:21。质量与背景档位仅代表本次选定的参考站模型,不应无条件应用到我方 GPT/Qwen 线路。
|
||||
- 多角度预设 7 项:自定义、鱼眼视角、倾斜视角、正面俯拍、正面仰拍、全景俯拍、背面视角。
|
||||
|
||||
### 3D 角色、机位与轨迹
|
||||
|
||||
- 角色 8 类:标准男性、标准女性、健硕、纤细、少年、儿童、宽厚、二头身;另有群众阵列和几何模型。
|
||||
- 姿势 20 项:站立、T 型、行走、跑步、坐姿、蹲下、单膝跪、双膝跪、叉腰、倚靠、鞠躬、思考、格斗、踢球、投掷、推进、招手、伸手、抱臂、看手机。
|
||||
- 机位入口 15 项:当前视角、正面中景、正面特写、正面全景、侧面跟拍、侧面近景、背面中景、俯拍全景、45° 俯拍、低角度仰拍、低角度广角、过肩镜头、过肩镜头(右)、鸟瞰、荷兰角。
|
||||
- 路径 5 种:圆环、直线、矩形、铅笔、钢笔。本轮实际创建圆环并展开时长、XYZ 位置/旋转/缩放、沿路径朝向和属性关键帧;其他四种确认了入口,未逐一绘制并导出,因此不声称它们的参考站结果已测试。
|
||||
- 关节调节:身体前倾/转身/侧倾,躯干前倾/扭转/侧倾,头部点头/转头/歪头,左右肩前举/外展/扭转,左右肘弯曲,左右髋前抬/外展/扭转,左右膝弯曲。
|
||||
|
||||
### 视频运镜库与后期
|
||||
|
||||
- 运镜 23 项:固定、跟随、盘旋抬升、盘旋下降、上摇、下摇、左摇、右摇、上升、下降、左移、右移、前推、后移、变焦推进、变焦拉远、柯克变焦、环绕、滚筒旋转、第一视角、无人机、高空航拍、手持。
|
||||
- 超分:本轮参考模型 Topazlabs;分辨率 1080P/2K/4K;补帧“高质量/不补帧”;慢放 1x/2x/3x/5x。
|
||||
- 视频消除:点选、框选、画笔、橡皮,最多 4 个目标。无音轨视频的音视频/人声/背景音分离按钮禁用;片段重拍提示最少 4 秒。
|
||||
|
||||
### 参考站视频模型目录快照
|
||||
|
||||
这只是现场菜单,不代表服务商 api.vip98k.com 提供同一模型、同一参数或同一线路,也不代表本轮验证了这些模型成功出片。
|
||||
|
||||
Seedance 2.5、Seedance 2.0 VIP、Wan 3.0、Wan 3.0 Prime、Minimax H3、Minimax H3 Max、Seedance 2.0 Fast VIP、Seedance 2.0 Mini、Happy Horse 1.1、Happy Horse 1.0、Kling O3、Kling 3.0 Turbo、Kling 3.0、Wan 2.7、Kling O1、Wan 2.6、Hailuo 2.3、Seedance 1.5 Pro、Seedance 1.0 Pro、Seedance 1.0 Lite、Kling 2.6、Hailuo 02、Vidu Q2、Vidu Q2 Pro、Vidu Q2 Turbo、Vidu Q3 Pro、OmniHuman 1.5、Kling 2.5、Wan 2.2、Wan 2.5、Pixverse V5.5、Pixverse V5、Hailuo 2.3 Fast、Kling 3.0 动作迁移、Style Video。
|
||||
|
||||
## 我方模型复用方案的准确边界
|
||||
|
||||
| 功能 | 当前可复用线路/能力 | 不能冒充的能力 |
|
||||
|---|---|---|
|
||||
| 分析、脚本、提示词 | 官方千问 3.8;视觉任务需视觉输入能力 | 不替代音频合成、视频超分等媒体算法 |
|
||||
| 图片生成、图片编辑、设计图预设 | 我方 GPT Image/Qwen 等兼容线路,按能力筛选 | 通用提示词生成不等于确定性图层分离、精确打光或 Topaz |
|
||||
| 首帧视频 | 阿里云 Wan 2.6 i2v flash,真实链路已测 | 不能自动当文生、首尾帧、多参考、5 分钟或精确轨迹接口 |
|
||||
| 文生/首尾帧/多图参考视频 | 已接 VIP98K 等适配器,按 supportedModes | 文生成功不代表其他输入模式已成功,历史上游失败要保留 |
|
||||
| 音频驱动视频 | 仅有已实现 reference_audios 的 AtlasCloud 适配可选 | “视频能生成声音”不等于“按参考音频驱动画面” |
|
||||
| 音频/音乐/配音 | 当前活动音频面板没有真实映射 | 不得继续展示固定参考站模型名并视为已接通 |
|
||||
| 视频裁剪、去原音轨、提取音轨 | FFmpeg,已有真实产物 | 不替代人声/背景音乐分离、语义对象消除或超分 |
|
||||
| 3D 场景与运镜 MP4 | Three.js + MediaRecorder,不消耗生图/视频模型 | 不替代骨骼动画、识图建场景;AI 视频只接首帧和描述 |
|
||||
|
||||
## 后续修复和验收顺序
|
||||
|
||||
1. **先去掉误导性状态和空按钮**:脚本、音频、历史、视频多数量和高级开关、多角度预设。验收要求:每次点击有真实状态变化/请求;失败不能报成功;未接服务明确禁用;空项目没有伪造的历史与完成资产。
|
||||
2. **补核心流程数据**:脚本行编辑、资产增删改与上传、实际提示词合成、完成门槛;真实历史接口、分页、批量下载和跨刷新一致性。验收要求:新建→编辑→生成→关闭→重开保持同一真实数据。
|
||||
3. **按模型能力补音频与专业后期**:优先接已有供应商的实际类型;缺供应商时保留禁用说明。验收要求:模型选择改变请求,格式/采样率/语速等参数能在产物或请求中验证,不用参考站商标名称充当服务。
|
||||
4. **补导演台深层功能**:底部多轨、角色骨骼和姿势、目标跟随、路径编辑、全景、识图导入。每项以场景保存重载、时间轴中间帧、导出视频三处共同验收。
|
||||
5. **逐状态做视觉比对**:固定视口与相同节点内容,对空态、选中、展开菜单、错误、生成中、成功、窄屏分别截图;记录位置/大小/颜色/字体差异。当前两张导演台图只能证明存在差异,不能计算可靠“像素还原百分比”。
|
||||
|
||||
### 尚未覆盖的结果层与条件分支
|
||||
|
||||
- 参考站计费生成后的新脚本资产/智能剪辑/拉片完整结果、多模型收费参数分支、人像协议签署后能力、付费角色线路未执行。
|
||||
- 3 秒测试片段不足以启用参考站片段重拍;已确认 4 秒门槛,但其编辑内层仍待用合适素材复核。
|
||||
- 参考站素材目录有分页/持续更新,本轮展开分类与可见卡片,不把首屏当作完整市场目录。
|
||||
- 本地全站刷新中任务恢复、真实切换浏览器后的完成通知、全局撤销/重做、全部模型逐线路成功率,不能用代码审查替代实测。
|
||||
- 旧版脚本与发布表单内部未展开;公开发布、分享权限、协议、声音克隆提交均不属于本轮操作。
|
||||
|
||||
这份记录可作为后续逐项关闭差异的基线;每关闭一项,应在 ID 下补提交号、浏览器证据和真实接口/产物证据,保留未覆盖条件。
|
||||
49
docs/video-canvas-director-chain-2026-09-16.md
Normal file
49
docs/video-canvas-director-chain-2026-09-16.md
Normal file
@ -0,0 +1,49 @@
|
||||
# 视频画布与 3D 导演台串联验收(2026-09-16 至 09-17)
|
||||
|
||||
测试范围:本地隔离工作树、真实登录、真实阿里云模型与现有对象存储。未部署线上,未修改线上模型目录。临时模型目录仅含官方千问 3.8 Max 和万相 `wan2.6-i2v-flash`。
|
||||
|
||||
## 实测发现与修复
|
||||
|
||||
- 旧导演台只有 CSS 场景、计时器和固定示例截图。活动入口替换为延迟加载的 Three.js 导演台:对象变换、机位、真实截图、GLB 导入及场景持久化。
|
||||
- 通用上传拒绝 GLB。增加登录校验的专用接口,限制 25 MiB,验证 GLB 2.0 头部、总长度、JSON 场景和嵌入资源;明确拒绝外部纹理/缓冲区。
|
||||
- 当前七牛存储域名未提供浏览器跨域响应头,GLB 上传成功后仍无法渲染。使用现有登录保护的模型代理,并加入该存储域名的匹配。
|
||||
- 新画布首次携带节点保存时,先写节点再建父记录导致外键失败。改为事务内创建父记录、同步节点和关系、更新画布;已有画布加行锁校验版本。
|
||||
- 主视频编辑器声音开关未传给生成接口,现已传递 `generateAudio`。
|
||||
- 首帧/首尾帧快捷入口自动插入示例图片且未固定模式。改为选择画布真实图片,显式设置模式;缺少首帧/尾帧时禁止提交。
|
||||
- 裁剪尺寸一直显示固定的 1024×576,且覆盖层继承 `pointer-events: none` 导致拖框实际移动节点。恢复裁剪层交互,尺寸随选择区域与素材尺寸变化;未知尺寸时显示区域百分比。
|
||||
- 音轨卡片播放按钮只有占位提示。接入真实 audio 元素、播放/暂停、进度和错误反馈。
|
||||
- 提示词接口补充不含密钥和提示词正文的失败类型/状态码日志,便于区分模型与传输错误。
|
||||
|
||||
## 验收记录
|
||||
|
||||
| 步骤 | 实际结果 |
|
||||
|---|---|
|
||||
| 首次带节点 PUT 保存 | HTTP 200,节点与画布均落库 |
|
||||
| 同版本并发保存 | 一次 200、一次 409,未覆盖较新版本 |
|
||||
| 真实 3D 渲染 | 人偶与上传的橙色立方体 GLB 可见;位置修改生效 |
|
||||
| 多机位截图 | 正面 1280×720 / 40,614 B;侧面 720×1280 / 38,032 B;文件哈希不同 |
|
||||
| 场景持久化 | 数据库保存后完整刷新页面,恢复 2 对象、3 机位、2 截图、9:16 比例及模型 X=1.8/Y=0;GLB 重新可见 |
|
||||
| 千问优化 | 首次页面调用失败,原文保留;重试成功,HTTP 200 / 54 秒,结果应用到视频节点 |
|
||||
| 真实截图引用 | 节点参考缩略图 URL 与导演台截图逐一一致 |
|
||||
| 万相生成 | HTTP 200 / 35.0 秒;1280×720,5.008 秒,708,505 B,H.264 + AAC 双声道 |
|
||||
| 浏览器播放 | `paused=false`、播放时间推进、无视频错误 |
|
||||
| 自动归档 | HTTP 200 / 23.8 秒,换成持久存储地址 |
|
||||
| 剪辑 | 以正确生成视频为输入,1280×720、1.734 秒、H.264 + AAC;HTTP 200 / 32.6 秒 |
|
||||
| 裁剪 | 在剪辑结果上真实拖框,标签和导出均为 638×358,1.734 秒、H.264 + AAC;HTTP 200 / 19.8 秒 |
|
||||
| 音视频分离 | 裁剪结果生成 638×358、1.734 秒无音轨视频,以及 1.719 秒 AAC 双声道 M4A |
|
||||
| 完整刷新恢复 | 全部结果节点和 6 条处理连线恢复;独立音频与无声视频均播放成功、时间推进且无媒体错误 |
|
||||
| 代码验证 | TypeScript 源码检查通过,6 组共 40 条定向回归测试通过 |
|
||||
|
||||
第一轮回归过程中,遗留示例节点与导演台截图重叠导致误选,生成了汽车背景视频。这轮被判为串联失败,不能作为导演台成功证据。清理旧示例后,从修复的新入口重跑,并核对真实参考 URL,再生成上述 1280×720 视频。
|
||||
|
||||
## 能力边界
|
||||
|
||||
AI 识图重建尚未接入,入口禁用并标明“未接通”;不再以计时器假装完成。当前验证的是基础场景与自包含 GLB,不包括复杂压缩模型、骨骼动画、完整素材库和参考站全部界面的像素验收。视频超分、智能去字幕等未配置服务的功能不计为通过。
|
||||
|
||||
本地冷编译和 SSH 数据库连接抖动明显增加了初次加载耗时。测试中恢复连接并重试失败保存后完成后续流程;这些本地耗时不能当作线上性能结论。原提示词优化首次失败没有足够诊断信息,不能断言具体根因。
|
||||
|
||||
## 串联顺序与留档
|
||||
|
||||
实际画布连线为:导演台 → 真实机位截图 → 万相首帧视频 → 剪辑 → 裁剪 → 无声视频 + 独立音轨。每一步均使用上一步真实产物。
|
||||
|
||||
本地 `.qa/video-chain/` 保存了截图、FFprobe 检查结果及实际 MP4/M4A;测试账户、密钥、签名地址、数据库快照不提交 Git。测试结束后禁用临时账户并清理隔离模型目录及临时服务。
|
||||
50
docs/video-canvas-director-motion-2026-09-17.md
Normal file
50
docs/video-canvas-director-motion-2026-09-17.md
Normal file
@ -0,0 +1,50 @@
|
||||
# 3D 导演台镜头轨迹修复与验收(2026-09-17)
|
||||
|
||||
此前导演台只保存静态机位,运镜素材库只向提示词追加文字。静态机位截图生成视频的验收不代表连续镜头轨迹可用。本次补齐时间轴、真实 3D 播放和视频导出,未部署生产环境。
|
||||
|
||||
## 完成的功能
|
||||
|
||||
- 场景增加 `motion` 数据:2–15 秒、最多 100 个关键帧、相机位置、注视点、FOV、线性/缓入缓出插值和循环设置。兼容没有轨迹的旧场景;轨迹随原有画布保存链路持久化,不需要数据库迁移。
|
||||
- 支持记录、覆盖、编辑、删除关键帧及修改时间;重复时间和无效数据拦截。调整时长按比例缩放关键帧时间。
|
||||
- 推进、拉远、90° 环绕、横移预设;黄色轨迹与关键帧标记、完整轨迹总览。
|
||||
- 播放、暂停、循环、时间轴定位;预览时关闭手动相机操控。进入编辑时从当前预览机位继续,避免跳回旧机位。页面进入后台时暂停预览。
|
||||
- 导入/导出带版本号的 JSON 轨迹文件;文件限制 256 KiB,坏文件不会覆盖现有轨迹。
|
||||
- 按真实相机轨迹录制无声 MP4,隐藏网格、辅助轴和路径,支持取消;浏览器不支持 MP4 编码时明确提示更新浏览器。切到后台、关闭导演台、渲染错误或超时会停止录制并释放媒体轨道,不把部分文件当成功结果。
|
||||
- 导出结果可预览、下载、上传为真实视频节点;保存实际时长,供剪辑使用。视频上传使用现有 `canvas-video-process` 用途参数。
|
||||
- “发送首帧与运镜到视频节点”真实渲染 0 秒机位,建立导演台→首帧→视频节点连线,携带完整轨迹元数据及精简的自然语言运镜描述。模型、尺寸等继续由视频节点选择;不自动发起计费生成。
|
||||
|
||||
## 实测中修复的回归
|
||||
|
||||
- 使用整个关键帧覆盖机位会把关键帧 ID 写成相机 ID,导致后续 FOV 编辑失效。现在仅合并 position、target、fov,保留相机身份,并有专门回归测试。
|
||||
- WebM 原始录制缺失 duration,影响剪辑;改为 MP4。实际产物为 H.264,包含可读取的时长。
|
||||
- 预览/录制时 OrbitControls 仍可能约束相机距离;改为显式设置相机朝向,录制期间锁定交互,避免改变轨迹。
|
||||
- 从预览切换到录制时,React 更新可能重新显示轨迹辅助线;加入录制锁,确保辅助线不进入导出画面。
|
||||
|
||||
## 验收证据
|
||||
|
||||
浏览器使用生产组件进行本地独立验收;测试页仅将画布保存与对象存储替换成本地适配器,不伪造 WebGL、时间轴或编码结果。本轮没有调用收费 AI 模型,也未重新验证生产数据库/对象存储的整条链路;原画布持久化和真实万相链路见 2026-09-16 验收记录。
|
||||
|
||||
| 检查 | 结果 |
|
||||
|---|---|
|
||||
| 环绕、推进与时间轴 | 浏览器真实播放,循环后仍持续播放;暂停及定位到终点有效 |
|
||||
| 轨迹总览 | 相机切换到轨迹包围盒总览视角 |
|
||||
| 自定义 JSON 导入 | 3 秒、2 个关键帧,位置与 FOV 参数生效 |
|
||||
| 坏文件导入 | 显示无效数据提示,保留原轨迹 |
|
||||
| 暂停时选择场景对象 | 预览不跳回编辑机位;场景区域像素对比无变化 |
|
||||
| 关键帧更新 | 第二帧 FOV 改为 65° 并记录,完整刷新后恢复 |
|
||||
| 首帧传递 | 从 0 秒机位生成 1280×720 PNG,回调收到首帧及 2 帧轨迹 |
|
||||
| 横屏 MP4 | 1280×720,H.264,150 帧,4.998 秒,无音轨 |
|
||||
| 播放中切换导出 | 1280×720,90 帧,3.0005 秒;从轨迹起点完整导出,画面无辅助线 |
|
||||
| 竖屏 MP4 | 720×1280,H.264,90 帧,3.002 秒,无音轨 |
|
||||
| 逐帧检查 | 对比初始、中间、结尾帧,视角连续变化,非静态图片录制 |
|
||||
| 取消导出 | 浏览器中取消后恢复按钮与编辑状态,未出现成功产物 |
|
||||
| 录制生命周期 | 自动化验证正常结束、取消、后台事件及渲染失败均释放轨道与帧回调 |
|
||||
| 代码验证 | TypeScript 源码检查通过;49 条定向回归测试通过 |
|
||||
|
||||
浏览器自动化的多标签切换未可靠触发页面可见性事件,因此后台处理依靠事件级回归验证,不将该次标签切换算作真实后台场景通过。下载链接已接入原生下载;本轮媒体文件通过组件上传回调留档,并用 FFprobe 核验。
|
||||
|
||||
本地 `.qa/director-motion/` 留存测试 MP4、逐帧截图与探测结果,不提交生成文件和本地测试适配器。
|
||||
|
||||
## AI 生成与真实轨迹的边界
|
||||
|
||||
真实 3D 导出使用配置的轨迹。当前阿里云首帧模型接入接收首帧与运镜文字,不接收可精确执行的相机轨迹参数,因此 AI 生成仍可能偏离预设,界面已明确说明。需要精确复现时使用“导出真实运镜视频”。AI 识图重建仍是独立的未接通能力,不属于本次运镜修复。
|
||||
38
docs/video-canvas-followup-2026-09-16.md
Normal file
38
docs/video-canvas-followup-2026-09-16.md
Normal file
@ -0,0 +1,38 @@
|
||||
# 视频画布第三轮完善与回归(2026-09-16)
|
||||
|
||||
接续 [上一轮记录](video-canvas-completion-2026-09-16.md)。继续在独立本地工作树测试,未部署生产,未更改官方千问 3.8 默认模型。
|
||||
|
||||
后续按用户要求测试阿里云,见 [万相真实测试记录](video-canvas-aliyun-test-2026-09-16.md)。
|
||||
|
||||
## 逐项完成
|
||||
|
||||
| 功能 | 查明的问题 | 本轮修复与验证 |
|
||||
|---|---|---|
|
||||
| 故事脚本 | 系统强制 18 镜头,不足时再复制内容扩充;用户要求 3 镜头也被扩大 | 删除自动补镜头;优先遵守用户镜头数、总时长;未指定时默认 6 镜头、最多 18。空行不再变成虚构镜头 |
|
||||
| 参考图检查 | 同一张图作为首尾帧被去重;网络波动和链接失效都报 400 | 探测去重但保持输入位置;无效输入明确拒绝;临时连接故障报 503、失效或无权限报 400;HEAD 不成功时继续 GET 检查并释放响应体 |
|
||||
| 视频长期保存 | 画布直接保留服务商临时链接,没有进入聊天消息的归档任务 | 视频先显示,然后后台转存至现有存储,成功后自动更新画布 URL;读取视频文件并确认视频流后才上传 |
|
||||
| 转存失败恢复 | 缺少单独重试,只能重新生成 | 保留原视频及临时状态,提供“转存视频”;保存操作不调用生成模型。请求去重、并发限制、公共 IP 校验、125 MiB 下载限制沿用或补齐 |
|
||||
| 视频时长显示 | 上传与剪辑保存 durationSeconds,但播放器和剪辑栏未读取,常回退 5 秒 | 优先使用实际文件时长,短片允许不足 1 秒的真实时长 |
|
||||
|
||||
自动转存由打开的画布触发。关闭页面前尚未完成的转存,会在重新打开、恢复临时状态后再尝试;这不是新增服务器持久化生成队列,不能保证用户离开后所有生成任务继续交付。已归档的节点使用存储地址,不依赖原服务商链接。
|
||||
|
||||
## 真实测试
|
||||
|
||||
| 测试 | 结果 | 证据 |
|
||||
|---|---|---|
|
||||
| 官方千问 3.8 三镜头脚本 | **通过,71.853 秒** | 返回且只返回 3 行,时长 3s / 2s / 3s,总计 8 秒,分别为外观、杯柄、咖啡液面;未虚构人物 |
|
||||
| 已生成 Veo 视频转存 | **通过,20.233 秒** | 七牛存储新地址可下载;ffprobe 为 H264 + AAC、1280×720、8 秒、1,005,090 字节,与转存前一致 |
|
||||
| 首帧图生视频复测 | **未通过,193.066 秒** | 参考图通过检查并提交生成;查询结果时上游返回 502。任务 `8aa87fb0-ef63-4f38-8930-0b1796331be4` |
|
||||
| 页面自动转存、播放与刷新恢复 | **通过** | 使用上一轮真实成片作为测试节点,页面出现正在转存状态,完成后 video.currentSrc 从 api.vip98k.com 切至 scqny.jiansun.vip;8 秒时长、播放器完整播放至 8 秒;画布保存后刷新仍使用七牛地址,readyState 为 4;本地冷编译和数据库隧道使该次转存约 2.2 分钟,不当作生产耗时 |
|
||||
| 同一任务从服务器只读复查 | **上游确认失败** | HTTP 409,`task_failed` / `Video generation failed`,请求 ID `852b2e2e-d288-47ec-8a94-9ed272f4070e`;未重复提交生成 |
|
||||
| 定向回归 | **65 项通过** | 7 项新契约 + 58 项已有回归;覆盖镜头不扩充、空行、首尾帧重复 URL、无效输入、暂时网络失败、归档成功与保留失败产物 |
|
||||
|
||||
浏览器回归使用专用账号和写入正确画布节点格式的测试素材,素材来自上一轮真实 Veo 成片。该测试用于验证自动转存、状态持久化和刷新后媒体读取,不冒充本轮新生成。原始证据保存在 `.qa/video-followup/`,带临时签名的地址不提交 Git。
|
||||
|
||||
## 尚未完成
|
||||
|
||||
首帧/首尾帧/多参考视频不能因文生视频成功而全部验收;本轮首帧已拿到明确上游失败状态。音频驱动视频、视频超清、去字幕、专业人声分离、完整 AutoLink 和智能剪辑等继续保持待完成状态。没有宣称整体像素级复刻完成。
|
||||
|
||||
另发现独立问题:对尚不存在的画布 ID 首次 PUT 且携带节点时,接口先写 CanvasNode、后创建 CanvasProject,触发外键约束失败;标准 POST 新建流程不受此特定顺序问题影响。该问题尚未在本轮修改,需另行修复并验证并发创建行为。
|
||||
|
||||
源码 TypeScript 检查通过;专用测试账号已禁用并撤销会话,临时凭据已删除,本轮开发服务和数据库隧道已停止;开发工具生成的配置改动不提交。
|
||||
@ -68,3 +68,12 @@
|
||||
- 复用现有阿里云官方线路 `https://dashscope.aliyuncs.com/compatible-mode/v1`;只读查询 `/models` 返回 200,目录包含 `qwen3.8-max`,未调用推理或生成接口。
|
||||
- 新增并启用模型 `4b412065-fa6c-475c-a3d9-2bc4239b9601`;更新 `defaultMultimodalModel` 及 `element-mark`、`video-prompt-optimize`、`video-prompt-translate` 三项覆盖。数据库回读一致,配置直接生效,无需重建或重启服务。
|
||||
- 原配置已备份到服务器 `backups/video-canvas-qwen38-1789553368137.json`。已打开的画布需要重新打开才能读取新配置;节点手动指定的模型保留用户选择。
|
||||
|
||||
|
||||
开发分支补充记录:
|
||||
|
||||
> 2026-09-17 深层复核更新:请以 [231 项逐层对照记录](video-canvas-deep-parity-2026-09-17.md) 为当前差异基线。本文的“完成”只限当时列出的子范围;音频、脚本资产、历史管理等仍有活动占位交互,不能据此认定整套功能已复刻。
|
||||
> 后续第三轮进展见 [脚本、参考图与视频转存回归](video-canvas-followup-2026-09-16.md)。
|
||||
> 后续进展见 [继续完善与复测记录](video-canvas-completion-2026-09-16.md):官方千问 3.8 提示词处理、Veo 文生视频、完整媒体处理 API 已出现真实成功结果。下文保留上一轮当时的测试状态。
|
||||
参考:[LibTV 画布](https://www.liblib.tv/canvas)。以登录后的 1280×720 画布为界面对照基准。本记录对应此前“模型接通即可,不继续真实生成测试”的阶段;用户随后已要求恢复真实测试,最新结果见 [真实测试与参考站对照](video-canvas-real-test-2026-09-16.md)。因此下表区分接口与参数核验、界面核验、实际生成,不能把未生成的功能标为产物验收通过,也不把局部对齐称为整站像素级复刻完成。
|
||||
| 视频分析 / 提示词优化翻译 | 分析模型配置已替换;后续实测发现视频提示词优化/翻译按钮仍仅提示待接入,不能视为功能已接通 | 见最新真实测试记录 |
|
||||
|
||||
111
docs/video-canvas-real-test-2026-09-16.md
Normal file
111
docs/video-canvas-real-test-2026-09-16.md
Normal file
@ -0,0 +1,111 @@
|
||||
# 视频画布真实测试与 LibTV 对照(2026-09-16)
|
||||
|
||||
> 后续进展见 [继续完善与复测记录](video-canvas-completion-2026-09-16.md):官方千问 3.8 提示词处理、Veo 文生视频、完整媒体处理 API 已出现真实成功结果。下文保留上一轮当时的测试状态。
|
||||
|
||||
本轮按用户最新指令恢复真实调用。在独立本地工作树、127.0.0.1:3016、两个专用测试账号下运行真实接口,未启用 CANVAS_E2E_LOCAL 模拟模式。使用现有数据库配置及服务商线路;未改真实用户画布。参考站为 https://www.liblib.tv/canvas,浏览器统一为 1280×720。
|
||||
|
||||
**结论:有视频模型,但本轮尚不能验收视频生成成功。图片生成和本地视频处理通过;保存缺陷已本地修复;仍有未接入入口。不能把模型目录可见等同于可生成,也不能把当前页面称为完整像素级复刻。**
|
||||
|
||||
## 真实调用结果
|
||||
|
||||
| 测试 | 结果 | 证据 / 限制 |
|
||||
|---|---|---|
|
||||
| VIP98K 当前目录 + 当前 Key 授权模型 | 两份目录均 30 个模型,视频为 `veo-3.1-fast` | 当前可用目录只有这一条视频模型;不代表服务商历史或其他账号没有其他模型 |
|
||||
| 文生视频,8 秒 / 720P | 失败,32.185 秒 | 任务受理后上游结果 HTTP 409 / Video generation failed;本站返回 500 |
|
||||
| 首帧图生视频,同参数 | 失败,23.872 秒 | 上游结果 HTTP 409 / Video generation failed |
|
||||
| 首尾帧视频,2 张图 | 失败,10.148 秒 | 上游 HTTP 503 / media service temporarily unavailable |
|
||||
| 多参考视频,2 张图 | 服务端记录失败 | 上游 HTTP 503;测试客户端随后中断,没有完整客户端耗时记录,不算通过 |
|
||||
| 服务商原始示例直接调用对照 | 失败,约 9.3 秒 | POST 202,第一次轮询 202,第二次 409,明确 `task_failed`。证明本次失败可绕过本站复现,并非仅本站参数或轮询问题 |
|
||||
| 官方 Qwen3.8 Max 文本脚本 | 失败,8.940 秒 | Access denied / account in good standing / overdue-payment |
|
||||
| 官方 Qwen3.8 Max 图片理解 | 失败,9.390 秒 | 同上,官方账户状态阻塞;未擅自切回旧模型 |
|
||||
| GPT Image 2.5 参考图产品三视图 | **通过,70.108 秒** | 本站真实图片 API 返回新图片,红杯三视角合成图;输入为人工合成测试图。此项只覆盖同链路样本,不代表所有图片预设逐个验收 |
|
||||
| 原有 Veo 成片读取 | 通过 | 只读取之前成功的任务,不计作本轮生成成功;H264 1280×720、AAC、8 秒 |
|
||||
| 成片画面裁剪(实际处理函数) | **通过,479 ms** | 640×360、8 秒、H264 + AAC,新文件 897501 字节 |
|
||||
| 成片音轨提取(实际处理函数) | **通过,81 ms** | 8 秒 AAC,197583 字节 |
|
||||
| 成片去音轨(实际处理函数) | **通过,39 ms** | 1280×720、8 秒,只有视频流,1526056 字节 |
|
||||
| 裁剪 / 拆轨完整 HTTP API | 本地环境阻塞 | 本机代理把服务商域名解析为 198.18.1.165,安全下载器拒绝该保留网段。未放宽 SSRF 防护。上述处理通过不等于完整下载/上传 API 验收通过 |
|
||||
|
||||
直接对照任务 ID:`d3b6199d-df42-479b-bc5f-8c797f61659c`;请求 ID:`68a19660-123d-474b-aa48-cd4c6e51bed3`。可提交给 VIP98K 排查 `veo-3.1-fast` 线路。没有对失败是否扣费作假定。
|
||||
|
||||
## 与参考站逐项比较
|
||||
|
||||
| 项目 | LibTV 当前界面 | 本站本轮核验 | 结论 |
|
||||
|---|---|---|---|
|
||||
| 视频模型菜单 | 多个 Seedance、Kling、Wan、Hailuo、Vidu 等模型 | 只显示当前真正授权且兼容的 Veo,以及自动选择 | 匹配正确,模型覆盖范围不同 |
|
||||
| 参数 | 当前 Seedance 2.0 界面有多比例、480P/720P/1080P/4K、可调时长和音频开关 | Veo 为 16:9、720P/1080P、8 秒,菜单浏览器核验通过 | 不能复制不受当前线路支持的选项;参考菜单可见不代表每种组合都已验证 |
|
||||
| 文生 / 首帧 / 首尾帧 / 多参考 | 有相应模式,参考图数量影响可选项 | 四类请求均实测,当前上游失败 | 接口已接,产物未通过 |
|
||||
| 音频生视频 | 有入口和对应专用模型 | 当前无已启用可用音频驱动视频线路,部分入口仍待接入 | 未完成 |
|
||||
| 提示词优化 / 翻译 | 有操作入口 | 当前视频编辑器点击仅显示“后端待接入”;配置中的模型不能使入口自动可用 | 未完成,需接真实处理接口 |
|
||||
| 联网搜索 / 素材校验 / AutoLink | 三项开关可见 | 本站有前两项开关;未验证其真实执行,AutoLink 不齐全 | 不计作功能完成 |
|
||||
| 智能剪辑 / 逐帧拉片 / 导演台 | 智能剪辑、逐帧拉片 SD2.5、导演台入口 | 本站视频合成及导演台存在;逐帧拉片未完整对应 | 未完成全量对齐 |
|
||||
| 时段剪辑 | 参考有剪辑工具 | 本站记录时间范围后仍提示导出后端待接入 | 未完成;与已通过的“画面裁剪”不同 |
|
||||
| 视频超清 / 去字幕 / 人声背景音分离 | 有对应入口 | 无确认可用专用线路;普通拆音轨不能替代人声分离 | 未完成 |
|
||||
| 画布保存 | 参考可持久化工作区 | 本站原 saveCanvas 未被调用,刷新实际丢节点;本轮补自动保存、手动保存、⌘/Ctrl+S | 本地修复并通过浏览器刷新验证 |
|
||||
| 文本草稿、标题、缩放 | 应在继续编辑时保留 | 文本内容、未提交草稿、标题、50% 缩放均刷新恢复 | 本地通过 |
|
||||
| 保存冲突 | 本轮未修改参考站冲突状态 | 串行合并更新;409 停止覆盖并提示,网络错误保留脏数据供重试 | 4 项保存队列测试通过 |
|
||||
| 顶部栏稳定性 | 顶栏固定在视口 | 本站节点编辑导致原生容器滚动、顶栏移出视口;改为固定画布和 overflow-clip | 本地修复 |
|
||||
| 视觉一致性 | 深色画布、底部工具栏、节点浮层 | 布局相似,但品牌/顶部导航、底纹、图标、功能覆盖仍不同 | 局部对齐,非像素级完成 |
|
||||
|
||||
参考账号显示 20 积分,所选视频生成界面显示 135;未充值或购买,也未在参考站提交生成。因此没有“双站同提示词成片效果”的 A/B 结果。
|
||||
|
||||
## 本轮修复与验证
|
||||
|
||||
- 自动保存仅在项目成功加载后启用;只有确认 404 才初始化空画布,加载失败不能覆盖原记录。
|
||||
- 画布标题、节点、连线、选择和视口统一保存,恢复时不再只恢复节点。
|
||||
- 文本编辑器草稿和模型选择及时写入节点;同项目的服务器刷新不重新覆盖本地编辑。
|
||||
- 保存采用串行队列,保存期间的新修改合并为下一次写入;失败保留状态,409 不拿最新版本号强行覆盖。
|
||||
- 保存状态可见;未保存退出时提示;切至后台尝试保存。
|
||||
- VIP98K 日志增加受理任务 ID 和失败结果状态,便于区分排队、上游失败、前端问题。
|
||||
- 12 项模型/参数/媒体安全测试 + 4 项保存队列测试 + 36 项 VIP98K 相关回归,共 52 项通过;密钥检查通过。
|
||||
- 源码 TypeScript 检查通过。Next 开发目录生成的 routes.d.ts/validator.ts 有损坏片段,单独源码检查排除了临时构建目录;未把生成文件错误隐瞒为完整生产构建通过。
|
||||
- 浏览器真实数据库保存和刷新恢复通过;本轮修复尚未部署生产。
|
||||
|
||||
## 证据位置
|
||||
|
||||
本地 `.qa/video-retest/`:`result-*.json` 为真实 API 结果;`provider-control-*.json` 为直接上游对照;`generated-three-view.png` 为本次真实图片产物;`local-processing-results.json` 及 `local-{crop,silent}.mp4`、`local-audio.m4a` 为处理验证;`save-*-refresh.txt` / `draft-after-refresh.txt` 为保存恢复记录;`libtv-video-parameters.png` 与 `local-video-parameters.png` 为同视口截图。原始结果可能含临时签名媒体地址,未提交到 Git。
|
||||
|
||||
下一步阻塞明确:VIP98K 修复/提供可用视频线路;阿里云账户恢复推理权限;补齐未接入的工具,再逐项实测。未经真实产物验证的功能仍保持未验收状态。
|
||||
|
||||
## 配置功能逐项验收台账
|
||||
|
||||
“未单独实测”不能视为通过;已测试同链路不自动覆盖其他提示词或专用接口。
|
||||
|
||||
| 功能 | 配置类型 / 路由 | 本轮状态 |
|
||||
|---|---|---|
|
||||
| 角色脸部三视图 (`character-face-three-view`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 角色设定图 (`character-design-sheet`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 场景设定图 (`scene-design-sheet`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 产品设定图 (`product-design-sheet`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 剧情推演四宫格 (`story-beat-grid-4`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 角色三视图生成 (`character-three-view`) | image-edit / canvas-generate-image | 同图片 API 产品三视图样本通过;该角色预设未单独生成 |
|
||||
| 多机位九宫格 (`multi-view-nine-grid`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 25宫格连贯分镜 (`continuous-storyboard-grid-25`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 电影级光影校正 (`cinematic-lighting-correction`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 画面推演 - 3秒后 (`frame-prediction-plus-3s`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 画面推演 - 5秒前 (`frame-prediction-minus-5s`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 宫格切分 (`grid-split`) | utility / local | 未单独实测,不能标为效果验收通过 |
|
||||
| 打光 (`lighting`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 高清 (`hd-upscale`) | utility / enhance | 未单独实测,不能标为效果验收通过 |
|
||||
| 720 全景 (`panorama-720`) | image-edit / extend | 未单独实测,不能标为效果验收通过 |
|
||||
| 扩图 (`image-outpaint`) | utility / extend | 未单独实测,不能标为效果验收通过 |
|
||||
| 重绘 (`image-repaint`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 擦除 (`image-erase`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 抠图 (`remove-bg`) | utility / remove-bg | 未单独实测,不能标为效果验收通过 |
|
||||
| 裁剪 (`image-crop`) | utility / local | 未单独实测,不能标为效果验收通过 |
|
||||
| 多角度编辑器 (`angle-edit`) | image-edit / angle-edit | 未单独实测,不能标为效果验收通过 |
|
||||
| 故事脚本生成 (`story-script`) | multimodal / canvas-workflow-analyze | 真实调用失败:官方 Qwen3.8 账户状态 |
|
||||
| 视频解析 (`video-analysis`) | multimodal / canvas-workflow-analyze | 分析默认模型被账户状态拦截;视频逐帧入口未单独实测 |
|
||||
| 图片反推提示词 (`image-to-prompt`) | multimodal / canvas-workflow-analyze | 同视觉理解链路被账户状态拦截;该预设未单独实测 |
|
||||
| 视频元素标记 (`element-mark`) | multimodal / pick-object | 未单独实测,不能标为效果验收通过 |
|
||||
| 文生视频 (`text-to-video`) | video / canvas-generate-video | 真实调用失败:上游 409 |
|
||||
| 首帧图生视频 (`first-frame-video`) | video / canvas-generate-video | 真实调用失败:上游 409 |
|
||||
| 首尾帧生成视频 (`first-last-frame-video`) | video / canvas-generate-video | 真实调用失败:上游 503 |
|
||||
| 音频生视频 (`audio-to-video`) | video / canvas-generate-video | 无确认可用线路,入口仍有待接入分支 |
|
||||
| 导演台 AI 识图导入 (`director-ai-import`) | multimodal / director-ai-import | 未单独实测,不能标为效果验收通过 |
|
||||
| 导演台 AI 全景图 (`director-panorama-ai`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 智能去字幕 (`video-subtitle-erase`) | utility / local | 无确认可用专用处理线路,不算完成 |
|
||||
| 视频提示词优化 (`video-prompt-optimize`) | utility / canvas-prompt-tools | 视频节点按钮未接后端 |
|
||||
| 视频音频分离 (`video-audio-separation`) | audio / local | 实际处理函数:提取音轨/静音通过;完整 API 受本机 DNS 阻塞;人声/背景音分离未接通 |
|
||||
| 视频提示词翻译 (`video-prompt-translate`) | utility / canvas-prompt-tools | 视频节点按钮未接后端 |
|
||||
|
||||
测试收尾:两个专用账号均已停用并清除活跃会话,测试环境凭据已删除,本地开发服务及数据库隧道已关闭。参考站本轮新增空视频节点已删除,原有两张图片节点保留,缩放恢复 100%。
|
||||
59
docs/video-canvas-repairs-2026-09-17.md
Normal file
59
docs/video-canvas-repairs-2026-09-17.md
Normal file
@ -0,0 +1,59 @@
|
||||
# 视频画布修复记录 — 2026-09-17
|
||||
|
||||
后续全面验证扫描见 [2026-09-17 扫描报告](video-canvas-validation-scan-2026-09-17.md):仍存在任务恢复、取消等未完成项,不能以本文作为全功能验收通过证明。
|
||||
|
||||
基于 `e5b2de3c` 的 231 项对照检查继续修复。本文记录已修改的具体行为和验证边界;**不表示 231 项全部完成,也不表示与 LibTV 像素级一致**。代码在 `codex/video-canvas-parity-0916`;本轮没有部署、重启 48 服务器或修改生产数据库。
|
||||
|
||||
## 生成与参数
|
||||
|
||||
| 对照 ID | 修复 | 验证 |
|
||||
|---|---|---|
|
||||
| 03-02、03-06、03-33 | 主图片生成传递所选清晰度;展示每张返回图片;空参考不再注入人物示例 | 请求契约单测覆盖 1K/2K/4K、数量、空参考、多张结果;不表示各模型都支持全部规格 |
|
||||
| 03-22 | 禁用的宫格功能在创建结果前拦截,不再把示例图标记成功 | 代码检查 |
|
||||
| 04-14 | 视频 1/2/4 个对应逐个请求及独立结果节点,单项失败保留其余成功结果;重复点击拦截 | 类型与执行链检查;未付费调用 4 个视频进行验收 |
|
||||
| 02-09、02-10 | 移除未执行的联网搜索、合规素材校验开关;明确保留基础输入校验 | 代码检查 |
|
||||
| 03-12、03-13、03-32 | 多角度预设更新并提交参数;环绕角转换到 0–360、缩放 0–100 转 0–10;鱼眼和倾斜通过配置的图片模型重绘 | 单测;浏览器选择背面实际提交 180°/0°/50%;未调用模型评估角度效果 |
|
||||
| 03-14 | 明确中央图片是参考原图;去掉无执行作用的提示词开关和固定价格 | 代码检查;不宣称实时生成预览 |
|
||||
| 06-19 | 分镜视频使用配置模型及镜头时长、真实参考;未提交时显示待生成,移除 Seedance 写死与示例封面 | 代码检查;仍是创建生成器节点,需用户提交生成 |
|
||||
|
||||
## 脚本工作流
|
||||
|
||||
- `06-04/05/07`:表格可编辑、添加、删除和上移镜头,清空内容不会立即被默认值填回;采用画布现有保存流程。
|
||||
- `06-08/09/10/11/13`:不再注入唐朝示例角色;从脚本引用识别候选资产,类型可人工修正;支持新增、上传、引用当前画布、清除、删除、调用配置图片模型生成。
|
||||
- `06-14/15/16/17`:完成数量按真实图片和提示词计算;全局风格可编辑并参与合成;单镜和批量合成调用现有分析服务,验证镜头数量与输出字段,失败保留原内容;去掉固定第三方模型和价格。
|
||||
- `06-18`:分镜图片节点按 `@资产名` 携带准备好的参考图片;按钮文案区分创建节点与已生成图片。
|
||||
- 浏览器组件验收:修改对白、新增镜头、引用资产、修改风格,刷新后恢复;空提示词仍显示 `0/2`,资产引用后显示 `1/3`。验收页采用 localStorage 保存适配器,**不是本轮数据库端到端验收**。AI 合成、资产 AI 生成与上传仅完成执行链检查,本轮没有重复付费请求。
|
||||
- 尚未实现拖动排序、资产逐项模型/比例/数量选择、完整自动批量生成闭环。
|
||||
|
||||
## 素材、下载和音频
|
||||
|
||||
- `10-09/10/11/12`:面板明确范围为“当前画布素材”,只列有真实文件地址的节点,不补示例,不截断为 8/10 条,去掉虚构日期,按节点添加顺序排序。空列表已在浏览器验证。
|
||||
- `10-13`:单项、预览和批量下载调用已有下载/ZIP 接口,不再用 15 秒动画代替下载;显示 ZIP 部分失败;音频下载保留原文件,不交给图片解码器。跨域文件经现有认证下载接口处理。单测验证 WAV 字节与扩展名。
|
||||
- `10-14`:动作明确为从素材列表移除,状态随画布保存;不冒充删除源文件或跨项目历史记录。
|
||||
- `10-18`:原空资产页改为当前画布媒体列表,搜索、类型过滤、定位和下载有实际执行;工作区显示项目标题。个人跨画布资产库和 Agent 资产仍未实现。
|
||||
- `07-01…18`:未找到已实现的 TTS/音乐后端适配器,移除固定模型、假音色、空生成按钮与假调节控件。提供 `/api/canvas/media/upload` 的真实音频上传入口,保存时长,继续使用原有播放器。**这是纠正不可用入口,不是接通配音/音乐生成**。
|
||||
- `01-26/10-25`:快捷键说明只显示已绑定操作,不再列出不存在的撤销、分组等快捷键。
|
||||
|
||||
## 视频后期
|
||||
|
||||
- `05-15/16`:纠正上一轮对截帧的判断。旧活动处理函数实际复制封面,并硬写 1280×720;现在使用 FFmpeg 按首帧、尾帧、播放器当前时间截取真实 PNG,并探测实际输出尺寸。
|
||||
- `05-06`:新增 2/3/5 倍慢放,视频时间戳与音轨同步变速;保留音高,输出最长 10 分钟。属于普通变速,**不是 AI 补帧**。
|
||||
- `05-04/09/10/12/13`:超分、去字幕、人声/背景音分离未接专用服务,在活动工具栏明确不可用;仍保留真实音视频分离。
|
||||
- 真实本地 FFmpeg 验证:320×180、24fps、2 秒合成视频(含音轨),首帧/1 秒帧/尾帧输出不同;2/3/5 倍慢放分别约 **4.013 / 6.005 / 9.990 秒**,三份都保留音轨。集成测试用临时目录并自动清理。
|
||||
|
||||
## 3D 导演台
|
||||
|
||||
- `08-03/04/05/07/08/09`:对象搜索、改名、复制、显隐、锁定变换轴、等比缩放、基础对象颜色;旋转输入改为度,保存仍用弧度。
|
||||
- `08-11/13`:基础人偶改为连接肩肘髋膝的关节层级,支持 12 种姿势:站立、T 型、行走、跑步、坐姿、蹲下、招手、伸手、鞠躬、格斗、踢球、看手机。**不是参考站完整的 20 姿势、8 体型或专业骨骼编辑器**。
|
||||
- `08-15`:已加载对象只在资源、几何或外观变化时重建;单纯位置/旋转/缩放不再销毁并重新下载全部 GLB。
|
||||
- `09-01/06/07/09`:扩展至 12 个机位预设;可对准所选对象;FOV 保留两位小数;相机时间轴移至底部可折叠区域。对准是一次性定位,不是自动跟随。
|
||||
- 浏览器实际显示 T 型、招手姿势,创建正面机位与环绕轨迹,导出并通过本地上传适配器接收 **1280×720、约 2.997 秒 MP4(VP9)**。旧场景与新增姿势/显隐等数据 JSON 往返单测通过。
|
||||
- 对象多轨、自动帧、目标跟随、自由路径、全景和 AI 识图重建仍未实现。
|
||||
|
||||
## 验证与未完成范围
|
||||
|
||||
- TypeScript:`pnpm exec tsc --noEmit --pretty false` 通过。
|
||||
- 5 组相关回归:**50 项通过,无跳过**,包括新加入的本地真实 FFmpeg 测试。
|
||||
- 浏览器:脚本编辑与恢复、资产引用计数、空素材列表、多角度预设提交、关节人偶渲染、相机轨迹导出。
|
||||
- 本轮使用生成参数契约和组件验收,没有重复测试全部供应商;不宣称新模型线路成功。
|
||||
- 尚需继续:生成中任务断线/刷新恢复、全画布撤销分组、多画布/故事板/Agent、跨项目资产、完整智能剪辑、专用后期服务、3D 高级轨迹和逐状态像素验收。底层未接通能力不能只靠复制页面宣称完成。
|
||||
70
docs/video-canvas-smart-edit-research-2026-09-17.md
Normal file
70
docs/video-canvas-smart-edit-research-2026-09-17.md
Normal file
@ -0,0 +1,70 @@
|
||||
# 智能剪辑:参考站实测与开源算法选型
|
||||
|
||||
检查日期:2026-09-17。本文区分实际观察、开源代码事实、建议方案与尚未实现的功能。
|
||||
|
||||
## 参考站已观察
|
||||
|
||||
[LibTV 画布](https://www.liblib.tv/canvas) 的智能剪辑入口有讲解视频、批量广告、口播视频、素材混剪四种预设。素材混剪预设连接七个素材节点,提示词包含卡点和突出产品,参数显示 16:9、1080P、20 秒。打开编辑器后,示例时间轴约 14 秒,含七个片段;这只是预设示例,不能当成自动生成成功的结果。没有点击收费生成。
|
||||
|
||||
编辑器可见分割、向左/右裁剪、字幕轨道、转场、吸附、缩放、原声静音、全屏预览,导出到本地或画布。分割/裁剪会检查播放头位置与最短片段长度。界面没有公开它实际使用的后台选片或卡点算法,不能推断它使用了以下任何项目。
|
||||
|
||||
## 已阅读的开源项目
|
||||
|
||||
| 项目 | 可复用能力及实现 | 适用场景 | 许可证/接入边界 |
|
||||
| --- | --- | --- | --- |
|
||||
| [FunClip](https://github.com/modelscope/FunClip) | FunASR 生成转写与时间戳;把选定文字、说话人或 LLM 给出的区间映射回视频;裁剪后重排字幕时间 | 中文口播、讲解、访谈精选 | [源码 MIT](https://github.com/modelscope/FunClip/blob/main/LICENSE),模型权重单独授权。Python/ASR 服务不能直接放进 Next 请求进程 |
|
||||
| [Auto-Editor](https://github.com/WyattBlue/auto-editor) | 按音量或运动量标记保留区间,可组合条件,前后 margin 保留呼吸和语句边界 | 去停顿、去静音粗剪 | [Unlicense](https://github.com/WyattBlue/auto-editor/blob/master/LICENSE);低音量不是无价值内容,不能直接当产品亮点评分 |
|
||||
| [PySceneDetect](https://github.com/Breakthrough/PySceneDetect) | ContentDetector 计算相邻帧变化;AdaptiveDetector 将目标帧分数与周围帧均值比较,同时检查最小内容变化和最短镜头 | 多素材拆镜头、混剪候选片段 | [BSD-3-Clause](https://github.com/Breakthrough/PySceneDetect/blob/main/LICENSE);负责边界检测,不负责语义或商品识别 |
|
||||
| [librosa](https://github.com/librosa/librosa) | onset strength → 节奏估计 → 动态规划选节拍;综合局部强度与节拍间隔一致性 | 背景音乐卡点 | [ISC](https://github.com/librosa/librosa/blob/main/LICENSE.md);弱节奏/静音可能无有效节拍,必须显式回退 |
|
||||
| [FFmpeg](https://ffmpeg.org/ffmpeg-filters.html) | trim/atrim、setpts/asetpts、concat、xfade/acrossfade、字幕渲染、响度处理 | 最终确定性合成 | 使用部署环境既有构建;具体许可依构建组件,不把所有构建概括成同一许可 |
|
||||
|
||||
阅读位置:FunClip `funclip/videoclipper.py` 的 `recog`、`clip`、`video_clip`;PySceneDetect `scenedetect/detectors/adaptive_detector.py` 的 `process_frame`;librosa `librosa/beat.py` 的 `__beat_tracker`、`__beat_track_dp`。这些代码已下载到本地研究目录查看,没有安装来源不明的脚本或启动重模型服务。
|
||||
|
||||
FunClip 的关键点是可靠的时间戳映射,而不是让语言模型随意猜视频秒数。它既接收文字匹配,也接收显式时间区间,再重算片段字幕。不能把“ASR 文字剪辑”直接等同于“无对白产品素材混剪”。
|
||||
|
||||
PySceneDetect 的自适应比值为目标变化分数除以邻域平均变化分数,另加内容阈值和最短镜头限制。这能减轻连续快速运镜带来的误切,但闪光和缓慢转场仍需结合素材调阈值。[算法实现](https://github.com/Breakthrough/PySceneDetect/blob/main/scenedetect/detectors/adaptive_detector.py)
|
||||
|
||||
librosa 的节拍跟踪先形成起音强度曲线,估计速度,再用动态规划寻找与速度相容的强起音序列,不是简单按固定秒数平均切。低置信度时保留普通剪辑并标明未检测到稳定节拍。[算法文档](https://librosa.org/doc/0.10.2/generated/librosa.beat.beat_track.html)
|
||||
|
||||
## 与现有系统的接入方案(尚未上线)
|
||||
|
||||
优先组合 FunClip 的时间戳选片思路、PySceneDetect、librosa 和现有 FFmpeg;千问3.8继续负责素材语义、脚本要求和候选镜头排序。无需为了剪辑更换现有生图/视频模型。
|
||||
|
||||
1. 预处理:现有受限下载器取得素材;ffprobe 读真实时长/FPS/音轨;以素材 hash 缓存低清代理、镜头边界、ASR 和节拍,避免反复解析。
|
||||
2. 候选分段:有对白时按 ASR 句子和说话人形成候选;无对白时按场景边界形成候选。去静音只作可选粗剪,保留前后余量,不切断语词。
|
||||
3. 语义选片:把候选 ID、真实开始/结束、抽帧与转写传给现有千问3.8,只允许模型返回候选 ID、用途和排序。服务端校验所有 ID 和区间;无素材时不能虚构成片。
|
||||
4. 卡点:分析用户选定/生成的音乐,输出真实 beat 秒数。根据最低镜头时长与素材余量,选每 2/4 拍换镜头,平衡节奏、语义连续性和目标总时长。口播优先语句边界,音乐卡点不能强行截断对白。
|
||||
5. 形成可编辑 EDL:记录素材 ID、sourceIn/sourceOut、timelineStart、speed、音量、字幕、转场。前端预览、时间轴修改和后台导出使用同一份 EDL。用整数微秒或有理数时间避免浮点累计漂移,显示时转换为帧。
|
||||
6. 合成:统一尺寸/FPS/像素格式/采样率;每段 trim 后时间归零。转场重叠计入总时长;无音轨片段补静音以保持 A/V 对齐;FFmpeg 执行显式参数数组,进程限时限并发,上传归档。
|
||||
7. 持久任务:分为解析、选片、编辑计划、渲染、转存,各步保存结果和输入版本。刷新或断线恢复同一任务,编辑后旧任务不能覆盖新版本;失败显示具体阶段。
|
||||
|
||||
示例 EDL 合同:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"timebase": "microseconds",
|
||||
"canvas": {"width": 1920, "height": 1080, "fps": 30},
|
||||
"clips": [{"id":"clip-1","assetId":"asset-a","sourceIn":1000000,"sourceOut":3000000,"timelineStart":0,"speed":1,"volume":1}],
|
||||
"music": {"assetId":"music-a","start":0,"gain":0.2},
|
||||
"subtitles": [],
|
||||
"transitions": []
|
||||
}
|
||||
```
|
||||
|
||||
## 对照记录与剩余工作
|
||||
|
||||
| 能力 | 当前项目状态 | 后续验收条件 |
|
||||
| --- | --- | --- |
|
||||
| 单视频裁剪、抽帧、音轨提取、慢放 | 现有真实 FFmpeg 功能 | 已有测试继续保持 |
|
||||
| 四类智能剪辑预设的自动成片 | 本次完成参考站与开源源码研究,未接入完整剪辑服务 | 上传不同素材后产生可编辑 EDL 与真实成片 |
|
||||
| 去静音/ASR 语义选片 | 选定开源候选,尚未部署 | 验证中文、低音量、无音轨、多人语音 |
|
||||
| 音乐卡点 | 已明确 librosa 算法与回退策略,尚未接入 | 合成脉冲音乐和真实音乐的切点误差可量化 |
|
||||
| 多素材时间轴、字幕、转场 | 仍需 EDL 编辑器和合成执行器;不把单片裁剪宣称为智能混剪 | 预览/导出逐片一致,跨转场音画同步 |
|
||||
|
||||
48 服务器此前反馈过内存高。部署 ASR 时应先核对实际可用资源,再使用独立受限 worker;不要在每个 Web 请求内加载一份模型。此处是实施建议,本次没有启动新的常驻模型服务。
|
||||
|
||||
|
||||
## 实施更新
|
||||
|
||||
以上是最初调研时点。已实现的开源分析与剪辑链路、实际验证和独立合成部署状态见 [视频合成服务实施记录](video-render-service-2026-09-17.md)。FunASR仍未完成真实权重验收,不把适配代码等同于转写已可用。
|
||||
145
docs/video-canvas-task-repairs-2026-09-17.md
Normal file
145
docs/video-canvas-task-repairs-2026-09-17.md
Normal file
@ -0,0 +1,145 @@
|
||||
# 视频画布任务与交互修复 · 2026-09-17
|
||||
|
||||
承接 `23f86dfc` 的全面扫描。用户补充“浏览器刷新一下就好了”,本轮重点修复任务结果与页面状态脱节,同时处理 F02–F07 中已确认的入口问题。本记录是分支修复与本地验收,未部署 48 服务器,也没有把原 231 项清单改成全部通过。
|
||||
|
||||
> 前半部分保留 0d17638a 阶段记录;本轮新增修复和验证见文末“后续修复”。
|
||||
|
||||
## 修复范围
|
||||
|
||||
| 扫描项 | 本轮改动 | 验证及边界 |
|
||||
|---|---|---|
|
||||
| F01 任务恢复 | 新增统一生成任务接口、数据库受理记录、请求 ID 去重、原子领取、心跳与结果保存;客户端提交前保存待核对请求,刷新/重开/online/focus/visibilitychange 时查询;去掉虚假进度;旧版无任务 ID 的 pending 明确提示无法恢复 | 主图像/视频生成、配置工具的图像/视频/分析、脚本再生成、批量分镜/视频接入。局部编辑按已有 taskId 恢复查询。真实数据库与浏览器验收见下文。专用图像后期、角度编辑、脚本弹窗资产生成/逐镜提示词仍有同步请求,未宣称全部入口具备同等恢复能力 |
|
||||
| F02 取消 | 排队任务通过数据库 CAS 取消;已提交供应商的任务返回明确说明,继续保存结果;删除已完成结果写入任务隐藏记录 | 数据库并发及浏览器排队取消通过;没有冒充支持上游中止 |
|
||||
| F03 模板 | 待执行模板设为 idle;参考样例标记示例;空脚本不再显示硬编码成稿 | 代码检查;未逐个模板做生产生成 |
|
||||
| F04 脚本 | 脚本入口统一 story-script;空响应/无有效镜头返回失败并保留旧稿 | 结果契约回归通过;脚本资产生成与单镜提示词处理不是此次持久队列接入范围 |
|
||||
| F05 批量 | 批量分镜和视频实际入队;按有无首帧选择兼容模式,采用模型声明的离散时长/比例/分辨率;完成任务不重复提交,失败/空节点可继续执行 | 模型参数和任务契约通过;没有新增真实付费批量请求 |
|
||||
| F06 素材弹窗 | 去掉假分页和无效图标,增加搜索、纵向滚动、自适应列、素材名称与明确数量单位 | 实际组件注入 100 个素材,第 100 项能选中、确认回传,搜索返回 1 项;使用当前 Tailwind 编译产物验收 |
|
||||
| F07 次级控件 | 文本及音频生视频提示词翻译接真实接口并预览后应用;旧脚本编辑聚焦;资源替换接上传;通知显示真实任务;模板筛选使用配置数据;退出接现有登出流程;未实现按钮明确禁用 | 代码与类型检查。未实际退出生产账户,未声称所有停用高级能力已实现 |
|
||||
|
||||
任务表复用既有 `0091_add_canvas_capability_tasks.sql`,没有新增数据库迁移。上线前须核对该既有迁移已应用。
|
||||
|
||||
## 任务一致性
|
||||
|
||||
- 同一请求 ID 重试不会重复执行;同一节点已有活动任务时复用现有任务。
|
||||
- 同画布任务串行领取,多执行者通过 PostgreSQL 事务锁与状态更新竞争。
|
||||
- 刷新前画布尚未保存的已受理节点,可从任务快照补回;不会从旧任务复活已保存删除的节点。
|
||||
- 多图结果使用任务 ID 与序号组成稳定结果节点 ID,轮询不重复追加,重新生成不误用上次附图。
|
||||
- 旧请求不能覆盖新请求;画布保存落后于新任务时,根据任务时间和保存版本恢复最新结果。
|
||||
- 连线仅首次恢复时补齐,后续轮询不反复恢复用户已经删除的连线。
|
||||
- 执行进程心跳超过 120 秒未更新,任务标记待核实;不自动重新提交付费请求。若原执行稍后确实返回,仍允许保存真实结果。
|
||||
- 浏览器离开与服务端执行进程重启是不同情况:本轮解决前者的主要入口;进程中断后尚未实现供应商任务 ID 的自动续查。
|
||||
|
||||
## 验证记录
|
||||
|
||||
1. **真实本地 PostgreSQL**:`scripts/qa/video-canvas-task-db.cjs` 建立隔离测试库,加载实际任务表迁移和实际 store。10 次并发相同提交只创建 1 条记录;10 次并发领取仅 1 次成功;跨用户隔离、排队取消、执行中不可取消、串行队列、结果保存、结果隐藏、过期不重投、晚到结果保存全部通过。这是并发数据库调用测试,不是 10 个操作系统进程实测。
|
||||
2. **真实浏览器任务恢复**:验收页使用实际 React Hook、实际任务 route/worker/store 与本地 PostgreSQL;鉴权使用固定测试用户,NextResponse/after 使用本地适配器,模型返回使用 12 秒测试桩。提交图片后立即刷新,最终出现 2 个结果且只 1 条任务;视频执行时关闭页面,再打开能恢复结果;后续分析排队任务成功取消;移除附图后刷新不复活。画布快照保存使用本地验收服务器,不是生产画布接口。
|
||||
3. **实际素材组件**:100 项列表可定位选择最后一项,确认回传 `测试素材 100`;搜索同名只剩 1 项且保留选中状态;已有第 100 项再连续点选第 1–10 项,最终只选中 10 项,第 10 项(第 11 次选择)被正确拦截。验收期间先发现旧测试样式缓存,重新编译当前源码样式后验证通过。
|
||||
4. **自动化**:最终 helpers/route 回归 437 项通过,无跳过,包含本次新增 13 项任务/批量参数测试和既有真实 FFmpeg 检查。不是 437 个生产端到端测试。
|
||||
5. **静态检查**:TypeScript 无错误;`git diff --check` 通过。新增服务端模块与旧接口共享执行逻辑,图像计费仍走原有计费模块。
|
||||
|
||||
没有进行新的付费模型测试,没有重新宣称参考站像素级一致,没有覆盖操作系统休眠、真实断网、多标签冲突、生产 Next.js 进程重启或生产部署后的回归。原扫描中 79 项缺失/停用能力依然需要单独实现与验证,详见原验证矩阵。
|
||||
|
||||
## 复现
|
||||
|
||||
```sh
|
||||
# FFmpeg / ffprobe 必须在 PATH 中,避免媒体测试跳过
|
||||
pnpm exec playwright test --config=playwright.helpers.config.ts
|
||||
pnpm exec tsc --noEmit --incremental false
|
||||
# 隔离数据库测试依赖仅装入忽略的 .qa 目录,不改项目依赖
|
||||
npm install --prefix .qa/video-task-db --no-save embedded-postgres
|
||||
node scripts/qa/video-canvas-task-db.cjs
|
||||
```
|
||||
|
||||
浏览器验收脚手架位于忽略目录 `.qa/video-durable` 和 `.qa/video-repair`;上述记录保留测试方法和模拟边界,不能代替后续部署验收。
|
||||
|
||||
## 后续修复:图片后期与脚本子任务(承接 0d17638a)
|
||||
|
||||
本轮把角度编辑(自定义参数及预设)、高清、扩图、抠图、宫格批量放大,以及脚本弹窗的资产生成、单镜/全部提示词合成接入已有持久队列。结果节点在提交时创建,完成后由任务查询更新;脚本任务另外按资产 ID、镜头 ID 回填对应字段。生成期间的修改或删除会保留,新结果继续留在独立节点,并显示冲突说明。脚本弹窗重新打开后显示服务器任务的处理中/失败状态。
|
||||
|
||||
高清接口的 AOS 分支可能先返回 HTTP 202 和 taskId。之前视频画布同步调用只寻找 original_url,导致将“已受理”误当失败。本轮先保存上游 taskId,再调用原有 GET 查询;查询中断或达到等待上限时标为“待核实”,保留上游标识,不重新 POST。当前仍不自动恢复服务端进程重启后的上游查询,需后续补全;浏览器刷新不影响在运行的服务端查询。
|
||||
|
||||
四个原 API 的执行实现抽取到 `lib/canvas/operations/`,原 URL 的入口继续通过 auth 验证身份;队列使用已校验的任务所有者调用共享执行函数。逐文件比较确认模型、存储、计费执行逻辑与抽取前保持一致。另修正上轮清理共享生图模块时遗漏的 `getCanvasImageTaskForUser` 导入,本轮完整类型检查通过。
|
||||
|
||||
本轮验证:
|
||||
|
||||
- helpers/route 回归 **447 项通过,无跳过**,其中任务专项 23 项;新增后期参数校验、202 续查只提交一次、断网/502/等待上限待核实、角度响应、空结果拒绝、脚本稳定 ID 回填与修改冲突保护。
|
||||
- 真实隔离 PostgreSQL:原并发/权限/取消检查通过;新增上游任务标识写入、待核实时保留标识、不自动重投检查通过。
|
||||
- 本地浏览器:实际 Hook/API/worker/store + PostgreSQL;鉴权、模型和后期服务使用测试桩。高清提交后刷新恢复;脚本资产提交后刷新自动回填;提示词提交后刷新自动回填 imagePrompt/motionPrompt,原对白保留;生成中修改资产的冲突保护单独检查。画布保存仍使用本地验收接口,不是生产画布 API。
|
||||
- TypeScript 与 diff 空白检查通过;没有新增付费模型调用,没有部署 48 服务器。
|
||||
|
||||
仍需继续的项目:视频解析故事入口尚有同步请求,后台进程中断后的上游续查、多标签同时编辑、生产部署回归,以及原矩阵中的高级功能缺口。本轮不据此宣称 231 项全部完成。
|
||||
|
||||
## 后续修复:视频故事与上游任务续查(承接 86b66172)
|
||||
|
||||
本节更新前面仍标为未完成的“视频解析故事”和“AOS 高清已有任务编号续查”。这两项已完成分支修复与下述本地验证,其他范围不因此自动视为已修复。
|
||||
|
||||
### 视频解析故事
|
||||
|
||||
- 工具栏“解析故事”在提交时创建故事节点,进入实际持久任务队列;刷新、重新打开画布后通过任务状态恢复结果。故事节点重新运行也走视频解析,修正了原先落入通用脚本生成分支的问题。
|
||||
- 解析失败或模型返回空镜头时显示错误并保留旧稿,新节点显示 0 镜。移除前后端自动填入的开场剧情、运镜、灯光、音乐等占位内容;缺失字段显示空值。
|
||||
- 前后端共用镜头规范化逻辑,保留时间 0,不再截断成最多 8 镜;目前上限 120 镜。双击提交由本地提交锁和服务端同节点任务去重保护。
|
||||
|
||||
### 高清续查
|
||||
|
||||
- AOS 受理后先持久化 taskId、操作类型与源图,再查询结果。执行进程中断后,只恢复服务器已保存且与原请求匹配的高清编号;恢复分支只 GET 原任务,不重新 POST。
|
||||
- 下一次画布任务查询发现心跳过期后标记 interrupted,再按 30 秒起、最高 5 分钟的间隔接管续查;限任务创建后 24 小时、总领取次数少于 20 次。不依赖后台永久调度器,服务重启后需要画布查询触发恢复。
|
||||
- 增加领取次数校验:旧执行者的心跳、结果、检查点与失败不能覆盖已被新执行者接管的任务。恢复窗口内对同一节点重复点击运行会复用原任务。
|
||||
- AOS 明确负数失败状态立即终止查询,修正被吞掉后长期显示 processing 的问题;每次网络查询有超时,整个短轮询按实际经过时间计时。
|
||||
- 仍未给没有持久化上游编号的其他供应商任务添加自动续查;视频模型及 VIP98K 同步调用在进程中断后的恢复不能据此视为完成。没有任务编号的请求不会自动重投。
|
||||
|
||||
### 本轮验证
|
||||
|
||||
- helpers/route **454 项通过,无跳过**,其中任务专项 30 项;包含既有真实 FFmpeg 验证。新增检查编号匹配、恢复时限/间隔、GET-only 恢复、执行权失效、终态失败、AOS 短轮询上限、完整镜头及空结果拒绝。
|
||||
- 真实隔离 PostgreSQL:10 个并发调用争抢续查只有 1 个成功;旧领取次数的结果/错误/检查点不能覆盖新领取;重复提交恢复中的节点不会新增任务。
|
||||
- **独立进程中断与重启**:实际 worker/store + PostgreSQL,供应商使用本地桩。首次进程在受理、保存编号并开始 GET 后强制退出,第二个独立进程从库恢复并完成。调用记录严格为 `POST → GET → GET`,上游编号一致。这不是生产 Next.js 部署重启测试。
|
||||
- **浏览器刷新**:实际故事节点组件、Hook、route、worker、store + PostgreSQL;鉴权/供应商/Next after 使用本地适配。故事任务处理中刷新后恢复为 1 个真实桩镜头;另一个空镜头响应变为失败,刷新后仍显示 0 镜与明确错误。画布快照保存仍为本地验收接口。
|
||||
- TypeScript 无错误、diff 空白检查通过。没有新增付费模型调用,没有部署 48 服务器。
|
||||
|
||||
后续仍需完成:其他供应商执行中断恢复、多标签编辑冲突、生产部署回归,以及原矩阵列出的高级功能缺口。本次未声称参考站 231 项全部完成或像素级一致。
|
||||
|
||||
## 后续修复:五类视频续查、多页面保存与 3D 加载(承接 69dcf9b7)
|
||||
|
||||
本节更新前面仍列为待办的五类视频供应商续查、多页面冲突和部分导演台能力。以下均为分支修复与本地验收;没有部署 48 服务器,没有新增付费生成,也没有声称参考站 231 项全部完成。
|
||||
|
||||
### 视频提交与恢复
|
||||
|
||||
- 阿里云、VIP98K external task、火山、AtlasCloud、Agnes 五类异步视频适配器接入同一任务生命周期。上游受理后先保存 taskId、原模型和服务商身份,再查询;恢复只 GET 原编号,不重复准备参考素材或 POST 生视频。
|
||||
- 恢复沿用既有 24 小时窗口、最多 20 次领取、30 秒至 5 分钟退避及执行权校验。由画布任务查询触发,不是脱离画布的永久后台调度器。
|
||||
- 模型或服务线路发生变化时停止自动查询并保留原编号。未取得编号的网络中断显示待核实,不自动重新提交;VIP98K/火山的持久任务路径禁用不确定 POST 的自动重试。明确的图片超限拒绝仍允许火山压缩后重试。
|
||||
- 明确失败/取消结束任务;AtlasCloud 的终态错误即使含有 network 字样也不会误当临时网络故障继续等待。没有上游编号的旧任务不能凭空恢复。
|
||||
- 去掉领取恢复任务时旧的 240 条扫描截断,按恢复窗口和领取次数筛选,避免陈旧记录挡住可恢复任务。
|
||||
|
||||
### 多页面保存与草稿
|
||||
|
||||
- 遇到保存版本冲突,读取最新服务器内容,对基线、本页与服务器三个版本按节点/连线稳定 ID 和字段合并。不同位置的修改自动保留,同字段、删除与编辑、相互冲突的顺序修改必须显式选择。
|
||||
- 页面展示冲突位置,可选择冲突处保留本页或服务器版;无冲突的修改继续保留。支持下载当前 JSON 草稿,不再让用户只能刷新丢掉一版修改。
|
||||
- 浏览器 sessionStorage 按画布保存当前标签页的未保存草稿,隐藏、离开和定时保存前保留;重开当前标签页时与服务器版本合并。修正初始化时旧快照覆盖刚恢复草稿的问题。
|
||||
- 移除节点序列化时每次重造的 createdAt/updatedAt,避免产生虚假冲突;服务器版本时间至少递增 1 毫秒。
|
||||
- 远端连续保存导致超过 3 次自动合并重试时,暂停并保留草稿,用户仍可手动重试,不会进入永久阻塞。当前只在保存冲突时拉取其他页面版本,没有新增实时多人协同;sessionStorage 不是跨设备备份,存储满时仍需使用草稿下载。
|
||||
|
||||
### 3D 导演台逐项记录
|
||||
|
||||
| 原矩阵 ID | 当前实现与修复 | 验收及剩余边界 |
|
||||
|---|---|---|
|
||||
| 08-01、08-15 | GLB 30 秒超时、404/解析失败提示、空几何拒绝、重试加载;WebGL 上下文丢失后可重建场景;异常中止视频导出 | 实际浏览器:404→重试显示橙色方块;35 秒响应在 30 秒提示超时,晚到结果不冒充成功;空模型提示无有效几何;模拟 GPU 丢失→重试恢复原对象。未覆盖所有浏览器/显卡 |
|
||||
| 08-10 | 八个人偶体型比例预设 | 实际浏览器选择幼童、重开保存;回归验证持久化。这是简化人偶比例,不等同参考站八种精细角色资产 |
|
||||
| 08-14 | 复制对象与 3×3 群众阵列,限制总数 200 | 实际浏览器显示 9 个人偶;回归验证数量、间距、上限。未新增自定义行列阵列编辑器 |
|
||||
| 08-17、08-18 | 上传 2:1 全景图、20 MiB 上限、20 条背景历史、默认背景、失败/20 秒超时提示及重试 | 实际 TextureLoader 浏览器渲染本地全景测试图、404 错误与切回默认背景;上传调用现有服务,未做生产对象存储上传验收 |
|
||||
| 09-02、09-14 | 各机位保存独立相机轨迹,切换不串轨 | 浏览器:机位 1 两帧→新机位零帧→机位 1 恢复两帧;序列化回归通过。尚不是对象、相机、灯光同时运行的完整多轨系统 |
|
||||
| 09-04、09-05 | yaw/pitch 旋转、持续注视所选对象;预览、截图、真实录制和发送的运镜描述共用相机解析 | 浏览器移动对象后注视坐标跟随,手动旋转会退出跟随;方向/距离及解析一致性回归通过。未实现相机位置跟随对象平移或物体动画轨 |
|
||||
| 09-13 | 编辑机位时自动记录当前时间关键帧 | 浏览器启用后旋转相机更新当前帧;预览播放不自动写帧 |
|
||||
| 09-19 | 整条轨迹右移 1 米、轨迹与注视方向一起转 90° | 浏览器检查变换后的首帧坐标;回归验证距离不变。不是自由变换轴或铅笔/钢笔路径编辑器 |
|
||||
| 09-24 | MP4 不支持时回退 WebM VP9/VP8/默认编码,文件名与内容类型一致 | 录制测试覆盖 MP4 与 WebM 回退、切后台/取消/渲染失败清理;本轮未新增真实生产视频上传 |
|
||||
|
||||
### 最终本地验证
|
||||
|
||||
1. helpers/route **468 项通过,无跳过**,包括真实 FFmpeg 检查。新增模型恢复、合并冲突、独立轨道/跟随/体型/轨迹等回归;不是 468 个生产端到端测试。
|
||||
2. `scripts/qa/video-canvas-provider-recovery.cjs` 调用实际模型选择与五类供应商适配器,使用合成目录、凭证及 fetch 响应。逐一验证受理后先存编号、查询中断、GET-only 续查、原服务商身份、明确终态失败只查一次、提交网络中断只 POST 一次。脚本禁止访问测试域名以外的网络,没有付费生成。
|
||||
3. `scripts/qa/video-canvas-task-db.cjs` 的真实隔离 PostgreSQL 检查通过,新增视频编号持久化、重复节点去重、10 个并发调用仅一个接管;保留原高清独立进程中断恢复测试。视频本轮没有另做生产进程重启测试。
|
||||
4. 两个真实浏览器标签页使用实际持久化 Hook 和冲突组件:不同节点修改自动合并,同节点冲突可选择且保留另一节点修改;恢复预置的 sessionStorage 草稿后可保存。画布 CAS 接口为本地验收服务器,没有写生产项目。
|
||||
5. 实际导演台组件的 WebGL、独立机位、跟随、自动帧、群众阵列、全景和加载异常验收见上表。浏览器脚手架位于忽略目录 `.qa/video-concurrency`。
|
||||
6. TypeScript 无错误,Git diff 空白检查通过。复现供应商检查:`node scripts/qa/video-canvas-provider-recovery.cjs`;其余测试命令同前文。
|
||||
|
||||
### 仍未完成的范围
|
||||
|
||||
原矩阵是历史扫描基线,本节是增量结果,不能据此将原 79 项缺失全部改为已完成。仍缺:TTS/音乐/音色接口,多画布/故事板/Agent 与跨项目资产,部分专用视频后期和智能剪辑,精细角色/手工关节、AI 识图重建、AI 全景、对象多轨、时间轴单位缩放、自由绘制轨迹,以及参考站逐状态像素对照与上线验收。现有服务尚无通用 TTS/音乐实现,不能用仅有入口或普通模型目录代替这些能力。未接通入口继续明确标示状态。
|
||||
285
docs/video-canvas-validation-matrix-2026-09-17.md
Normal file
285
docs/video-canvas-validation-matrix-2026-09-17.md
Normal file
@ -0,0 +1,285 @@
|
||||
# 视频画布验证矩阵 · 2026-09-17
|
||||
|
||||
逐项核对原 231 项。分类表示**证据和剩余工作**,不是完成率;“有部分证据”包括历史实测、契约单测和代码检查,均不能单独代替本次端到端验收。详细说明见同名扫描报告与 JSON。
|
||||
|
||||
## 01 画布、节点和项目
|
||||
|
||||
| ID | 子功能 | 验证结论 | 证据 | 剩余工作 |
|
||||
|---|---|---|---|---|
|
||||
| 01-01 | 项目/标题 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-02 | 项目/多画布切换 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-03 | 视图/工作流 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-04 | 视图/故事板 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-05 | 故事板/关键元素 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-06 | 故事板/音频 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-07 | 故事板/对话引用 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-08 | 故事板/剪辑 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-09 | 添加/基本节点 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-10 | 添加/智能剪辑 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-11 | 添加/逐帧拉片 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-12 | 添加/脚本版本 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-13 | 添加/搜索 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-14 | 画布/平移缩放 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-15 | 画布/小地图 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-16 | 画布/网格吸附 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-17 | 画布/连线显隐 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-18 | 画布/整理 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-19 | 画布/分组解组 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-20 | 画布/撤销重做 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-21 | 右键/评级 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-22 | 右键/复制粘贴 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-23 | 右键/资产沉淀 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-24 | 右键/全景预览 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-25 | 右键/素材校验 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-26 | 快捷键/一致性 | 有部分证据,未完整验收 | 本轮代码检查 | 全画布撤销、分组等原参考能力未实现。 |
|
||||
| 01-27 | 持久化/完成结果恢复 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商) | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 01-28 | 持久化/后台与刷新中任务 | 仍有问题(F01) | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
|
||||
## 02 文本和提示词
|
||||
|
||||
| ID | 子功能 | 验证结论 | 证据 | 剩余工作 |
|
||||
|---|---|---|---|---|
|
||||
| 02-01 | 文本/手工编写 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 02-02 | 文本/文生视频 | 仍有问题(F03) | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 02-03 | 文本/图片反推 | 仍有问题(F03) | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 02-04 | 文本/文字生音乐 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 02-05 | 提示词/优化 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商) | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 02-06 | 提示词/翻译 | 仍有问题(F07) | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商) | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 02-07 | 提示词/@引用 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 02-08 | 提示词/AutoLink | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 02-09 | 提示词/联网搜索 | 缺失或停用 | 修复记录;本轮活动代码检查 | 联网搜索与供应商合规检查服务未实现。 |
|
||||
| 02-10 | 提示词/自动校验素材 | 缺失或停用 | 修复记录;本轮活动代码检查 | 联网搜索与供应商合规检查服务未实现。 |
|
||||
| 02-11 | 提示词/文本模型显示 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
|
||||
## 03 图片生成与编辑
|
||||
|
||||
| ID | 子功能 | 验证结论 | 证据 | 剩余工作 |
|
||||
|---|---|---|---|---|
|
||||
| 03-01 | 图片/模型选择 | 有部分证据,未完整验收 | 本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-02 | 图片/1K 2K 4K | 有部分证据,未完整验收 | 本轮 generation-contract 回归通过 | 供应商逐模型规格、实际多张生成和页面端到端未逐一验收。 |
|
||||
| 03-03 | 图片/画质 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-04 | 图片/透明背景 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-05 | 图片/比例 | 有部分证据,未完整验收 | 本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-06 | 图片/数量 | 有部分证据,未完整验收 | 本轮 generation-contract 回归通过 | 供应商逐模型规格、实际多张生成和页面端到端未逐一验收。 |
|
||||
| 03-07 | 图片/替换素材 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-08 | 图片/人像调节 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-09 | 图片/情绪调节 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-10 | 图片/全景生成 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-11 | 图片/全景沉浸预览 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-12 | 多角度/七种预设 | 有部分证据,未完整验收 | 本轮浏览器:鱼眼→右箭头→提交 preset=自定义、horizontalAngle=10;本轮角度映射回归通过 | 未验证全部预设的供应商实际画面效果。 |
|
||||
| 03-13 | 多角度/环绕俯仰缩放 | 有部分证据,未完整验收 | 本轮浏览器:鱼眼→右箭头→提交 preset=自定义、horizontalAngle=10;本轮角度映射回归通过 | 未验证全部预设的供应商实际画面效果。 |
|
||||
| 03-14 | 多角度/预览 | 有部分证据,未完整验收 | 此前组件浏览器验收 | 不是实时角度渲染;多分辨率布局仍待验。 |
|
||||
| 03-15 | 打光/智能模式 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-16 | 打光/亮度颜色 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-17 | 打光/六方向 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-18 | 打光/轮廓光 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-19 | 高清/专用模型 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-20 | 高清/倍率 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-21 | 宫格预设/11 类 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-22 | 宫格预设/禁用状态 | 有部分证据,未完整验收 | 本轮代码检查 | 后台开关与页面端到端尚未专项验收。 |
|
||||
| 03-23 | 宫格切分/固定规格 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-24 | 宫格切分/自定义 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-25 | 宫格切分/边界与选中 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-26 | 元素编辑/点选 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-27 | 元素编辑/框选 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-28 | 元素编辑/画笔 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-29 | 图层分离/物体拆分 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-30 | 图片/扩图重绘擦除抠图 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-31 | 图片/下载预览 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 03-32 | 图片/小屏弹窗 | 有部分证据,未完整验收 | 此前组件浏览器验收 | 不是实时角度渲染;多分辨率布局仍待验。 |
|
||||
| 03-33 | 图片/空参考输入 | 有部分证据,未完整验收 | 本轮 generation-contract 回归通过 | 供应商逐模型规格、实际多张生成和页面端到端未逐一验收。 |
|
||||
|
||||
## 04 视频生成和模型匹配
|
||||
|
||||
| ID | 子功能 | 验证结论 | 证据 | 剩余工作 |
|
||||
|---|---|---|---|---|
|
||||
| 04-01 | 视频/文生视频 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商) | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-02 | 视频/首帧生成 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商) | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-03 | 视频/首尾帧 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-04 | 视频/多图参考 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-05 | 视频/全能参考 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-06 | 视频/5 分钟长视频 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-07 | 视频/模型目录 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-08 | 视频/按功能过滤模型 | 有部分证据,未完整验收 | 本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-09 | 视频/无兼容模型 | 有部分证据,未完整验收 | 本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-10 | 视频/比例规格 | 有部分证据,未完整验收 | 本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-11 | 视频/清晰度 | 有部分证据,未完整验收 | 本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-12 | 视频/时长 | 有部分证据,未完整验收 | 本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-13 | 视频/生成音频 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商) | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-14 | 视频/生成数量 | 有部分证据,未完整验收 | 本轮代码检查 | 混合失败、排队中删除、重复提交、断线恢复未验收。 |
|
||||
| 04-15 | 视频/参考选择 | 有部分证据,未完整验收 | 本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-16 | 视频/标记引用 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-17 | 视频/运镜库 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-18 | 视频/运镜引用 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-19 | 视频/角色素材 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-20 | 视频/特效库 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-21 | 视频/生成任务恢复 | 仍有问题(F01,F02) | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 04-22 | 视频/结果归档 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商);本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
|
||||
## 05 视频后期、智能剪辑和拉片
|
||||
|
||||
| ID | 子功能 | 验证结论 | 证据 | 剩余工作 |
|
||||
|---|---|---|---|---|
|
||||
| 05-01 | 后期/工具栏结构 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 05-02 | 后期/时间裁剪 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商);本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 05-03 | 后期/空间裁剪 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商);本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 05-04 | 后期/视频高清 | 缺失或停用 | 本轮代码检查 | 专用超分、去字幕、人声/背景分离服务尚未接通。 |
|
||||
| 05-05 | 后期/补帧 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 05-06 | 后期/慢放 | 有部分证据,未完整验收 | 本轮真实 FFmpeg 回归:首/中/尾不同、2/3/5 倍慢放保留音轨 | 实际网页当前帧选取→处理接口→结果节点链路本轮未再执行。 |
|
||||
| 05-07 | 后期/片段重拍 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 05-08 | 后期/智能续写 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 05-09 | 后期/智能去字幕 | 缺失或停用 | 本轮代码检查 | 专用超分、去字幕、人声/背景分离服务尚未接通。 |
|
||||
| 05-10 | 后期/框选去字幕 | 缺失或停用 | 本轮代码检查 | 专用超分、去字幕、人声/背景分离服务尚未接通。 |
|
||||
| 05-11 | 后期/音视频分离 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商);本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 05-12 | 后期/人声提取 | 缺失或停用 | 本轮代码检查 | 专用超分、去字幕、人声/背景分离服务尚未接通。 |
|
||||
| 05-13 | 后期/背景音提取 | 缺失或停用 | 本轮代码检查 | 专用超分、去字幕、人声/背景分离服务尚未接通。 |
|
||||
| 05-14 | 后期/主体消除 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 05-15 | 后期/截取帧 | 有部分证据,未完整验收 | 本轮真实 FFmpeg 回归:首/中/尾不同、2/3/5 倍慢放保留音轨 | 实际网页当前帧选取→处理接口→结果节点链路本轮未再执行。 |
|
||||
| 05-16 | 后期/截图规格显示 | 有部分证据,未完整验收 | 本轮真实 FFmpeg 回归:首/中/尾不同、2/3/5 倍慢放保留音轨 | 实际网页当前帧选取→处理接口→结果节点链路本轮未再执行。 |
|
||||
| 05-17 | 智能剪辑/四种模板 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 05-18 | 智能剪辑/讲解模板 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 05-19 | 智能剪辑/多素材编排 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 05-20 | 逐帧拉片/输入信息 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 05-21 | 逐帧拉片/拆解维度 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 05-22 | 逐帧拉片/结果继续创作 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
|
||||
## 06 脚本的三层流程
|
||||
|
||||
| ID | 子功能 | 验证结论 | 证据 | 剩余工作 |
|
||||
|---|---|---|---|---|
|
||||
| 06-01 | 脚本/模型生成 | 仍有问题(F04) | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 06-02 | 脚本/手工编写 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 06-03 | 确认镜头/字段 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 06-04 | 确认镜头/单元格编辑 | 有部分证据,未完整验收 | 此前组件浏览器编辑刷新恢复 | 数据库持久化、行删除后的资产/分镜同步仍需端到端。 |
|
||||
| 06-05 | 确认镜头/添加镜头 | 有部分证据,未完整验收 | 此前组件浏览器编辑刷新恢复 | 数据库持久化、行删除后的资产/分镜同步仍需端到端。 |
|
||||
| 06-06 | 确认镜头/拖动排序 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 06-07 | 确认镜头/行操作 | 有部分证据,未完整验收 | 此前组件浏览器编辑刷新恢复 | 数据库持久化、行删除后的资产/分镜同步仍需端到端。 |
|
||||
| 06-08 | 准备资产/角色场景道具 | 有部分证据,未完整验收 | 此前浏览器资产引用与计数;本轮代码检查 | 上传成功/失败、AI 生成及所有资产类型未完整验收。 |
|
||||
| 06-09 | 准备资产/真实来源 | 有部分证据,未完整验收 | 此前浏览器资产引用与计数;本轮代码检查 | 上传成功/失败、AI 生成及所有资产类型未完整验收。 |
|
||||
| 06-10 | 准备资产/新增 | 有部分证据,未完整验收 | 此前浏览器资产引用与计数;本轮代码检查 | 上传成功/失败、AI 生成及所有资产类型未完整验收。 |
|
||||
| 06-11 | 准备资产/替换清除删除 | 有部分证据,未完整验收 | 此前浏览器资产引用与计数;本轮代码检查 | 上传成功/失败、AI 生成及所有资产类型未完整验收。 |
|
||||
| 06-12 | 准备资产/资产生成参数 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 06-13 | 准备资产/完成状态 | 有部分证据,未完整验收 | 此前浏览器资产引用与计数;本轮代码检查 | 上传成功/失败、AI 生成及所有资产类型未完整验收。 |
|
||||
| 06-14 | 合成提示词/完成门槛 | 有部分证据,未完整验收 | 本轮浏览器:接口 501 后显示失败,原对白/两镜头保留,完成数 0/2;此前浏览器风格编辑与刷新 | 仅验证本地失败分支;真实合成成功与批量部分失败未验收。 |
|
||||
| 06-15 | 合成提示词/单镜合成 | 有部分证据,未完整验收 | 本轮浏览器:接口 501 后显示失败,原对白/两镜头保留,完成数 0/2;此前浏览器风格编辑与刷新 | 仅验证本地失败分支;真实合成成功与批量部分失败未验收。 |
|
||||
| 06-16 | 合成提示词/全部合成 | 有部分证据,未完整验收 | 本轮浏览器:接口 501 后显示失败,原对白/两镜头保留,完成数 0/2;此前浏览器风格编辑与刷新 | 仅验证本地失败分支;真实合成成功与批量部分失败未验收。 |
|
||||
| 06-17 | 合成提示词/风格来源 | 有部分证据,未完整验收 | 本轮浏览器:接口 501 后显示失败,原对白/两镜头保留,完成数 0/2;此前浏览器风格编辑与刷新 | 仅验证本地失败分支;真实合成成功与批量部分失败未验收。 |
|
||||
| 06-18 | 输出/批量分镜 | 仍有问题(F05) | 本轮执行链检查 | 不是完整自动批量生成;无首帧时仍选择首帧模型,时长未按离散规格约束。 |
|
||||
| 06-19 | 输出/批量视频 | 仍有问题(F05) | 本轮执行链检查 | 不是完整自动批量生成;无首帧时仍选择首帧模型,时长未按离散规格约束。 |
|
||||
| 06-20 | 输出/导出 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 06-21 | 脚本/旧版 | 仍有问题(F04,F07) | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
|
||||
## 07 音频、配音和音色
|
||||
|
||||
| ID | 子功能 | 验证结论 | 证据 | 剩余工作 |
|
||||
|---|---|---|---|---|
|
||||
| 07-01 | 音频/模型选择 | 缺失或停用 | 本轮代码检查 | TTS、音乐、音色克隆及这些参数能力未实现;不能记为验收通过。 |
|
||||
| 07-02 | 音频/生成提交 | 缺失或停用 | 本轮代码检查 | TTS、音乐、音色克隆及这些参数能力未实现;不能记为验收通过。 |
|
||||
| 07-03 | 音频/草稿恢复 | 缺失或停用 | 本轮代码检查 | TTS、音乐、音色克隆及这些参数能力未实现;不能记为验收通过。 |
|
||||
| 07-04 | 音频/停顿语气 | 缺失或停用 | 本轮代码检查 | TTS、音乐、音色克隆及这些参数能力未实现;不能记为验收通过。 |
|
||||
| 07-05 | 音频/翻译 | 缺失或停用 | 本轮代码检查 | TTS、音乐、音色克隆及这些参数能力未实现;不能记为验收通过。 |
|
||||
| 07-06 | 音频/语速声调音量 | 缺失或停用 | 本轮代码检查 | TTS、音乐、音色克隆及这些参数能力未实现;不能记为验收通过。 |
|
||||
| 07-07 | 音频/音高强度音色 | 缺失或停用 | 本轮代码检查 | TTS、音乐、音色克隆及这些参数能力未实现;不能记为验收通过。 |
|
||||
| 07-08 | 音频/音效 | 缺失或停用 | 本轮代码检查 | TTS、音乐、音色克隆及这些参数能力未实现;不能记为验收通过。 |
|
||||
| 07-09 | 音频/重置 | 缺失或停用 | 本轮代码检查 | TTS、音乐、音色克隆及这些参数能力未实现;不能记为验收通过。 |
|
||||
| 07-10 | 音频/语言 | 缺失或停用 | 本轮代码检查 | TTS、音乐、音色克隆及这些参数能力未实现;不能记为验收通过。 |
|
||||
| 07-11 | 音频/采样率 | 缺失或停用 | 本轮代码检查 | TTS、音乐、音色克隆及这些参数能力未实现;不能记为验收通过。 |
|
||||
| 07-12 | 音频/格式 | 缺失或停用 | 本轮代码检查 | TTS、音乐、音色克隆及这些参数能力未实现;不能记为验收通过。 |
|
||||
| 07-13 | 音色/列表 | 缺失或停用 | 本轮代码检查 | TTS、音乐、音色克隆及这些参数能力未实现;不能记为验收通过。 |
|
||||
| 07-14 | 音色/试听选择 | 缺失或停用 | 本轮代码检查 | TTS、音乐、音色克隆及这些参数能力未实现;不能记为验收通过。 |
|
||||
| 07-15 | 音色/搜索筛选 | 缺失或停用 | 本轮代码检查 | TTS、音乐、音色克隆及这些参数能力未实现;不能记为验收通过。 |
|
||||
| 07-16 | 音色/收藏分页 | 缺失或停用 | 本轮代码检查 | TTS、音乐、音色克隆及这些参数能力未实现;不能记为验收通过。 |
|
||||
| 07-17 | 音色/克隆 | 缺失或停用 | 本轮代码检查 | TTS、音乐、音色克隆及这些参数能力未实现;不能记为验收通过。 |
|
||||
| 07-18 | 音乐/生成 | 缺失或停用 | 本轮代码检查 | TTS、音乐、音色克隆及这些参数能力未实现;不能记为验收通过。 |
|
||||
| 07-19 | 音频生视频/模型匹配 | 仍有问题(F07) | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 07-20 | 音频/播放已上传音轨 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商) | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
|
||||
## 08 3D 导演台:场景与角色
|
||||
|
||||
| ID | 子功能 | 验证结论 | 证据 | 剩余工作 |
|
||||
|---|---|---|---|---|
|
||||
| 08-01 | 场景/真实渲染 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商) | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 08-02 | 场景/视角 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商) | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 08-03 | 场景/对象搜索 | 有部分证据,未完整验收 | 本轮场景序列化回归通过;此前代码检查 | 需逐控件浏览器验收,锁定只限制变换轴,颜色只适用于基本对象。 |
|
||||
| 08-04 | 场景/显隐锁定 | 有部分证据,未完整验收 | 本轮场景序列化回归通过;此前代码检查 | 需逐控件浏览器验收,锁定只限制变换轴,颜色只适用于基本对象。 |
|
||||
| 08-05 | 场景/重命名 | 有部分证据,未完整验收 | 本轮场景序列化回归通过;此前代码检查 | 需逐控件浏览器验收,锁定只限制变换轴,颜色只适用于基本对象。 |
|
||||
| 08-06 | 场景/移动旋转缩放 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商) | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 08-07 | 场景/旋转单位 | 有部分证据,未完整验收 | 本轮场景序列化回归通过;此前代码检查 | 需逐控件浏览器验收,锁定只限制变换轴,颜色只适用于基本对象。 |
|
||||
| 08-08 | 场景/统一缩放 | 有部分证据,未完整验收 | 本轮场景序列化回归通过;此前代码检查 | 需逐控件浏览器验收,锁定只限制变换轴,颜色只适用于基本对象。 |
|
||||
| 08-09 | 场景/颜色 | 有部分证据,未完整验收 | 本轮场景序列化回归通过;此前代码检查 | 需逐控件浏览器验收,锁定只限制变换轴,颜色只适用于基本对象。 |
|
||||
| 08-10 | 角色/八种体型 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 08-11 | 角色/二十种姿势 | 有部分证据,未完整验收 | 本轮姿势数据/往返回归;此前浏览器 T 型、招手姿势 | 其余 10 种姿势逐一视觉检查未做;参考站 20 姿势及手工关节编辑未齐。 |
|
||||
| 08-12 | 角色/身体躯干头部 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 08-13 | 角色/肩肘髋膝 | 有部分证据,未完整验收 | 本轮姿势数据/往返回归;此前浏览器 T 型、招手姿势 | 其余 10 种姿势逐一视觉检查未做;参考站 20 姿势及手工关节编辑未齐。 |
|
||||
| 08-14 | 角色/群众阵列 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 08-15 | 角色/本地模型 | 有部分证据,未完整验收 | 此前真实 GLB 链路;本轮 GLB 完整性/代理契约回归 | 弱网、损坏文件、超大 GLB、WebGL 丢失与导入中关闭的实际浏览器行为待验。 |
|
||||
| 08-16 | 角色/几何模型 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 08-17 | 全景/上传背景 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 08-18 | 全景/历史记录 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 08-19 | 全景/AI 生成 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 08-20 | AI 导入/图源选择 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 08-21 | AI 导入/插入与覆盖 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 08-22 | AI 导入/关闭继续任务 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
|
||||
## 09 3D 导演台:机位、轨迹、输出
|
||||
|
||||
| ID | 子功能 | 验证结论 | 证据 | 剩余工作 |
|
||||
|---|---|---|---|---|
|
||||
| 09-01 | 机位/预设 | 有部分证据,未完整验收 | 此前浏览器正面预设与环绕导出;本轮相机数据回归;此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商) | 12 机位逐项视觉和小屏布局未全验;不是自动跟随。 |
|
||||
| 09-02 | 机位/切换 | 有部分证据,未完整验收 | 本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-03 | 机位/位置与注视坐标 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商);本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-04 | 机位/手动旋转 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-05 | 机位/跟随对象 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-06 | 机位/注视对象 | 有部分证据,未完整验收 | 此前浏览器正面预设与环绕导出;本轮相机数据回归;此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商) | 12 机位逐项视觉和小屏布局未全验;不是自动跟随。 |
|
||||
| 09-07 | 机位/FOV | 有部分证据,未完整验收 | 此前浏览器正面预设与环绕导出;本轮相机数据回归;此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商) | 12 机位逐项视觉和小屏布局未全验;不是自动跟随。 |
|
||||
| 09-08 | 机位/画幅比例 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商);本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-09 | 时间轴/布局 | 有部分证据,未完整验收 | 此前浏览器正面预设与环绕导出;本轮相机数据回归;此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商) | 12 机位逐项视觉和小屏布局未全验;不是自动跟随。 |
|
||||
| 09-10 | 时间轴/播放暂停跳转 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商);本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-11 | 时间轴/总时长 | 有部分证据,未完整验收 | 本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-12 | 时间轴/单位与缩放 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-13 | 时间轴/自动帧 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-14 | 时间轴/多轨 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-15 | 时间轴/属性轨 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-16 | 关键帧/编辑删除 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商);本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-17 | 轨迹/圆环直线矩形 | 有部分证据,未完整验收 | 本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-18 | 轨迹/铅笔钢笔 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-19 | 轨迹/变换与朝向 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-20 | 轨迹/查看全路径 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商);本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-21 | 轨迹/平滑插值 | 有部分证据,未完整验收 | 本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-22 | 轨迹/JSON 导入导出 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商);本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-23 | 输出/机位截图 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商) | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-24 | 输出/运镜视频 | 有部分证据,未完整验收 | 此前实测记录(见 director-chain/director-motion/real-test/aliyun-test 文档;本轮未重跑供应商);本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-25 | 输出/AI 生视频 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 09-26 | 输出/取消与切后台 | 有部分证据,未完整验收 | 本轮相关契约/算法回归通过;不覆盖全部 UI 与真实接口 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
|
||||
## 10 素材、历史、模板与公共入口
|
||||
|
||||
| ID | 子功能 | 验证结论 | 证据 | 剩余工作 |
|
||||
|---|---|---|---|---|
|
||||
| 10-01 | 模板/工具箱 | 仍有问题(F03) | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 10-02 | 模板/我的工具箱 | 仍有问题(F07) | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 10-03 | 风格/广场分类 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 10-04 | 风格/收藏最近 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 10-05 | 特效/收藏最近 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 10-06 | 角色库/筛选 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 10-07 | 角色库/四类图片 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 10-08 | 运镜库/自定义收藏 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 10-09 | 历史/全部与本画布 | 有部分证据,未完整验收 | 此前空素材浏览器验收;本轮源码复核 | 素材选择弹窗仍有无处理函数的分页控件;大量素材滚动可达性未验。 |
|
||||
| 10-10 | 历史/示例混入 | 有部分证据,未完整验收 | 此前空素材浏览器验收;本轮源码复核 | 素材选择弹窗仍有无处理函数的分页控件;大量素材滚动可达性未验。 |
|
||||
| 10-11 | 历史/分页数量 | 仍有问题(F06) | 此前空素材浏览器验收;本轮源码复核 | 素材选择弹窗仍有无处理函数的分页控件;大量素材滚动可达性未验。 |
|
||||
| 10-12 | 历史/日期与排序 | 有部分证据,未完整验收 | 此前空素材浏览器验收;本轮源码复核 | 素材选择弹窗仍有无处理函数的分页控件;大量素材滚动可达性未验。 |
|
||||
| 10-13 | 历史/批量下载 | 有部分证据,未完整验收 | 本轮原始音频字节下载回归 | 认证下载/ZIP 部分失败、大文件、过期URL、Safari 下载本轮未端到端。 |
|
||||
| 10-14 | 历史/删除 | 有部分证据,未完整验收 | 本轮代码检查 | 非源文件删除;隐藏状态在数据库保存/刷新未本轮验收。 |
|
||||
| 10-15 | 历史/评级保存资产 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 10-16 | 资产管理/节点定位 | 有部分证据,未完整验收 | 本轮浏览器收起→节点消失→展开恢复;本轮浏览器:画布脚本筛选→素材默认全部→视频筛选→画布仍脚本 | 跨项目个人/Agent 资产库未实现;定位、重命名、下载仍需完整应用验收。 |
|
||||
| 10-17 | 资产管理/列表宫格 | 待专项验证 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 10-18 | 资产管理/个人与Agent | 有部分证据,未完整验收 | 本轮浏览器收起→节点消失→展开恢复;本轮浏览器:画布脚本筛选→素材默认全部→视频筛选→画布仍脚本 | 跨项目个人/Agent 资产库未实现;定位、重命名、下载仍需完整应用验收。 |
|
||||
| 10-19 | 资产管理/文件夹上传 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 10-20 | 分享/公开链接 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 10-21 | 发布/发布作品 | 仍有问题(F07) | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 10-22 | Agent/对话及引用 | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 10-23 | Agent/Skills | 缺失或停用 | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 10-24 | Agent/生成权限与通知 | 仍有问题(F07) | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
| 10-25 | 帮助/快捷键教程 | 有部分证据,未完整验收 | 本轮代码检查 | 全画布撤销、分组等原参考能力未实现。 |
|
||||
| 10-26 | 账户/积分 | 仍有问题(F07) | 基线代码对照;缺少可复验的专项结果 | 需要逐状态功能验收;不能以存在代码作为通过依据。 |
|
||||
|
||||
3151
docs/video-canvas-validation-scan-2026-09-17.json
Normal file
3151
docs/video-canvas-validation-scan-2026-09-17.json
Normal file
File diff suppressed because it is too large
Load Diff
66
docs/video-canvas-validation-scan-2026-09-17.md
Normal file
66
docs/video-canvas-validation-scan-2026-09-17.md
Normal file
@ -0,0 +1,66 @@
|
||||
# 视频画布全面验证扫描 · 2026-09-17
|
||||
|
||||
后续修复记录:[任务与交互修复](video-canvas-task-repairs-2026-09-17.md)。以下内容保留扫描时的基线,不代表修复后的现状;也未将未验收项批量改为通过。
|
||||
|
||||
结论:**仍有未验证功能,也有已确认但尚未修复的功能缺口,不能判定全面验收通过。** 本轮以 `f10e6b7c` 为代码起点,核对原 10 组、231 项对照清单,追踪活动页面的调用、请求和保存流程,补跑相关回归与实际组件浏览器验证。没有重新提交付费模型任务,没有重新实测参考网站,也没有部署 48 服务器。
|
||||
|
||||
## 证据范围
|
||||
|
||||
| 分类 | 项数 | 含义 |
|
||||
|---|---:|---|
|
||||
| 待专项验证 | 56 | 主要有代码/对照记录,缺少对应的专项验收证据 |
|
||||
| 有部分证据,未完整验收 | 80 | 有代码检查、契约回归、本地组件或历史实测之一;不等于全链路通过 |
|
||||
| 仍有问题 | 16 | 关联下方 F01–F07 的具体问题;多个条目可属于同一问题 |
|
||||
| 缺失或停用 | 79 | 没有等价实现,或已明确禁用;不是单纯漏测 |
|
||||
| 合计 | 231 | 分类为保守的验证工作清单,**不是产品完成率** |
|
||||
|
||||
完整逐项清单:[验证矩阵](video-canvas-validation-matrix-2026-09-17.md),机器可读数据:[验证 JSON](video-canvas-validation-scan-2026-09-17.json)。原对照文件是旧基线,本次没有把旧状态全部改成“已完成”。
|
||||
|
||||
## 本轮确认的未完成项
|
||||
|
||||
| 编号 | 级别 | 触发与结果 | 证据位置 | 后续验收要求 |
|
||||
|---|---|---|---|---|
|
||||
| F01 | P1 | 生成中刷新/重开:保存的 pending 节点没有重新查询视频任务;本地定时器仍将进度推到 96%。局部编辑有 taskId,但轮询仅从提交入口启动,加载时未恢复 | `LiblibVideoCanvasReplica.tsx`:加载约 4922、定时器 5052、局部编辑 7214/7471;`services/canvas/index.ts` 的 generateVideo 为一次 HTTP 请求 | 服务端持久任务 ID、查询/续订、过期状态;真实刷新、断网、切后台、重开后恢复成功/失败,且不重复扣费。仅切后台是否必然失败本轮没有实测结论 |
|
||||
| F02 | P1 | “取消生成”→“确定”只关闭弹窗;请求仍继续。弹窗却写“停止将不退还算力” | 活动根组件约 10807:onCancel/onConfirm 都只 setCancelGenerationConfirmNodeId(null) | 明确上游能否取消;不能取消时真实告知。排队取消、执行中取消、取消后晚到结果分别验证 |
|
||||
| F03 | P1 | 快捷模板仍注入示例素材;角色三视图、首帧模板直接设 generated:true,未执行真实生成 | createStarterWorkflow 约 4588;getConfiguredReferenceAsset 约 3490 | 示例清楚标识;未运行节点不标生成完成,历史/导出不混淆样例与用户结果 |
|
||||
| F04 | P1 | 脚本弹窗“重新生成”调用 runNode;连入图片/视频后会选择 image-to-prompt/agent-plan。工具栏另一路却使用 story-script;此外 success:true 但空数据仍可能标完成 | 弹窗 onRun 约 11350;runNode 约 9740;runStoryScriptToolbarAction 约 8352 | 相同脚本在两入口生成应走同一功能契约;空响应、缺镜头字段保留旧稿并报告失败 |
|
||||
| F05 | P1 | “批量生视频”目前只创建生成器;没有首帧也取首帧模型;时长只限 2–15 秒,不遵守模型离散时长 | runStoryScriptToolbarAction 约 8623–8660 | 无图选兼容文生模式或拦截;批量真正执行、规格约束、部分失败续跑、刷新恢复 |
|
||||
| F06 | P1 | 素材选择弹窗仍保留无处理函数的上一页/下一页、“15条/页”和跳页外观;一次渲染所有素材,容器没有明确的纵向滚动,超一屏可达性待验。卡片垃圾桶/预览是 span | HistoryAssetPickerModal 约 23016–23190 | 去掉假分页或接真实分页;超过 15/100 条、最后一项可选、预览/删除行为和选择上限分别验收 |
|
||||
| F07 | P2 | 仍有仅提示“待接入”或无行为的次级控件:文本翻译、音频生视频高级设置/展开、旧脚本编辑、我的工具箱、通知分类、发布和账户退出等 | TextTargetModuleComposer、AudioToVideoComposerPanel、PresetScriptCanvasNode、WorkflowPanel、NotificationsPopover、PublishModal、AccountPopover | 与主入口逐一比对;接通或明确禁用,不能用主入口成功覆盖旧入口 |
|
||||
|
||||
上述判断来自代码调用链;没有把它们描述为全部浏览器复现。F01/F02 是优先项,关系到用户看到的长时间处理中和重复提交风险。
|
||||
|
||||
## 本轮修复并补验的三个小问题
|
||||
|
||||
1. **多角度方向箭头**:鱼眼/倾斜选中后,箭头修改数值仍保留特殊预设,提交会走特殊提示词路径。四个方向按钮现与滑块一致切到“自定义”。实际组件浏览器复现修复前 `preset=鱼眼视角,horizontalAngle=10`,修复后为 `preset=自定义,horizontalAngle=10`;本轮没有请求供应商生成图片。
|
||||
2. **资产管理收起列表**:原先只转动箭头,节点仍显示。现在收起真正隐藏节点行,展开恢复,文案改成“节点列表”,不冒充全画布分组。浏览器已验证消失和恢复。
|
||||
3. **画布/素材筛选串状态**:原先同一 filter 同时用于“脚本/导演台”和“图片/视频/音频”,切页可留下素材下拉框没有的选项。现在两套筛选各自保持;浏览器验证画布选“脚本”→素材默认为“全部”→素材选“视频”→返回画布仍为“脚本”。
|
||||
|
||||
另补验脚本合成的失败分支:本地静态服务器对真实分析请求返回 501,页面显示“工作流分析失败”,保留两行脚本和已修改对白,提示词完成数仍为 0/2。该结果只证明组件对这类失败的处理,不代表生产分析服务成功。
|
||||
|
||||
## 自动化检查
|
||||
|
||||
- 视频画布专项六组:**56 项通过,无跳过**;包括真实本地 FFmpeg 视频截帧、2/3/5 倍慢放及音轨验证。
|
||||
- 扩展 helpers 全量首轮:423 项中 421 通过,2 项模型目录用例失败。追溯到 2026-09-07 的 `e49b0707e`:目录已要求明确的多图输入契约,旧用例只有 image-to-image 标签。既有 `vip98k-reference-routing` 测试也要求不能从宽泛标签推断多图能力。
|
||||
- 补齐旧用例的数组输入 schema,并新增“只有标签/单图字符串 schema 必须拒绝”的反例,**未放宽生产筛选规则**。模型分类与参考路由专项 52 项通过。
|
||||
- 最终全量:**424 项通过,无失败、无跳过**。这 424 项是当前 `playwright.helpers.config.ts` 中的 helper/route 契约回归,**不是 424 个完整页面端到端测试,也不是全部供应商实测**。
|
||||
- TypeScript 检查通过(最终提交前重跑)。
|
||||
|
||||
复现:FFmpeg/ffprobe 加入 PATH 后执行 `pnpm exec playwright test --config=playwright.helpers.config.ts`。若未安装这两个程序,媒体测试会跳过,不能沿用“真实媒体通过”的结论。
|
||||
|
||||
## 活动入口扫描方式
|
||||
|
||||
`node scripts/audit/video-canvas-controls.cjs output.json` 用 TypeScript AST 从实际入口 `LiblibVideoCanvasReplica` 追踪本文件标识符引用,记录缺少直接事件/仅提示类控件的候选。本轮 400 个可达声明、41 个未引用函数、53 条控件候选。候选列表见 [控件扫描 JSON](video-canvas-control-scan-2026-09-17.json)。
|
||||
|
||||
这是保守静态扫描,不是运行时覆盖率:组合组件可用其他回调,submit 按钮可由 form 处理,恒为 false 的分支仍可能被扫描到。因此 **53 条候选不等于 53 个故障**。例如 VideoArchiveButton/工作流组合组件有真实回调;运镜搜索走 form submit;ZoomDock 的 compact 分支未在当前调用启用。旧 DirectorConsole3DDialog 等 41 个未引用函数未用于证明当前活动导演台故障;实际 3D 在 `components/canvas/director/` 中复核并由对应回归覆盖。
|
||||
|
||||
## 下一轮必须补齐的验证
|
||||
|
||||
- **任务生命周期**:刷新、断网重连、浏览器后台、退出重开、重复点击、批量中的取消/删除、多标签同时编辑;现有保存队列单测不能代替这些场景。
|
||||
- **生成与脚本**:逐模型/模式规格、真实多张返回、批量混合成败、脚本单镜/全部合成成功、资产生成上传、脚本到图片再到视频完整闭环。历史某条供应商线路成功不等于今天所有线路通过。
|
||||
- **素材与后期**:超过一屏的素材、跨域/过期 URL、ZIP 部分失败、音频上传格式与时长边界、网页播放器当前时间截帧、无音轨文件、长视频资源占用。
|
||||
- **3D 导演台**:全部 12 姿势/12 机位逐项视觉检查,4 类轨迹浏览器端运行;弱网/损坏/超大 GLB,WebGL 上下文丢失,切后台中断导出,多浏览器编码格式与生产上传。既有单测和历史一条 MP4 导出不足以覆盖这些情况。
|
||||
- **布局与一致性**:不同分辨率、缩放、空态/加载/失败/完成逐状态像素比对;本轮没有新增与参考站像素一致的结论。
|
||||
- **尚缺实现**:TTS/音乐/音色、智能剪辑、多画布/故事板/Agent、跨项目资产、专用后期、3D 多轨/自动帧/跟随/自由路径等,需先实现再验收。
|
||||
|
||||
本轮范围止于扫描、三处明确界面修复及回归记录。F01–F07 未因本次扫描自动视作修复;线上版本也不会因分支推送自动更新。
|
||||
28
docs/video-render-service-2026-09-17.md
Normal file
28
docs/video-render-service-2026-09-17.md
Normal file
@ -0,0 +1,28 @@
|
||||
# 视频合成独立服务实施记录
|
||||
|
||||
用户明确只迁移视频合成,其他分析、选片和任务管理保留主项目。新建独立 Node HTTP 服务,复用同一 EDL/FFmpeg 渲染实现;没有重新实现另一套预览/导出时间规则。
|
||||
|
||||
## 算力服务器
|
||||
|
||||
只读检查:shujuku3,AMD EPYC 7B12,64物理核/128线程,125GiB内存,可用约79GiB,根盘空余约151GiB(检查时点)。四张Tesla P40,三张已加载千问模型。现有 `qwen3.8-27b-q4-32k` 三个实例在18230/18231/18233,健康接口均成功;`/props` 明确 vision/video/audio 均为false,只能复用文字能力。
|
||||
|
||||
部署:专用 video-render 用户和 systemd 服务,FFmpeg 6.1.1,已有 Noto CJK 字体。CPUQuota=400%,MemoryHigh=6G,MemoryMax=8G,并发1,监听127.0.0.1:18340。文字助手指向18231,普通合成不调用模型;此轮没有新增模型推理测试或改变现有千问服务配置。
|
||||
|
||||
48 主机通过专用 SSH 隧道访问同一回环端口。新隧道密钥仅允许指定回环监听端口,固定主机公钥,服务另有随机 Bearer 认证。客户端凭据保存在 `/etc/video-render-client.env`,没有提交 Git。
|
||||
|
||||
## 已验证
|
||||
|
||||
- 真实 PySceneDetect 识别三个色块镜头;RMS识别两段有声区间;librosa识别22个节拍。
|
||||
- 真实 FFmpeg:直接拼接4秒、叠化后3.5秒,含无音轨素材、2倍速、配乐和中文字幕。
|
||||
- 独立 HTTP 服务真实测试:无凭据401、同ID重复提交幂等、不同参数409、强制杀进程后重启恢复、完成后重启仍可下载。
|
||||
- 48主机适配器→加密隧道→算力服务→读取成片→现有对象存储归档:720p横屏及1080p竖屏成功。竖屏额外覆盖三片段、非整秒边界、不同速度和0.25秒叠化。
|
||||
- 真实 React 组件操作:添加素材、生成方案、转写未就绪禁用、分割、变速、转场、排序、导出参数、关闭重开恢复、EDL下载。尚需主项目发布后的完整页面验证。
|
||||
- helpers 482项通过,1项跳过;TypeScript通过。真实PostgreSQL队列回归通过。
|
||||
|
||||
## 智能剪辑实现范围
|
||||
|
||||
四种模式、最多三版广告排序、语句/镜头候选、可选千问3.8视觉选片、节拍切镜、去静音、可编辑时间轴和字幕、方案/SRT下载、成片回到画布。模型语义请求状态不明确时禁止重复调用。广告多版本目前是候选排序轮换,不能宣称独立创意脚本生成。
|
||||
|
||||
FunASR适配与安装脚本已实现,未装权重、未做真实ASR验收,界面禁用自动转写并支持导入SRT。当前本地千问接口不能替代视觉选片,视觉分析继续使用主项目已有模型配置。
|
||||
|
||||
部署边界:独立合成服务已启动且两机链路实测通过;主项目正式应用的切换状态将在发布验证后追加。此前音频/导演台等开发分支改动不能凭本记录视为已经上线。
|
||||
169
hooks/use-video-canvas-persistence.ts
Normal file
169
hooks/use-video-canvas-persistence.ts
Normal file
@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { mergeCanvasSnapshots, type CanvasConflictChoice } from "@/lib/video-canvas/merge-snapshot";
|
||||
import {
|
||||
CanvasRevisionConflict,
|
||||
VideoCanvasSaveQueue,
|
||||
type VideoCanvasSaveStatus,
|
||||
} from "@/lib/video-canvas/save-queue";
|
||||
|
||||
export function useVideoCanvasPersistence(
|
||||
projectId: string,
|
||||
loaded: { id: string; revision: string } | null,
|
||||
snapshot: string,
|
||||
applySnapshot?: (snapshot: string) => void,
|
||||
) {
|
||||
const queueRef = useRef<VideoCanvasSaveQueue | null>(null);
|
||||
const latestSnapshot = useRef(snapshot);
|
||||
const observedSnapshot = useRef(snapshot);
|
||||
latestSnapshot.current = snapshot;
|
||||
const [state, setState] = useState<{ status: VideoCanvasSaveStatus; message?: string }>({
|
||||
status: "saved",
|
||||
});
|
||||
const [conflicts, setConflicts] = useState<string[]>([]);
|
||||
const applyRef = useRef(applySnapshot);
|
||||
applyRef.current = applySnapshot;
|
||||
const ready = loaded?.id === projectId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !loaded) return;
|
||||
let active = true;
|
||||
const queue = new VideoCanvasSaveQueue(
|
||||
latestSnapshot.current,
|
||||
loaded.revision,
|
||||
async (value, revision) => {
|
||||
const response = await fetch(`/api/canvas/${encodeURIComponent(projectId)}`, {
|
||||
method: "PUT",
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...JSON.parse(value),
|
||||
workspaceType: "video",
|
||||
baseCanvasUpdatedAt: revision,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.status === 409) {
|
||||
const latest = await fetch(`/api/canvas/${encodeURIComponent(projectId)}`, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const remote = await latest.json();
|
||||
if (!latest.ok || !remote.success || !remote.data?.updatedAt)
|
||||
throw Error("读取其他页面的修改失败,当前草稿已保留,请重试");
|
||||
throw new CanvasRevisionConflict(
|
||||
"其他页面修改了相同内容,请选择冲突部分保留哪一版",
|
||||
JSON.stringify({ title: remote.data.title, canvasData: remote.data.canvasData }),
|
||||
String(remote.data.updatedAt),
|
||||
);
|
||||
}
|
||||
if (!response.ok || !result.success || !result.data?.updatedAt) {
|
||||
throw new Error(result.error || "保存失败,请点击重试");
|
||||
}
|
||||
return String(result.data.updatedAt);
|
||||
},
|
||||
(status, message) => {
|
||||
if (active) setState({ status, message });
|
||||
},
|
||||
applyRef.current
|
||||
? {
|
||||
merge: mergeCanvasSnapshots,
|
||||
apply: (value) => {
|
||||
if (active) applyRef.current?.(value);
|
||||
},
|
||||
conflict: (fields) => {
|
||||
if (active) setConflicts(fields);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
queueRef.current = queue;
|
||||
observedSnapshot.current = latestSnapshot.current;
|
||||
setConflicts([]);
|
||||
setState({ status: "saved" });
|
||||
const backupKey = `video-canvas-draft:${projectId}`;
|
||||
try {
|
||||
const backup = JSON.parse(sessionStorage.getItem(backupKey) || "null");
|
||||
if (backup?.base && backup?.snapshot) queue.restoreBackup(backup.base, backup.snapshot);
|
||||
} catch {
|
||||
/* Ignore corrupt draft cache; server content is intact. */
|
||||
}
|
||||
const retainDraft = () => {
|
||||
try {
|
||||
if (queue.dirty) sessionStorage.setItem(backupKey, JSON.stringify(queue.backup));
|
||||
else sessionStorage.removeItem(backupKey);
|
||||
} catch {
|
||||
/* The unsaved-change warning and JSON export remain available when browser storage is full. */
|
||||
}
|
||||
};
|
||||
// Fixed cadence also saves while generation progress is continuously changing.
|
||||
const interval = window.setInterval(() => {
|
||||
retainDraft();
|
||||
if (queue.dirty && !queue.failed) void queue.flush().then(retainDraft);
|
||||
}, 1500);
|
||||
const onHidden = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
retainDraft();
|
||||
void queue.flush().then(retainDraft);
|
||||
}
|
||||
};
|
||||
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!queue.dirty) return;
|
||||
retainDraft();
|
||||
void queue.flush();
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
document.addEventListener("visibilitychange", onHidden);
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(interval);
|
||||
document.removeEventListener("visibilitychange", onHidden);
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
retainDraft();
|
||||
void queue.flush().then(retainDraft);
|
||||
if (queueRef.current === queue) queueRef.current = null;
|
||||
};
|
||||
}, [projectId, ready, loaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && observedSnapshot.current !== snapshot) {
|
||||
observedSnapshot.current = snapshot;
|
||||
queueRef.current?.update(snapshot);
|
||||
}
|
||||
}, [snapshot, ready]);
|
||||
|
||||
const save = useCallback(() => {
|
||||
if (!ready) return;
|
||||
queueRef.current?.update(latestSnapshot.current);
|
||||
void queueRef.current?.flush();
|
||||
}, [ready]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
save();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [save]);
|
||||
|
||||
const resolveConflict = useCallback(
|
||||
(choice: CanvasConflictChoice) => queueRef.current?.resolveConflict(choice),
|
||||
[],
|
||||
);
|
||||
const downloadDraft = useCallback(() => {
|
||||
const data = queueRef.current?.backup.snapshot || latestSnapshot.current;
|
||||
const url = URL.createObjectURL(new Blob([data], { type: "application/json" }));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `video-canvas-${projectId}-draft.json`;
|
||||
anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}, [projectId]);
|
||||
return { ...state, save, ready, conflicts, resolveConflict, downloadDraft };
|
||||
}
|
||||
274
hooks/use-video-canvas-tasks.ts
Normal file
274
hooks/use-video-canvas-tasks.ts
Normal file
@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
reconcileCanvasTasks,
|
||||
type CanvasGenerationTask,
|
||||
type CanvasTaskRequest,
|
||||
} from "@/lib/video-canvas/task-contract";
|
||||
|
||||
type Node = CanvasTaskRequest["node"];
|
||||
type Edge = CanvasTaskRequest["edges"][number];
|
||||
const storageKey = (projectId: string) => `video-canvas-outbox:${projectId}`;
|
||||
function outbox(projectId: string): CanvasTaskRequest[] {
|
||||
try {
|
||||
const entries = JSON.parse(localStorage.getItem(storageKey(projectId)) || "[]");
|
||||
return Array.isArray(entries)
|
||||
? entries.filter(
|
||||
(item) =>
|
||||
item?.projectId === projectId && typeof item?.requestId === "string" && item?.node?.id,
|
||||
)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function remember(request: CanvasTaskRequest) {
|
||||
const entries = outbox(request.projectId).filter((item) => item.requestId !== request.requestId);
|
||||
localStorage.setItem(storageKey(request.projectId), JSON.stringify([...entries, request]));
|
||||
}
|
||||
function forget(projectId: string, requestId: string) {
|
||||
localStorage.setItem(
|
||||
storageKey(projectId),
|
||||
JSON.stringify(outbox(projectId).filter((item) => item.requestId !== requestId)),
|
||||
);
|
||||
}
|
||||
export function useVideoCanvasTasks<T extends Node>(
|
||||
projectId: string,
|
||||
loaded: { id: string; revision: string } | null,
|
||||
setNodes: React.Dispatch<React.SetStateAction<T[]>>,
|
||||
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>,
|
||||
) {
|
||||
const [tasks, setTasks] = useState<CanvasGenerationTask[]>([]);
|
||||
const hiddenNodes = useRef(new Set<string>());
|
||||
const recoveredEdges = useRef(new Set<string>());
|
||||
const refreshRef = useRef<() => void>(() => {});
|
||||
const activeProject = useRef(projectId);
|
||||
activeProject.current = projectId;
|
||||
const apply = useCallback(
|
||||
(tasks: CanvasGenerationTask[]) => {
|
||||
if (activeProject.current !== projectId || !loaded) return;
|
||||
setTasks((current) => {
|
||||
const next = new Map(current.map((task) => [task.id, task]));
|
||||
for (const task of tasks) next.set(task.id, task);
|
||||
return [...next.values()];
|
||||
});
|
||||
const visibleTasks = tasks.map((task) => ({
|
||||
...task,
|
||||
request: {
|
||||
...task.request,
|
||||
hiddenNodeIds: [...(task.request.hiddenNodeIds || []), ...hiddenNodes.current],
|
||||
},
|
||||
}));
|
||||
setNodes((nodes) => reconcileCanvasTasks(nodes as any, visibleTasks, loaded.revision) as T[]);
|
||||
const recoverable = visibleTasks.filter((task) => {
|
||||
if (recoveredEdges.current.has(task.id)) return false;
|
||||
recoveredEdges.current.add(task.id);
|
||||
return Date.parse(task.createdAt) > Date.parse(loaded.revision);
|
||||
});
|
||||
setEdges((edges) => {
|
||||
const existing = new Set(edges.map((edge) => `${edge.source}>${edge.target}`));
|
||||
const extras = recoverable
|
||||
.flatMap((task) =>
|
||||
task.request.edges.filter(
|
||||
(edge) =>
|
||||
!task.request.hiddenNodeIds.includes(edge.source) &&
|
||||
!task.request.hiddenNodeIds.includes(edge.target),
|
||||
),
|
||||
)
|
||||
.filter((edge) => {
|
||||
const key = `${edge.source}>${edge.target}`;
|
||||
if (existing.has(key)) return false;
|
||||
existing.add(key);
|
||||
return true;
|
||||
});
|
||||
return extras.length ? [...edges, ...extras] : edges;
|
||||
});
|
||||
},
|
||||
[projectId, loaded, setNodes, setEdges],
|
||||
);
|
||||
const send = useCallback(
|
||||
async (request: CanvasTaskRequest) => {
|
||||
const response = await fetch("/api/canvas/generation-tasks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok || !body.success) {
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
forget(projectId, request.requestId);
|
||||
if (activeProject.current === projectId)
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === request.node.id && node.draftValues?.taskRequestId === request.requestId
|
||||
? {
|
||||
...node,
|
||||
draftValues: {
|
||||
...node.draftValues,
|
||||
generationStatus: "failed",
|
||||
generationError: body.error,
|
||||
},
|
||||
}
|
||||
: node,
|
||||
),
|
||||
);
|
||||
}
|
||||
throw Error(body.error || "提交暂时失败,恢复连接后自动核对");
|
||||
}
|
||||
forget(projectId, request.requestId);
|
||||
if (activeProject.current !== projectId) return body.task as CanvasGenerationTask;
|
||||
// The server may return an existing task for the same active node.
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === request.node.id
|
||||
? {
|
||||
...node,
|
||||
draftValues: {
|
||||
...node.draftValues,
|
||||
taskRequestId: body.task.requestId,
|
||||
generationTaskId: body.task.id,
|
||||
},
|
||||
}
|
||||
: node,
|
||||
),
|
||||
);
|
||||
apply([body.task]);
|
||||
return body.task as CanvasGenerationTask;
|
||||
},
|
||||
[projectId, apply, setNodes],
|
||||
);
|
||||
const submit = useCallback(
|
||||
async (
|
||||
node: T,
|
||||
kind: CanvasTaskRequest["kind"],
|
||||
params: Record<string, unknown>,
|
||||
edges: Edge[] = [],
|
||||
attachment?: CanvasTaskRequest["attachment"],
|
||||
) => {
|
||||
if (!loaded || loaded.id !== projectId) throw Error("画布尚未加载完成,请稍后提交");
|
||||
const requestId = crypto.randomUUID();
|
||||
const snapshot = {
|
||||
...node,
|
||||
draftValues: {
|
||||
...node.draftValues,
|
||||
taskRequestId: requestId,
|
||||
generationTaskId: undefined,
|
||||
appliedTaskStatus: undefined,
|
||||
generationStatus: "pending",
|
||||
generationProgress: 0,
|
||||
pendingLabel: "提交中",
|
||||
generationError: undefined,
|
||||
},
|
||||
};
|
||||
const request: CanvasTaskRequest = {
|
||||
projectId,
|
||||
requestId,
|
||||
kind,
|
||||
node: snapshot,
|
||||
params,
|
||||
edges,
|
||||
attachment,
|
||||
};
|
||||
// Persist before POST: a refresh during an uncertain network response retries the same ID.
|
||||
remember(request);
|
||||
setNodes((nodes) => nodes.map((item) => (item.id === node.id ? snapshot : item)));
|
||||
try {
|
||||
await send(request);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "连接中断,恢复后将核对任务,请勿重复提交",
|
||||
);
|
||||
}
|
||||
refreshRef.current();
|
||||
},
|
||||
[loaded, projectId, send, setNodes],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!loaded || loaded.id !== projectId) return;
|
||||
hiddenNodes.current.clear();
|
||||
recoveredEdges.current.clear();
|
||||
setTasks([]);
|
||||
let stopped = false,
|
||||
running = false;
|
||||
const refresh = async () => {
|
||||
if (stopped || running) return;
|
||||
running = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/canvas/generation-tasks?projectId=${encodeURIComponent(projectId)}`,
|
||||
{ cache: "no-store", signal: AbortSignal.timeout(15000) },
|
||||
);
|
||||
if (!response.ok) return;
|
||||
const body = await response.json();
|
||||
if (stopped || !body.success) return;
|
||||
apply(body.tasks);
|
||||
const accepted = new Map<string, CanvasGenerationTask>(
|
||||
body.tasks.map((task: CanvasGenerationTask) => [task.requestId, task]),
|
||||
);
|
||||
for (const request of outbox(projectId)) {
|
||||
if (stopped) break;
|
||||
if (accepted.has(request.requestId)) {
|
||||
forget(projectId, request.requestId);
|
||||
continue;
|
||||
}
|
||||
await send(request);
|
||||
}
|
||||
} catch {
|
||||
/* Network loss is not a generation failure. The next online/focus event queries again. */
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
refreshRef.current = () => void refresh();
|
||||
const wake = () => {
|
||||
if (document.visibilityState === "visible") void refresh();
|
||||
};
|
||||
const timer = setInterval(() => void refresh(), 5000);
|
||||
window.addEventListener("online", wake);
|
||||
window.addEventListener("focus", wake);
|
||||
document.addEventListener("visibilitychange", wake);
|
||||
void refresh();
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
window.removeEventListener("online", wake);
|
||||
window.removeEventListener("focus", wake);
|
||||
document.removeEventListener("visibilitychange", wake);
|
||||
};
|
||||
}, [projectId, loaded, apply, send]);
|
||||
const cancel = useCallback(
|
||||
async (taskId: string) => {
|
||||
const response = await fetch("/api/canvas/generation-tasks", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectId, taskId }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (body.task) apply([body.task]);
|
||||
if (!response.ok || !body.success) throw Error(body.error || "取消失败,请刷新任务状态");
|
||||
refreshRef.current();
|
||||
},
|
||||
[projectId, apply],
|
||||
);
|
||||
const dismiss = useCallback(
|
||||
async (nodeId: string, taskId?: string) => {
|
||||
hiddenNodes.current.add(nodeId);
|
||||
if (!taskId) return;
|
||||
const response = await fetch("/api/canvas/generation-tasks", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectId, taskId, nodeId }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
hiddenNodes.current.delete(nodeId);
|
||||
throw Error("移除结果失败,请重试");
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
return { submit, cancel, dismiss, tasks, refresh: () => refreshRef.current() };
|
||||
}
|
||||
93
lib/ai/providers/aliyun-canvas-video.ts
Normal file
93
lib/ai/providers/aliyun-canvas-video.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import {
|
||||
ProviderTaskFailedError,
|
||||
type ProviderTaskLifecycle,
|
||||
} from "@/lib/video-canvas/video-task-lifecycle";
|
||||
import type { ExecuteDirectVideoGenerationParams } from "@/lib/ai/tools/generate-video";
|
||||
|
||||
// This adapter deliberately exposes only the official model whose contract we support.
|
||||
export const ALIYUN_CANVAS_VIDEO_MODEL = "wan2.6-i2v-flash";
|
||||
|
||||
export function buildAliyunCanvasVideoRequest(params: ExecuteDirectVideoGenerationParams, modelId: string) {
|
||||
if (modelId !== ALIYUN_CANVAS_VIDEO_MODEL) throw new Error("当前阿里云视频模型尚未接入画布");
|
||||
if (params.mode !== "first-frame" || params.images?.length !== 1) throw new Error("万相首帧生视频需要一张参考图");
|
||||
if (params.audioUrls?.length) throw new Error("此画布接入暂不支持参考音频");
|
||||
const duration = params.duration ?? 5;
|
||||
const resolution = params.resolution ?? "720p";
|
||||
if (!Number.isInteger(duration) || duration < 2 || duration > 15) throw new Error("万相视频时长需为 2–15 秒整数");
|
||||
if (!["720p", "1080p"].includes(resolution)) throw new Error("万相视频仅支持 720P / 1080P");
|
||||
if (params.cameraFixed || params.returnLastFrame) throw new Error("此万相接入不支持固定镜头参数或返回尾帧参数");
|
||||
return {
|
||||
model: modelId,
|
||||
input: { prompt: params.prompt, img_url: params.images[0] },
|
||||
parameters: {
|
||||
resolution: resolution.toUpperCase(), duration,
|
||||
// The upstream derives aspect ratio from the input image, not a ratio parameter.
|
||||
audio: params.generateAudio ?? false,
|
||||
prompt_extend: false, watermark: params.watermark ?? false,
|
||||
...(params.seed != null ? { seed: params.seed } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeAliyunCanvasVideo(
|
||||
params: ExecuteDirectVideoGenerationParams & {
|
||||
modelId: string;
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
lifecycle?: ProviderTaskLifecycle;
|
||||
},
|
||||
runtime = {
|
||||
fetch: globalThis.fetch,
|
||||
sleep: (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
||||
},
|
||||
) {
|
||||
const body = buildAliyunCanvasVideoRequest(params, params.modelId);
|
||||
const base = new URL(params.baseURL);
|
||||
base.pathname = "/api/v1";
|
||||
base.search = "";
|
||||
base.hash = "";
|
||||
const headers = { Authorization: `Bearer ${params.apiKey}`, "Content-Type": "application/json", "X-DashScope-Async": "enable" };
|
||||
const deadline = Date.now() + 275_000;
|
||||
let taskId = params.lifecycle?.resume?.taskId || "";
|
||||
const request = async (path: string, init: RequestInit = {}) => {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) throw new Error(`阿里云视频查询超时,任务 ${taskId},请勿重复生成`);
|
||||
const response = await runtime.fetch(`${base.toString().replace(/\/$/, "")}${path}`, {
|
||||
...init, headers, signal: AbortSignal.timeout(Math.min(30_000, remaining)),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(`阿里云视频请求失败(${response.status}):${data.code || data.error?.code || "UnknownError"}${taskId ? `,任务 ${taskId}` : ""}`);
|
||||
return data;
|
||||
};
|
||||
await params.lifecycle?.assertActive();
|
||||
if (!taskId) {
|
||||
const submitted = await request("/services/aigc/video-generation/video-synthesis", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
taskId = submitted.output?.task_id;
|
||||
if (!taskId) throw new Error("阿里云未返回视频任务 ID");
|
||||
await params.lifecycle?.accepted({ taskId });
|
||||
}
|
||||
await params.onProgress?.(`阿里云视频任务已提交:${taskId}`);
|
||||
while (Date.now() < deadline) {
|
||||
await runtime.sleep(Math.min(5_000, Math.max(0, deadline - Date.now())));
|
||||
await params.lifecycle?.assertActive();
|
||||
const result = await request(`/tasks/${encodeURIComponent(taskId)}`);
|
||||
const output = result.output || {};
|
||||
if (output.task_status === "SUCCEEDED") {
|
||||
if (!/^https?:\/\//i.test(output.video_url || "")) throw new Error("阿里云任务完成但没有返回视频");
|
||||
return { url: output.video_url as string, metadata: {
|
||||
provider: "aliyun", taskId, requestId: result.request_id,
|
||||
duration: body.parameters.duration, resolution: params.resolution ?? "720p",
|
||||
generateAudio: body.parameters.audio,
|
||||
} };
|
||||
}
|
||||
if (["FAILED", "CANCELED", "UNKNOWN"].includes(output.task_status)) {
|
||||
throw new ProviderTaskFailedError(
|
||||
`阿里云视频生成失败:${output.code || result.code || output.task_status},任务 ${taskId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`阿里云视频查询超时,任务 ${taskId},请勿重复生成`);
|
||||
}
|
||||
@ -1,3 +1,7 @@
|
||||
import {
|
||||
ProviderTaskFailedError,
|
||||
type ProviderTaskLifecycle,
|
||||
} from "@/lib/video-canvas/video-task-lifecycle";
|
||||
import "server-only";
|
||||
|
||||
import sharp from "sharp";
|
||||
@ -280,11 +284,13 @@ async function waitForTask(params: {
|
||||
baseURL?: string;
|
||||
endpoint: string;
|
||||
taskId: string;
|
||||
lifecycle?: ProviderTaskLifecycle;
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
let transientFailures = 0;
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
await delay(POLL_INTERVAL_MS);
|
||||
await params.lifecycle?.assertActive();
|
||||
try {
|
||||
const endpoint = params.endpoint.replace(
|
||||
/\{(?:request_id|id|task_id)\}/g,
|
||||
@ -313,9 +319,10 @@ async function waitForTask(params: {
|
||||
return outputs;
|
||||
}
|
||||
if (["failed", "error", "timeout", "canceled", "cancelled"].includes(status)) {
|
||||
throw new Error(extractErrorMessage(payload, "Atlas Cloud 图片生成失败"));
|
||||
throw new ProviderTaskFailedError(extractErrorMessage(payload, "Atlas Cloud 任务失败"));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ProviderTaskFailedError) throw error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!/^HTTP (?:429|5\d\d)$/.test(message) && !/fetch|network|abort/i.test(message)) {
|
||||
throw error;
|
||||
@ -379,6 +386,7 @@ export async function executeAtlasCloudVideoGeneration(params: {
|
||||
modelId: string;
|
||||
modelConfig: AtlasCloudVideoModelConfig;
|
||||
options: VideoGenerationOptions;
|
||||
lifecycle?: ProviderTaskLifecycle;
|
||||
}) {
|
||||
const runtime = params.modelConfig.atlasCloud;
|
||||
if (!runtime) {
|
||||
@ -393,21 +401,30 @@ export async function executeAtlasCloudVideoGeneration(params: {
|
||||
0,
|
||||
Math.max(0, Math.min(14, runtime.maxReferenceImages || 0))
|
||||
);
|
||||
const referenceImages = await Promise.all(
|
||||
uniqueImages.map((url) => uploadReferenceMedia(params.baseURL, params.apiKey, url))
|
||||
);
|
||||
const { body } = createAtlasCloudVideoRequestBody({
|
||||
modelId: params.modelId,
|
||||
options: params.options,
|
||||
referenceImages,
|
||||
modelConfig: params.modelConfig,
|
||||
});
|
||||
const submitted = await submitTask({
|
||||
apiKey: params.apiKey,
|
||||
baseURL: params.baseURL,
|
||||
endpoint: runtime.submitEndpoint,
|
||||
body,
|
||||
});
|
||||
const referenceImages = params.lifecycle?.resume
|
||||
? []
|
||||
: await Promise.all(
|
||||
uniqueImages.map((url) => uploadReferenceMedia(params.baseURL, params.apiKey, url)),
|
||||
);
|
||||
const { body } = params.lifecycle?.resume
|
||||
? { body: {} }
|
||||
: createAtlasCloudVideoRequestBody({
|
||||
modelId: params.modelId,
|
||||
options: params.options,
|
||||
referenceImages,
|
||||
modelConfig: params.modelConfig,
|
||||
});
|
||||
await params.lifecycle?.assertActive();
|
||||
const submitted = params.lifecycle?.resume
|
||||
? { outputs: [] as string[], taskId: params.lifecycle.resume.taskId }
|
||||
: await submitTask({
|
||||
apiKey: params.apiKey,
|
||||
baseURL: params.baseURL,
|
||||
endpoint: runtime.submitEndpoint,
|
||||
body,
|
||||
});
|
||||
if (submitted.taskId && !params.lifecycle?.resume)
|
||||
await params.lifecycle?.accepted({ taskId: submitted.taskId });
|
||||
const outputUrls = submitted.outputs.length
|
||||
? submitted.outputs
|
||||
: await waitForTask({
|
||||
@ -415,6 +432,7 @@ export async function executeAtlasCloudVideoGeneration(params: {
|
||||
baseURL: params.baseURL,
|
||||
endpoint: runtime.pollEndpoint,
|
||||
taskId: submitted.taskId,
|
||||
lifecycle: params.lifecycle,
|
||||
});
|
||||
const videoUrl = outputUrls[0];
|
||||
if (!videoUrl) {
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
import {
|
||||
ProviderTaskFailedError,
|
||||
type ProviderTaskLifecycle,
|
||||
} from "@/lib/video-canvas/video-task-lifecycle";
|
||||
import "server-only";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
@ -415,7 +419,14 @@ async function fetchTaskResult(params: {
|
||||
});
|
||||
const payload = await parseJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status})`);
|
||||
logger.error("task result request failed", {
|
||||
taskId: params.taskId,
|
||||
httpStatus: response.status,
|
||||
status: extractStatus(payload),
|
||||
providerMessage: extractErrorMessage(payload),
|
||||
...extractPayloadShape(payload),
|
||||
});
|
||||
throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status}, task_id: ${params.taskId})`);
|
||||
}
|
||||
|
||||
const status = extractStatus(payload);
|
||||
@ -1124,6 +1135,7 @@ async function submitTask(params: {
|
||||
attempt,
|
||||
status: response.status,
|
||||
providerRequestId: response.headers.get("x-request-id"),
|
||||
taskId: response.ok ? extractTaskId(payload) : undefined,
|
||||
attemptDurationMs: Date.now() - attemptStartedAt,
|
||||
totalDurationMs: Date.now() - submissionStartedAt,
|
||||
});
|
||||
@ -1203,6 +1215,7 @@ export type Vip98kExternalTaskStatus = {
|
||||
|
||||
export async function submitVip98kExternalTask(params: {
|
||||
apiKey: string;
|
||||
durable?: boolean;
|
||||
baseURL?: string | null;
|
||||
modelId: string;
|
||||
kind: string;
|
||||
@ -1275,7 +1288,7 @@ export async function submitVip98kExternalTask(params: {
|
||||
params.modelId,
|
||||
runtimeConfig,
|
||||
),
|
||||
maxSubmissionAttempts: runtimeConfig.maxSubmissionAttempts,
|
||||
maxSubmissionAttempts: params.durable ? 1 : runtimeConfig.maxSubmissionAttempts,
|
||||
bodyKeys: Object.keys(body).sort(),
|
||||
requestedSize: body.size,
|
||||
requestedAspectRatio: body.aspect_ratio || body.aspectRatio || body.ratio,
|
||||
@ -1294,7 +1307,7 @@ export async function submitVip98kExternalTask(params: {
|
||||
params.modelId,
|
||||
runtimeConfig,
|
||||
),
|
||||
maxSubmissionAttempts: runtimeConfig.maxSubmissionAttempts,
|
||||
maxSubmissionAttempts: params.durable ? 1 : runtimeConfig.maxSubmissionAttempts,
|
||||
submissionRetryBaseDelayMs: runtimeConfig.submissionRetryBaseDelayMs,
|
||||
});
|
||||
const resultPath = submitted.resultPath || invocation?.resultPath || "";
|
||||
@ -1344,6 +1357,7 @@ async function waitForTask(params: {
|
||||
apiKey: string;
|
||||
baseURL?: string | null;
|
||||
taskId: string;
|
||||
lifecycle?: ProviderTaskLifecycle;
|
||||
modelId: string;
|
||||
resultPath?: string;
|
||||
modelConfig: Vip98kModelRuntimeConfig;
|
||||
@ -1367,6 +1381,7 @@ async function waitForTask(params: {
|
||||
? params.resultPath
|
||||
: `${baseURL}${params.resultPath.startsWith("/") ? "" : "/"}${params.resultPath}`
|
||||
: `${baseURL}/api/external/task/result?taskId=${encodeURIComponent(params.taskId)}`;
|
||||
await params.lifecycle?.assertActive();
|
||||
const { payload, status, outputUrls: outputs } = await fetchTaskResult({
|
||||
apiKey: params.apiKey,
|
||||
baseURL,
|
||||
@ -1397,7 +1412,7 @@ async function waitForTask(params: {
|
||||
...extractPayloadShape(payload),
|
||||
});
|
||||
const detail = providerMessage ? `: ${providerMessage}` : "";
|
||||
throw new Error(
|
||||
throw new ProviderTaskFailedError(
|
||||
`VIP98K task failed${detail} (task_id: ${params.taskId}, model: ${params.modelId})`,
|
||||
);
|
||||
}
|
||||
@ -1605,10 +1620,22 @@ export async function executeVip98kExternalTask(params: {
|
||||
videoParams?: Record<string, unknown>;
|
||||
imageParams?: Record<string, unknown>;
|
||||
modelConfig?: Vip98kModelRuntimeConfig;
|
||||
lifecycle?: ProviderTaskLifecycle;
|
||||
}): Promise<Vip98kExternalTaskResult> {
|
||||
const submitted = await submitVip98kExternalTask({
|
||||
...params,
|
||||
});
|
||||
await params.lifecycle?.assertActive();
|
||||
const submitted = params.lifecycle?.resume
|
||||
? {
|
||||
...params.lifecycle.resume,
|
||||
outputUrls: [] as string[],
|
||||
payload: null,
|
||||
resultPath: params.lifecycle.resume.resultPath || "",
|
||||
}
|
||||
: await submitVip98kExternalTask({ ...params, durable: Boolean(params.lifecycle) });
|
||||
if (submitted.taskId && !params.lifecycle?.resume)
|
||||
await params.lifecycle?.accepted({
|
||||
taskId: submitted.taskId,
|
||||
resultPath: submitted.resultPath,
|
||||
});
|
||||
|
||||
if (submitted.outputUrls.length > 0) {
|
||||
return {
|
||||
@ -1628,6 +1655,7 @@ export async function executeVip98kExternalTask(params: {
|
||||
modelId: params.modelId,
|
||||
resultPath: submitted.resultPath,
|
||||
modelConfig: params.modelConfig || {},
|
||||
lifecycle: params.lifecycle,
|
||||
});
|
||||
return {
|
||||
taskId: submitted.taskId,
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
import {
|
||||
ProviderTaskFailedError,
|
||||
type ProviderTaskLifecycle,
|
||||
} from "@/lib/video-canvas/video-task-lifecycle";
|
||||
/**
|
||||
* 火山引擎视频生成 Provider(豆包 Seedance 模型)
|
||||
* 支持:文生视频、图生视频(首帧/首尾帧/参考图)
|
||||
@ -420,6 +424,7 @@ async function sendCreateVideoTaskRequest(params: {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
requestBody: Record<string, any>;
|
||||
durable?: boolean;
|
||||
}) {
|
||||
return fetchWithRetry(
|
||||
params.url,
|
||||
@ -434,7 +439,7 @@ async function sendCreateVideoTaskRequest(params: {
|
||||
{
|
||||
label: "create-task",
|
||||
timeoutMs: 90000,
|
||||
retries: 2,
|
||||
retries: params.durable ? 0 : 2,
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -530,7 +535,8 @@ function buildFullPrompt(options: VideoGenerationOptions): string {
|
||||
async function createVideoTask(
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
options: VideoGenerationOptions
|
||||
options: VideoGenerationOptions,
|
||||
durable = false,
|
||||
): Promise<{ taskId: string; inputImageUrls: any }> {
|
||||
// 火山引擎官方文档:/contents/generations/tasks(复数形式)
|
||||
// https://www.volcengine.com/docs/82379/1520758
|
||||
@ -564,6 +570,7 @@ async function createVideoTask(
|
||||
url,
|
||||
apiKey,
|
||||
requestBody: payload.requestBody,
|
||||
durable,
|
||||
});
|
||||
|
||||
console.log("[Volcano Video] Response status:", response.status);
|
||||
@ -589,6 +596,7 @@ async function createVideoTask(
|
||||
url,
|
||||
apiKey,
|
||||
requestBody: payload.requestBody,
|
||||
durable,
|
||||
});
|
||||
console.log("[Volcano Video] Retry response status:", response.status);
|
||||
if (!response.ok) {
|
||||
@ -789,17 +797,23 @@ export async function doGenerate(
|
||||
export async function* doGenerateStream(
|
||||
apiKey: string,
|
||||
baseURL: string,
|
||||
options: VideoGenerationOptions
|
||||
options: VideoGenerationOptions,
|
||||
lifecycle?: ProviderTaskLifecycle,
|
||||
): AsyncGenerator<string, void, unknown> {
|
||||
try {
|
||||
// 1. 创建任务(不输出进度文字)
|
||||
const { taskId, inputImageUrls } = await createVideoTask(baseURL, apiKey, options);
|
||||
await lifecycle?.assertActive();
|
||||
const { taskId, inputImageUrls } = lifecycle?.resume
|
||||
? { taskId: lifecycle.resume.taskId, inputImageUrls: {} }
|
||||
: await createVideoTask(baseURL, apiKey, options, Boolean(lifecycle));
|
||||
if (!lifecycle?.resume) await lifecycle?.accepted({ taskId });
|
||||
|
||||
// 2. 轮询任务状态(静默等待)
|
||||
const maxRetries = 60;
|
||||
const pollInterval = 10000;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
await lifecycle?.assertActive();
|
||||
let status: any;
|
||||
try {
|
||||
status = await fetchTaskStatus(baseURL, apiKey, taskId);
|
||||
@ -852,11 +866,11 @@ export async function* doGenerateStream(
|
||||
|
||||
if (status.status === "failed") {
|
||||
const errorMsg = status.error?.message || status.message || "未知错误";
|
||||
throw new Error(`视频生成失败: ${errorMsg}`);
|
||||
throw new ProviderTaskFailedError(`视频生成失败: ${errorMsg}`);
|
||||
}
|
||||
|
||||
if (status.status === "cancelled") {
|
||||
throw new Error("视频生成任务已取消");
|
||||
throw new ProviderTaskFailedError("视频生成任务已取消");
|
||||
}
|
||||
|
||||
// 静默等待,不输出进度文字
|
||||
|
||||
@ -1,8 +1,17 @@
|
||||
import {
|
||||
bindVideoTaskLifecycle,
|
||||
ProviderTaskFailedError,
|
||||
ProviderTaskUncertainError,
|
||||
type ProviderTaskLifecycle,
|
||||
type VideoTaskLifecycle,
|
||||
} from "@/lib/video-canvas/video-task-lifecycle";
|
||||
import { executeVip98kExternalTask } from "@/lib/ai/providers/vip98k";
|
||||
/**
|
||||
* 视频生成工具
|
||||
* 支持火山引擎视频生成的多种模式
|
||||
*/
|
||||
|
||||
import { executeAliyunCanvasVideo } from "@/lib/ai/providers/aliyun-canvas-video";
|
||||
import { getCanvasVideoModes, isExecutableCanvasVideoModel, supportsCanvasAudioVideo, validateCanvasVideoInputs, getCanvasVideoParameters } from "@/lib/video-canvas/model-matching";
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
@ -36,6 +45,7 @@ export interface ExecuteDirectVideoGenerationParams {
|
||||
returnLastFrame?: boolean;
|
||||
preferredModelRef?: string;
|
||||
onProgress?: (chunk: string) => void | Promise<void>;
|
||||
taskLifecycle?: VideoTaskLifecycle;
|
||||
}
|
||||
|
||||
export interface ExecuteDirectVideoGenerationResult {
|
||||
@ -472,6 +482,7 @@ async function executeAgnesVideoGeneration(params: {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
options: VideoGenerationOptions;
|
||||
lifecycle?: ProviderTaskLifecycle;
|
||||
}) {
|
||||
const baseUrl = String(params.baseUrl || "https://apihub.agnes-ai.com/v1").replace(/\/+$/, "");
|
||||
const createUrl = `${baseUrl}/videos`;
|
||||
@ -500,14 +511,20 @@ async function executeAgnesVideoGeneration(params: {
|
||||
body.mode = "ti2vid";
|
||||
}
|
||||
|
||||
const createResponse = await fetch(createUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
await params.lifecycle?.assertActive();
|
||||
const createResponse = params.lifecycle?.resume
|
||||
? Response.json({
|
||||
video_id: params.lifecycle.resume.videoId,
|
||||
task_id: params.lifecycle.resume.taskId,
|
||||
})
|
||||
: await fetch(createUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const createText = await createResponse.text();
|
||||
let createData: Record<string, any> = {};
|
||||
try {
|
||||
@ -527,16 +544,20 @@ async function executeAgnesVideoGeneration(params: {
|
||||
throw new Error("Agnes 视频任务创建成功但没有返回 video_id/task_id");
|
||||
}
|
||||
|
||||
if (!params.lifecycle?.resume)
|
||||
await params.lifecycle?.accepted({ taskId: taskId || videoId, videoId });
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = 285_000;
|
||||
let lastData = createData;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
await params.lifecycle?.assertActive();
|
||||
const resultUrl = videoId
|
||||
? buildAgnesResultUrl(baseUrl, videoId, params.options.model)
|
||||
: `${baseUrl}/videos/${encodeURIComponent(taskId)}`;
|
||||
const resultResponse = await fetch(resultUrl, {
|
||||
headers: { Authorization: `Bearer ${params.apiKey}` },
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
const resultText = await resultResponse.text();
|
||||
let resultData: Record<string, any> = {};
|
||||
@ -562,7 +583,9 @@ async function executeAgnesVideoGeneration(params: {
|
||||
};
|
||||
}
|
||||
if (status === "failed") {
|
||||
throw new Error(`Agnes 视频生成失败: ${resultData?.error?.message || resultData?.error || "任务失败"}`);
|
||||
throw new ProviderTaskFailedError(
|
||||
`Agnes 视频生成失败: ${resultData?.error?.message || resultData?.error || "任务失败"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -614,7 +637,39 @@ async function executeVip98kVideoGeneration(params: {
|
||||
generationParams: Partial<VideoGenerationOptions>;
|
||||
}): Promise<ExecuteDirectVideoGenerationResult> {
|
||||
const selected = params.selected.model;
|
||||
params.generationParams.duration ??= params.selected.capabilities.durationOptions?.[0] || params.selected.capabilities.durationRange?.min || 5;
|
||||
params.generationParams.duration ??=
|
||||
params.selected.capabilities.durationOptions?.[0] ||
|
||||
params.selected.capabilities.durationRange?.min ||
|
||||
5;
|
||||
const lifecycle = bindVideoTaskLifecycle(params.request.taskLifecycle, selected, "vip98k");
|
||||
if (lifecycle) {
|
||||
const result = await executeVip98kExternalTask({
|
||||
apiKey: selected.provider.apiKey,
|
||||
baseURL: selected.provider.baseUrl,
|
||||
modelId: selected.model.modelId,
|
||||
kind: "video",
|
||||
prompt: params.prompt,
|
||||
imageUrls: params.request.images,
|
||||
videoParams: params.generationParams as Record<string, unknown>,
|
||||
modelConfig: {
|
||||
imageGenConfig: selected.model.imageGenConfig || {},
|
||||
capabilities: selected.model.capabilities,
|
||||
},
|
||||
lifecycle,
|
||||
});
|
||||
const videoUrl =
|
||||
result.outputUrls.find((url) => /\.(mp4|webm|mov|m4v)(?:[?#].*)?$/i.test(url)) ||
|
||||
result.outputUrls[0];
|
||||
if (!videoUrl) throw new Error("VIP98K 视频任务完成但没有返回视频");
|
||||
return {
|
||||
success: true,
|
||||
videoUrl,
|
||||
metadata: { provider: "vip98k", taskId: result.taskId, temporaryVideoUrl: videoUrl },
|
||||
modelId: String(selected.model.modelId),
|
||||
modelRef: String(selected.model.id),
|
||||
modelDisplayName: String(selected.model.displayName || selected.model.modelId),
|
||||
};
|
||||
}
|
||||
const languageModel = await getLanguageModelById(selected.model.id);
|
||||
if (!languageModel || typeof (languageModel as any).doGenerate !== "function") {
|
||||
throw new Error("所选 VIP98K 视频模型初始化失败");
|
||||
@ -670,7 +725,7 @@ async function executeVip98kVideoGeneration(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeDirectVideoGeneration(
|
||||
async function executeDirectVideoGenerationInternal(
|
||||
params: ExecuteDirectVideoGenerationParams,
|
||||
): Promise<ExecuteDirectVideoGenerationResult> {
|
||||
const prompt = String(params.prompt || "").trim();
|
||||
@ -678,9 +733,9 @@ export async function executeDirectVideoGeneration(
|
||||
throw new Error("缺少视频生成提示词");
|
||||
}
|
||||
|
||||
const preferredParentModelId = await resolvePreferredVideoParentModelId(
|
||||
params.preferredModelRef,
|
||||
);
|
||||
if (params.taskLifecycle?.resume)
|
||||
params = { ...params, preferredModelRef: params.taskLifecycle.resume.modelRef };
|
||||
const preferredParentModelId = await resolvePreferredVideoParentModelId(params.preferredModelRef);
|
||||
validateCanvasVideoInputs(params.mode, params.images);
|
||||
const parentModelId = preferredParentModelId;
|
||||
|
||||
@ -734,11 +789,22 @@ export async function executeDirectVideoGeneration(
|
||||
);
|
||||
|
||||
let videoMetadata: Record<string, any> | null = null;
|
||||
if (isAgnesVideoModel(model)) {
|
||||
if (model.provider.type === "qianwen-video") {
|
||||
const result = await executeAliyunCanvasVideo({
|
||||
...params,
|
||||
duration: generationParams.duration,
|
||||
modelId: model.model.modelId,
|
||||
apiKey: model.provider.apiKey,
|
||||
baseURL: model.provider.baseUrl,
|
||||
lifecycle: bindVideoTaskLifecycle(params.taskLifecycle, model, "aliyun"),
|
||||
});
|
||||
videoMetadata = { ...result.metadata, temporaryVideoUrl: result.url };
|
||||
} else if (isAgnesVideoModel(model)) {
|
||||
const agnesResult = await executeAgnesVideoGeneration({
|
||||
apiKey: model.provider.apiKey,
|
||||
baseUrl: model.provider.baseUrl,
|
||||
options,
|
||||
lifecycle: bindVideoTaskLifecycle(params.taskLifecycle, model, "agnes"),
|
||||
});
|
||||
videoMetadata = {
|
||||
...(agnesResult.metadata || {}),
|
||||
@ -751,13 +817,19 @@ export async function executeDirectVideoGeneration(
|
||||
modelId: model.model.modelId,
|
||||
modelConfig: model.model.imageGenConfig || {},
|
||||
options,
|
||||
lifecycle: bindVideoTaskLifecycle(params.taskLifecycle, model, "atlascloud"),
|
||||
});
|
||||
videoMetadata = {
|
||||
...(atlasResult.metadata || {}),
|
||||
temporaryVideoUrl: atlasResult.url,
|
||||
};
|
||||
} else {
|
||||
for await (const chunk of doGenerateStream(model.provider.apiKey, model.provider.baseUrl, options)) {
|
||||
for await (const chunk of doGenerateStream(
|
||||
model.provider.apiKey,
|
||||
model.provider.baseUrl,
|
||||
options,
|
||||
bindVideoTaskLifecycle(params.taskLifecycle, model, "volcano"),
|
||||
)) {
|
||||
await params.onProgress?.(chunk);
|
||||
const metadataMatch = chunk.match(/<!-- VIDEO_METADATA:(.*?) -->/);
|
||||
if (metadataMatch) {
|
||||
@ -780,6 +852,42 @@ export async function executeDirectVideoGeneration(
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeDirectVideoGeneration(
|
||||
params: ExecuteDirectVideoGenerationParams,
|
||||
): Promise<ExecuteDirectVideoGenerationResult> {
|
||||
let accepted = Boolean(params.taskLifecycle?.resume);
|
||||
const lifecycle = params.taskLifecycle;
|
||||
try {
|
||||
return await executeDirectVideoGenerationInternal({
|
||||
...params,
|
||||
taskLifecycle: lifecycle
|
||||
? {
|
||||
...lifecycle,
|
||||
save: async (checkpoint) => {
|
||||
accepted = true;
|
||||
await lifecycle.save(checkpoint);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const unknownSubmission =
|
||||
lifecycle &&
|
||||
/fetch failed|network|socket|connection|timeout|timed out|abort|超时|未返回.*任务\s*ID|neither a task ID/i.test(
|
||||
message,
|
||||
);
|
||||
if ((accepted || unknownSubmission) && !(error instanceof ProviderTaskFailedError)) {
|
||||
throw new ProviderTaskUncertainError(
|
||||
accepted
|
||||
? message
|
||||
: "视频提交连接中断,尚未取得任务编号,无法确认是否已受理;请先核对服务商记录,系统不会自动重新提交",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频生成工具
|
||||
*/
|
||||
|
||||
39
lib/canvas/enhance-task-poll.ts
Normal file
39
lib/canvas/enhance-task-poll.ts
Normal file
@ -0,0 +1,39 @@
|
||||
// Polling is bounded by elapsed time, including network waits. Terminal provider failures
|
||||
// must escape immediately instead of being converted into a perpetual pending response.
|
||||
export class EnhanceTerminalError extends Error {}
|
||||
export async function pollTaskResult(
|
||||
apiUrl: string,
|
||||
apiKey: string,
|
||||
taskId: string,
|
||||
delay = 1000,
|
||||
timeout = 120000,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<any> {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetcher(
|
||||
`${apiUrl}/api/tasks/visual/scale/${encodeURIComponent(taskId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "X-API-KEY": apiKey },
|
||||
signal: AbortSignal.timeout(Math.max(1, Math.min(10000, deadline - Date.now()))),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw Error(`轮询请求失败: ${response.status}`);
|
||||
const result = await response.json();
|
||||
if (!result.data) throw Error("高清查询未返回任务状态");
|
||||
if (result.data.state < 0)
|
||||
throw new EnhanceTerminalError(
|
||||
`AOS任务失败: ${String(result.data.err_message || result.data.err_info || result.data.state_detail || result.data.state)}`,
|
||||
);
|
||||
if (result.data.progress >= 100) return result;
|
||||
} catch (error) {
|
||||
if (error instanceof EnhanceTerminalError) throw error;
|
||||
}
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining > 0)
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, remaining)));
|
||||
}
|
||||
throw Error("轮询超时");
|
||||
}
|
||||
126
lib/canvas/generate-image-response.ts
Normal file
126
lib/canvas/generate-image-response.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import "server-only";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
createCanvasImageTask,
|
||||
enqueueCanvasImageTask,
|
||||
getCanvasImageTaskForUser,
|
||||
} from "@/lib/canvas/image-task-queue";
|
||||
import { generateCanvasImage, type CanvasGenerateImageRequest } from "@/lib/canvas/generate-image";
|
||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import { getImageToolCreditCost, type ImageToolCreditFeature } from "@/lib/points/config";
|
||||
|
||||
interface GenerateImageRequest extends CanvasGenerateImageRequest {
|
||||
async?: boolean;
|
||||
}
|
||||
|
||||
function normalizeBillingFeature(
|
||||
value: unknown,
|
||||
): Extract<ImageToolCreditFeature, "multi-view" | "repaint"> | null {
|
||||
if (value === "multi-view" || value === "repaint") return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImageRequest {
|
||||
return {
|
||||
allowFailover: typeof body.allowFailover === "boolean" ? body.allowFailover : undefined,
|
||||
prompt: String(body.prompt || "").trim(),
|
||||
negativePrompt: typeof body.negativePrompt === "string" ? body.negativePrompt : undefined,
|
||||
model: typeof body.model === "string" ? body.model : undefined,
|
||||
aspectRatio:
|
||||
typeof body.aspectRatio === "string" && body.aspectRatio.trim()
|
||||
? body.aspectRatio.trim()
|
||||
: "auto",
|
||||
imageSize:
|
||||
typeof body.imageSize === "string" && body.imageSize.trim()
|
||||
? body.imageSize.trim()
|
||||
: undefined,
|
||||
imageCount:
|
||||
typeof body.imageCount === "number" && Number.isFinite(body.imageCount) ? body.imageCount : 1,
|
||||
promptExtend: typeof body.promptExtend === "boolean" ? body.promptExtend : undefined,
|
||||
referenceImage: typeof body.referenceImage === "string" ? body.referenceImage : undefined,
|
||||
referenceImages: Array.isArray(body.referenceImages)
|
||||
? body.referenceImages.map((item) => String(item || "").trim()).filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeCanvasImageResponse(body: GenerateImageRequest, userId: string) {
|
||||
try {
|
||||
const payload: CanvasGenerateImageRequest = {
|
||||
...toGenerateImagePayload(body),
|
||||
ownerKey: userId,
|
||||
};
|
||||
if (!payload.prompt) {
|
||||
return NextResponse.json({ success: false, error: "请输入提示词" }, { status: 400 });
|
||||
}
|
||||
|
||||
const billingFeature = normalizeBillingFeature(body.billingFeature);
|
||||
const pointsCharge = billingFeature
|
||||
? await (async () => {
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
return prepareFeaturePointsCharge({
|
||||
userId: userId,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, billingFeature),
|
||||
reason: billingFeature === "multi-view" ? "image_multi_view" : "image_repaint",
|
||||
modelId: "canvas-generate-image",
|
||||
note: billingFeature === "multi-view" ? "图片多视角生成消耗积分" : "图片重绘消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
})()
|
||||
: null;
|
||||
if (pointsCharge && !pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const asyncMode = body.async === true;
|
||||
if (asyncMode) {
|
||||
const task = await createCanvasImageTask({
|
||||
userId: userId,
|
||||
request: payload as unknown as Record<string, unknown>,
|
||||
});
|
||||
await enqueueCanvasImageTask(task.id);
|
||||
const currentTask = await getCanvasImageTaskForUser({
|
||||
taskId: task.id,
|
||||
userId: userId,
|
||||
});
|
||||
if (pointsCharge?.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume({
|
||||
note:
|
||||
billingFeature === "multi-view"
|
||||
? `图片多视角生成消耗积分(${task.id})`
|
||||
: `图片重绘消耗积分(${task.id})`,
|
||||
});
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume canvas image task points:", pointsError);
|
||||
}
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
taskId: task.id,
|
||||
status: currentTask?.status || task.status,
|
||||
queueStatus: currentTask?.queueStatus,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await generateCanvasImage(payload);
|
||||
if (pointsCharge?.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume();
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume canvas image points:", pointsError);
|
||||
}
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Canvas Generate Image] Error:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "生成失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
384
lib/canvas/operations/angle-edit.ts
Normal file
384
lib/canvas/operations/angle-edit.ts
Normal file
@ -0,0 +1,384 @@
|
||||
import "server-only";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
createMediaAssetRelation,
|
||||
getMediaAssetsByUserAndUrls,
|
||||
getPointsSystemConfig,
|
||||
getSystemSetting,
|
||||
upsertMediaAsset,
|
||||
} from "@/lib/db/queries";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
|
||||
const logger = createLogger("angle-edit-api");
|
||||
|
||||
// 角度编辑API配置类型
|
||||
type AngleEditConfig = {
|
||||
enabled?: boolean;
|
||||
falApiKey?: string;
|
||||
loraScale?: number;
|
||||
inferenceSteps?: number;
|
||||
guidanceScale?: number;
|
||||
maxImageSize?: number;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
// 设置API路由最大执行时间为3分钟(180秒)
|
||||
|
||||
/**
|
||||
* 压缩图片生成缩略图
|
||||
*/
|
||||
async function createThumbnail(buffer: Buffer): Promise<Buffer> {
|
||||
try {
|
||||
const sharp = await import("sharp");
|
||||
|
||||
const thumbnailBuffer = await sharp.default(buffer)
|
||||
.resize(500, null, {
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.jpeg({ quality: 80 })
|
||||
.toBuffer();
|
||||
|
||||
logger.debug("thumbnail_created", {
|
||||
originalBytes: buffer.length,
|
||||
thumbnailBytes: thumbnailBuffer.length,
|
||||
});
|
||||
return thumbnailBuffer;
|
||||
} catch (error) {
|
||||
logger.warn("thumbnail_creation_failed", error);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 base64 图片上传到 S3
|
||||
*/
|
||||
async function uploadBase64ToS3(base64Data: string, prefix: string = "angle-edit"): Promise<{ thumbnail: string; original: string }> {
|
||||
try {
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
const timestamp = Date.now();
|
||||
const randomStr = Math.random().toString(36).slice(2, 8);
|
||||
const thumbnailFilename = `${prefix}-thumb-${timestamp}-${randomStr}.png`;
|
||||
const originalFilename = `${prefix}-orig-${timestamp}-${randomStr}.png`;
|
||||
|
||||
logger.debug("source_image_prepared", { originalBytes: buffer.length });
|
||||
|
||||
const thumbnailBuffer = await createThumbnail(buffer);
|
||||
|
||||
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
|
||||
thumbnailBuffer.byteOffset,
|
||||
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
const originalArrayBuffer = buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
|
||||
logger.debug("storage_upload_started", {
|
||||
hasOriginal: originalArrayBuffer.byteLength > 0,
|
||||
hasThumbnail: thumbnailArrayBuffer.byteLength > 0,
|
||||
prefix,
|
||||
});
|
||||
const [thumbnailResult, originalResult] = await Promise.all([
|
||||
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
|
||||
uploadFile(originalFilename, originalArrayBuffer),
|
||||
]);
|
||||
|
||||
logger.info("storage_upload_completed", {
|
||||
originalUrl: originalResult.url,
|
||||
thumbnailUrl: thumbnailResult.url,
|
||||
});
|
||||
|
||||
return {
|
||||
thumbnail: thumbnailResult.url,
|
||||
original: originalResult.url,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("storage_upload_failed", error);
|
||||
throw new Error(`图片上传失败: ${error instanceof Error ? error.message : "未知错误"}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeAngleEditPOST(request: Request, trustedUserId: string) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 1. 验证用户登录
|
||||
const session = { user: { id: trustedUserId } };
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
logger.info("request_received", { authenticated: true });
|
||||
|
||||
// 2. 从数据库获取配置
|
||||
const angleEditConfig = await getSystemSetting({ key: "angle_edit_config" });
|
||||
const config = (angleEditConfig?.value as AngleEditConfig) || {};
|
||||
|
||||
// 验证功能是否启用
|
||||
if (!config.enabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "角度编辑功能未启用,请联系管理员在系统设置中开启" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证 API Key
|
||||
if (!config.falApiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "fal.ai API Key 未配置,请联系管理员配置" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("config_loaded", {
|
||||
enabled: Boolean(config.enabled),
|
||||
hasApiKey: Boolean(config.falApiKey),
|
||||
loraScale: config.loraScale || 1,
|
||||
});
|
||||
|
||||
// 3. 解析请求
|
||||
const body = await request.json();
|
||||
const {
|
||||
image_url,
|
||||
image_base64,
|
||||
// 数值参数 (官方 API 格式)
|
||||
horizontal_angle, // 0-360 度
|
||||
vertical_angle, // -30 到 90 度
|
||||
zoom, // 0-10
|
||||
lora_scale,
|
||||
} = body;
|
||||
|
||||
if (!image_url && !image_base64) {
|
||||
return NextResponse.json({ error: "缺少图片数据" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 使用请求中的参数或配置中的默认值
|
||||
const finalLoraScale = lora_scale ?? config.loraScale ?? 1;
|
||||
const finalHorizontalAngle = horizontal_angle ?? 0;
|
||||
const finalVerticalAngle = vertical_angle ?? 0;
|
||||
const finalZoom = zoom ?? 5;
|
||||
|
||||
logger.info("request_parameters_normalized", {
|
||||
hasBase64Input: Boolean(image_base64),
|
||||
hasUrlInput: Boolean(image_url),
|
||||
horizontalAngle: finalHorizontalAngle,
|
||||
loraScale: finalLoraScale,
|
||||
verticalAngle: finalVerticalAngle,
|
||||
zoom: finalZoom,
|
||||
});
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "angle-edit"),
|
||||
reason: "image_angle_edit",
|
||||
modelId: "qwen-image-edit-2511-multiple-angles",
|
||||
note: "多角度编辑消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
// 4. 准备图片输入
|
||||
let imageInput: string;
|
||||
if (image_url) {
|
||||
imageInput = image_url;
|
||||
} else {
|
||||
const mimeType = image_base64.startsWith("/9j/") ? "image/jpeg" : "image/png";
|
||||
imageInput = `data:${mimeType};base64,${image_base64}`;
|
||||
}
|
||||
|
||||
// 5. 动态导入 fal.ai SDK 并调用官方多角度 API
|
||||
logger.info("provider_call_started", {
|
||||
modelId: "fal-ai/qwen-image-edit-2511-multiple-angles",
|
||||
});
|
||||
|
||||
const fal = await import("@fal-ai/serverless-client");
|
||||
|
||||
fal.config({
|
||||
credentials: config.falApiKey,
|
||||
});
|
||||
|
||||
// 使用官方多角度 API 端点
|
||||
// 文档: https://fal.ai/models/fal-ai/qwen-image-edit-2511-multiple-angles
|
||||
const result = await fal.subscribe("fal-ai/qwen-image-edit-2511-multiple-angles", {
|
||||
input: {
|
||||
image_urls: [imageInput], // 官方使用 image_urls (数组)
|
||||
horizontal_angle: finalHorizontalAngle, // 水平旋转角度 0-360
|
||||
vertical_angle: finalVerticalAngle, // 垂直角度 -30 到 90
|
||||
zoom: finalZoom, // 缩放 0-10
|
||||
lora_scale: finalLoraScale, // LoRA 强度
|
||||
num_inference_steps: config.inferenceSteps || 28,
|
||||
guidance_scale: config.guidanceScale || 4.5, // 官方默认 4.5
|
||||
enable_safety_checker: true,
|
||||
output_format: "png",
|
||||
},
|
||||
logs: true,
|
||||
onQueueUpdate: (update) => {
|
||||
if (update.status === "IN_PROGRESS") {
|
||||
logger.debug("provider_progress", { status: update.status });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 6. 处理结果
|
||||
const apiResult = result as { images?: Array<{ url?: string }>; prompt?: string };
|
||||
const outputImage = apiResult.images?.[0];
|
||||
if (!outputImage?.url) {
|
||||
throw new Error("API 未返回有效图片");
|
||||
}
|
||||
|
||||
const outputImageUrl = outputImage.url;
|
||||
logger.info("provider_call_completed", {
|
||||
hasGeneratedPrompt: Boolean(apiResult.prompt),
|
||||
imageCount: apiResult.images?.length ?? 0,
|
||||
outputImageUrl,
|
||||
});
|
||||
|
||||
// 7. 下载图片并上传到我们的 S3
|
||||
const imageResponse = await fetch(outputImageUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error(`下载输出图片失败: ${imageResponse.status}`);
|
||||
}
|
||||
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
||||
const imageBase64Result = imageBuffer.toString("base64");
|
||||
|
||||
const s3Result = await uploadBase64ToS3(imageBase64Result, "angle-edit");
|
||||
logger.info("result_image_persisted", {
|
||||
originalUrl: s3Result.original,
|
||||
thumbnailUrl: s3Result.thumbnail,
|
||||
});
|
||||
|
||||
// 8. 保存到数据库
|
||||
const processingTime = (Date.now() - startTime) / 1000;
|
||||
|
||||
const [savedImage] = await db
|
||||
.insert(generatedImage)
|
||||
.values({
|
||||
userId: session.user.id,
|
||||
thumbnailUrl: s3Result.thumbnail,
|
||||
originalUrl: s3Result.original,
|
||||
prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`,
|
||||
modelId: "qwen-image-edit-2511-multiple-angles",
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume({
|
||||
note: `多角度编辑消耗积分(${savedImage.id})`,
|
||||
});
|
||||
} catch (pointsError) {
|
||||
logger.error("points_consume_failed", pointsError, {
|
||||
imageId: savedImage.id,
|
||||
userId: session.user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceAssets =
|
||||
typeof image_url === "string" && image_url.trim()
|
||||
? await getMediaAssetsByUserAndUrls({
|
||||
userId: session.user.id,
|
||||
urls: [image_url],
|
||||
})
|
||||
: [];
|
||||
const media = await upsertMediaAsset({
|
||||
userId: session.user.id,
|
||||
mediaType: "image",
|
||||
sourceType: "edited",
|
||||
status: "ready",
|
||||
originalUrl: s3Result.original,
|
||||
previewUrl: s3Result.original,
|
||||
thumbnailUrl: s3Result.thumbnail,
|
||||
displayName: `角度编辑 ${finalHorizontalAngle}/${finalVerticalAngle}`.slice(0, 80),
|
||||
prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`,
|
||||
modelId: "qwen-image-edit-2511-multiple-angles",
|
||||
sourceTable: "GeneratedImage",
|
||||
sourceRowId: savedImage.id,
|
||||
primaryParentAssetId: sourceAssets[0]?.id ?? null,
|
||||
metadata: {
|
||||
horizontalAngle: finalHorizontalAngle,
|
||||
verticalAngle: finalVerticalAngle,
|
||||
zoom: finalZoom,
|
||||
},
|
||||
});
|
||||
if (media) {
|
||||
for (const sourceAsset of sourceAssets) {
|
||||
await createMediaAssetRelation({
|
||||
userId: session.user.id,
|
||||
fromAssetId: sourceAsset.id,
|
||||
toAssetId: media.id,
|
||||
relationType: "edited_from",
|
||||
metadata: {
|
||||
horizontalAngle: finalHorizontalAngle,
|
||||
verticalAngle: finalVerticalAngle,
|
||||
zoom: finalZoom,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (assetError) {
|
||||
logger.warn("media_asset_registration_failed", assetError);
|
||||
}
|
||||
|
||||
logger.info("request_completed", {
|
||||
processingSeconds: Number(processingTime.toFixed(2)),
|
||||
});
|
||||
|
||||
// 9. 返回结果
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
image_url: s3Result.original,
|
||||
thumbnail_url: s3Result.thumbnail,
|
||||
image_base64: imageBase64Result,
|
||||
processing_time: processingTime,
|
||||
angles: {
|
||||
horizontal_angle: finalHorizontalAngle,
|
||||
vertical_angle: finalVerticalAngle,
|
||||
zoom: finalZoom,
|
||||
},
|
||||
prompt: apiResult.prompt,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error("request_failed", error);
|
||||
|
||||
// 输出详细的错误信息
|
||||
if (error && typeof error === "object" && "status" in error) {
|
||||
const apiError = error as { status?: number; body?: unknown; message?: string };
|
||||
logger.warn("provider_error_summary", {
|
||||
bodyType: typeof apiError.body,
|
||||
hasBody: apiError.body != null,
|
||||
message: apiError.message,
|
||||
status: apiError.status,
|
||||
});
|
||||
|
||||
if (apiError.status === 403) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "API 访问被拒绝 (403 Forbidden)。请检查 API Key 是否正确,以及是否有权限访问该模型。",
|
||||
details: apiError.body,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "角度编辑处理失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
1349
lib/canvas/operations/enhance.ts
Normal file
1349
lib/canvas/operations/enhance.ts
Normal file
File diff suppressed because it is too large
Load Diff
238
lib/canvas/operations/extend.ts
Normal file
238
lib/canvas/operations/extend.ts
Normal file
@ -0,0 +1,238 @@
|
||||
import "server-only";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { ExtendServiceError, executeExtendJob } from "@/lib/ai/extend/service";
|
||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||
import {
|
||||
getFriendlyRateLimitMessage,
|
||||
getUserFacingRequestErrorMessage,
|
||||
isRateLimitError,
|
||||
} from "@/lib/request-error";
|
||||
|
||||
|
||||
function parseIncludeBase64(value: unknown): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
const normalized = String(value ?? "").trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes";
|
||||
}
|
||||
|
||||
function buildExtendSuccessPayload(result: Awaited<ReturnType<typeof executeExtendJob>>) {
|
||||
return {
|
||||
success: true,
|
||||
original_url: result.original_url,
|
||||
thumbnail_url: result.thumbnail_url,
|
||||
...(result.image_base64 ? { base64: result.image_base64 } : {}),
|
||||
processing_time: result.processing_time,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeExtendPOST(request: Request, trustedUserId: string) {
|
||||
try {
|
||||
const session = { user: { id: trustedUserId } };
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
|
||||
let imageUrl: string | null = null;
|
||||
let imageBase64: string | null = null;
|
||||
let prompt = "图像拓展处理";
|
||||
let chatId: string | null = null;
|
||||
let messageId: string | null = null;
|
||||
let includeBase64 = false;
|
||||
const requestId = `extend-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const body = await request.json();
|
||||
imageUrl = body.image_url ? String(body.image_url).trim() : null;
|
||||
imageBase64 = body.image_base64 ? String(body.image_base64).trim() : null;
|
||||
prompt = body.prompt || prompt;
|
||||
chatId = body.chatId || null;
|
||||
messageId = body.messageId || null;
|
||||
includeBase64 = parseIncludeBase64(body.includeBase64);
|
||||
|
||||
if (!imageUrl && !imageBase64) {
|
||||
return NextResponse.json(
|
||||
{ error: "缺少image_base64或image_url参数" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[Extend API] Request received:", {
|
||||
requestId,
|
||||
source: imageUrl ? "url" : "base64",
|
||||
includeBase64,
|
||||
chatId,
|
||||
messageId,
|
||||
xScale: body.xScale,
|
||||
yScale: body.yScale,
|
||||
});
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "extend"),
|
||||
reason: "image_extend",
|
||||
modelId: "image-extend",
|
||||
note: "图片扩图消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const result = await executeExtendJob({
|
||||
userId: session.user.id,
|
||||
imageUrl,
|
||||
imageBase64,
|
||||
prompt,
|
||||
chatId,
|
||||
messageId,
|
||||
xScale: body.xScale ? Number(body.xScale) : undefined,
|
||||
yScale: body.yScale ? Number(body.yScale) : undefined,
|
||||
includeBase64,
|
||||
requestId,
|
||||
});
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume();
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume extend points:", pointsError);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[Extend API] Request completed:", {
|
||||
requestId,
|
||||
includeBase64,
|
||||
processingTime: result.processing_time,
|
||||
originalUrl: result.original_url.slice(0, 120),
|
||||
});
|
||||
|
||||
return NextResponse.json(buildExtendSuccessPayload(result));
|
||||
}
|
||||
|
||||
if (contentType.includes("multipart/form-data")) {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "缺少file参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const multipartChatId = String(formData.get("chatId") || "") || null;
|
||||
const multipartMessageId = String(formData.get("messageId") || "") || null;
|
||||
includeBase64 = parseIncludeBase64(formData.get("includeBase64"));
|
||||
console.log("[Extend API] Multipart request received:", {
|
||||
requestId,
|
||||
includeBase64,
|
||||
chatId: multipartChatId,
|
||||
messageId: multipartMessageId,
|
||||
bytes: buffer.length,
|
||||
});
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "extend"),
|
||||
reason: "image_extend",
|
||||
modelId: "image-extend",
|
||||
note: "图片扩图消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const result = await executeExtendJob({
|
||||
userId: session.user.id,
|
||||
imageBase64: buffer.toString("base64"),
|
||||
prompt: String(formData.get("prompt") || prompt),
|
||||
chatId: multipartChatId,
|
||||
messageId: multipartMessageId,
|
||||
includeBase64,
|
||||
requestId,
|
||||
});
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume();
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume extend points:", pointsError);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[Extend API] Multipart request completed:", {
|
||||
requestId,
|
||||
includeBase64,
|
||||
processingTime: result.processing_time,
|
||||
originalUrl: result.original_url.slice(0, 120),
|
||||
});
|
||||
|
||||
return NextResponse.json(buildExtendSuccessPayload(result));
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "不支持的Content-Type,请使用application/json或multipart/form-data" },
|
||||
{ status: 400 },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ExtendServiceError) {
|
||||
console.error("[Extend API] Request failed:", {
|
||||
status: error.status,
|
||||
message: error.message,
|
||||
detail: error.detail,
|
||||
});
|
||||
if (error.status === 429 || isRateLimitError(error)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: getFriendlyRateLimitMessage("当前操作过于频繁,请稍后再试"),
|
||||
detail: error.detail || error.message,
|
||||
retryAfter: 10,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Retry-After": "10",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: getUserFacingRequestErrorMessage(error, "图像拓展失败"),
|
||||
detail: error.detail || error.message,
|
||||
},
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[Extend API] 错误:", error);
|
||||
if (isRateLimitError(error)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: getFriendlyRateLimitMessage("当前操作过于频繁,请稍后再试"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
retryAfter: 10,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Retry-After": "10",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: getUserFacingRequestErrorMessage(error, "图像拓展失败"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
136
lib/canvas/operations/rmbg.ts
Normal file
136
lib/canvas/operations/rmbg.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import "server-only";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
executeRmbgJob,
|
||||
fetchImageBufferFromUrl,
|
||||
RmbgServiceError,
|
||||
} from "@/lib/ai/rmbg/service";
|
||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||
|
||||
|
||||
export async function executeRemoveBackgroundPOST(request: Request, trustedUserId: string) {
|
||||
try {
|
||||
const session = { user: { id: trustedUserId } };
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
|
||||
let imageBuffer: Buffer;
|
||||
let imageBase64: string | undefined;
|
||||
let prompt = "抠图处理";
|
||||
let chatId: string | null = null;
|
||||
let messageId: string | null = null;
|
||||
let skipGallerySave = false;
|
||||
let sourceImageUrl: string | null = null;
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const body = await request.json();
|
||||
prompt = body.prompt || "抠图处理";
|
||||
chatId = body.chatId || null;
|
||||
messageId = body.messageId || null;
|
||||
skipGallerySave = body.skipGallerySave === true || body.skip_gallery_save === 1;
|
||||
|
||||
const imageUrl = body.imageUrl || body.image_url;
|
||||
const explicitSourceImageUrl = body.sourceImageUrl || body.source_image_url;
|
||||
|
||||
if (imageUrl) {
|
||||
sourceImageUrl = String(imageUrl);
|
||||
imageBuffer = await fetchImageBufferFromUrl(sourceImageUrl);
|
||||
} else if (body.image_base64) {
|
||||
imageBase64 = String(body.image_base64);
|
||||
imageBuffer = Buffer.from(imageBase64, "base64");
|
||||
if (explicitSourceImageUrl) {
|
||||
sourceImageUrl = String(explicitSourceImageUrl);
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "缺少 imageUrl 或 image_base64 参数" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (contentType.includes("multipart/form-data")) {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "缺少file参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
imageBuffer = Buffer.from(arrayBuffer);
|
||||
imageBase64 = imageBuffer.toString("base64");
|
||||
prompt = (formData.get("prompt") as string) || "抠图处理";
|
||||
chatId = (formData.get("chatId") as string) || null;
|
||||
messageId = (formData.get("messageId") as string) || null;
|
||||
skipGallerySave =
|
||||
formData.get("skipGallerySave") === "true" || formData.get("skip_gallery_save") === "1";
|
||||
sourceImageUrl =
|
||||
(formData.get("sourceImageUrl") as string) ||
|
||||
(formData.get("source_image_url") as string) ||
|
||||
null;
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "不支持的Content-Type,请使用application/json或multipart/form-data" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "remove-background"),
|
||||
reason: "image_remove_background",
|
||||
modelId: "rmbg",
|
||||
note: "图片抠图消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const result = await executeRmbgJob({
|
||||
userId: session.user.id,
|
||||
imageBuffer,
|
||||
imageBase64,
|
||||
prompt,
|
||||
chatId,
|
||||
messageId,
|
||||
skipGallerySave,
|
||||
sourceImageUrl,
|
||||
});
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume();
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume RMBG points:", pointsError);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RmbgServiceError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message,
|
||||
detail: error.detail || error.message,
|
||||
},
|
||||
{ status: error.status }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[RMBG API] 处理错误:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -157,7 +157,7 @@ function looksLikeErrorDocument(buffer: Buffer, contentType: string): boolean {
|
||||
return /^(?:<!doctype\s+html|<html[\s>]|<\?xml|<error[\s>]|[{[])/i.test(text);
|
||||
}
|
||||
|
||||
function detectAsset(bytes: Buffer, contentType: string): { kind: "image" | "video"; mimeType: string; ext: string } | null {
|
||||
function detectAsset(bytes: Buffer, contentType: string): { kind: "image" | "video" | "audio"; mimeType: string; ext: string } | null {
|
||||
if (bytes.length < 4) return null;
|
||||
|
||||
if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
||||
@ -184,8 +184,14 @@ function detectAsset(bytes: Buffer, contentType: string): { kind: "image" | "vid
|
||||
if (looksLikeSvg(bytes, contentType)) {
|
||||
return { kind: "image", mimeType: "image/svg+xml", ext: "svg" };
|
||||
}
|
||||
if (hasAsciiAt(bytes, 0, "RIFF") && hasAsciiAt(bytes, 8, "WAVE")) return {kind:"audio",mimeType:"audio/wav",ext:"wav"};
|
||||
if (hasAsciiAt(bytes, 0, "ID3") || (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0 && (bytes[1] & 0x06) !== 0)) return {kind:"audio",mimeType:"audio/mpeg",ext:"mp3"};
|
||||
if (hasAsciiAt(bytes, 0, "OggS")) return {kind:"audio",mimeType:"audio/ogg",ext:"ogg"};
|
||||
if (hasAsciiAt(bytes, 0, "fLaC")) return {kind:"audio",mimeType:"audio/flac",ext:"flac"};
|
||||
if (getContentTypeMime(contentType).startsWith("audio/")) return {kind:"audio",mimeType:getContentTypeMime(contentType),ext:getContentTypeMime(contentType).includes("mp4") ? "m4a" : getContentTypeMime(contentType).split("/")[1]};
|
||||
if (hasAsciiAt(bytes, 4, "ftyp")) {
|
||||
const brand = bytes.subarray(8, Math.min(bytes.length, 32)).toString("latin1").toLowerCase();
|
||||
if (brand.includes("m4a")) return {kind:"audio",mimeType:"audio/mp4",ext:"m4a"};
|
||||
if (brand.includes("avif")) return { kind: "image", mimeType: "image/avif", ext: "avif" };
|
||||
if (brand.includes("heic") || brand.includes("heif") || brand.includes("mif1")) {
|
||||
return { kind: "image", mimeType: "image/heic", ext: "heic" };
|
||||
@ -258,7 +264,7 @@ async function normalizeCanvasDownloadAsset(params: {
|
||||
throw new Error("无法识别图片格式");
|
||||
}
|
||||
|
||||
if (detected.kind === "video") {
|
||||
if (detected.kind !== "image") {
|
||||
return {
|
||||
ok: true,
|
||||
filename: buildCanvasAssetFilename({
|
||||
|
||||
@ -7,6 +7,7 @@ const PROXY_HOST_KEYWORDS = [
|
||||
"marble",
|
||||
"s3.1080p.hk",
|
||||
"img.yydd.ai",
|
||||
"scqny.jiansun.vip",
|
||||
"blob.vercel-storage.com",
|
||||
"aliyuncs.com",
|
||||
"amazonaws.com",
|
||||
|
||||
@ -1238,13 +1238,13 @@ export async function getMediaAssetByUserAndUrl(params: {
|
||||
export async function getMediaAssetsByUserAndUrls(params: {
|
||||
userId: string;
|
||||
urls: string[];
|
||||
}): Promise<MediaAsset[]> {
|
||||
}, executor: Pick<typeof db, "select" | "insert" | "delete"> = db): Promise<MediaAsset[]> {
|
||||
const normalizedUrls = Array.from(
|
||||
new Set(params.urls.map((url) => normalizeMediaUrl(url)).filter(Boolean)),
|
||||
);
|
||||
if (!normalizedUrls.length) return [];
|
||||
try {
|
||||
return await db
|
||||
return await executor
|
||||
.select()
|
||||
.from(mediaAsset)
|
||||
.where(
|
||||
@ -1397,9 +1397,9 @@ export async function createMediaAssetRelation(params: {
|
||||
sourceNodeId?: string | null;
|
||||
confidence?: number | null;
|
||||
metadata?: any;
|
||||
}): Promise<MediaAssetRelation | null> {
|
||||
}, executor: Pick<typeof db, "select" | "insert" | "delete"> = db): Promise<MediaAssetRelation | null> {
|
||||
try {
|
||||
const [result] = await db
|
||||
const [result] = await executor
|
||||
.insert(mediaAssetRelation)
|
||||
.values({
|
||||
userId: params.userId,
|
||||
@ -1589,7 +1589,7 @@ export async function syncCanvasNodesForProject(params: {
|
||||
edges?: any[];
|
||||
viewport?: { x: number; y: number; zoom: number };
|
||||
} | null | undefined;
|
||||
}) {
|
||||
}, executor: Pick<typeof db, "select" | "insert" | "delete"> = db) {
|
||||
const nodes = Array.isArray(params.canvasData?.nodes) ? params.canvasData?.nodes : [];
|
||||
const assetUrls = Array.from(
|
||||
new Set(
|
||||
@ -1602,7 +1602,7 @@ export async function syncCanvasNodesForProject(params: {
|
||||
? await getMediaAssetsByUserAndUrls({
|
||||
userId: params.userId,
|
||||
urls: assetUrls,
|
||||
})
|
||||
}, executor)
|
||||
: [];
|
||||
const assetByUrl = new Map<string, MediaAsset>();
|
||||
for (const asset of matchedAssets) {
|
||||
@ -1626,7 +1626,7 @@ export async function syncCanvasNodesForProject(params: {
|
||||
};
|
||||
});
|
||||
|
||||
await db.delete(mediaAssetRelation).where(
|
||||
await executor.delete(mediaAssetRelation).where(
|
||||
and(
|
||||
eq(mediaAssetRelation.projectId, params.projectId),
|
||||
eq(mediaAssetRelation.relationType, "located_on_canvas"),
|
||||
@ -1664,7 +1664,7 @@ export async function syncCanvasNodesForProject(params: {
|
||||
});
|
||||
|
||||
if (insertableNodes.length > 0) {
|
||||
await db
|
||||
await executor
|
||||
.insert(canvasNode)
|
||||
.values(insertableNodes)
|
||||
.onConflictDoUpdate({
|
||||
@ -1682,14 +1682,14 @@ export async function syncCanvasNodesForProject(params: {
|
||||
});
|
||||
|
||||
const activeNodeIds = insertableNodes.map((node) => node.id);
|
||||
await db.delete(canvasNode).where(
|
||||
await executor.delete(canvasNode).where(
|
||||
and(
|
||||
eq(canvasNode.projectId, params.projectId),
|
||||
notInArray(canvasNode.id, activeNodeIds),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await db.delete(canvasNode).where(eq(canvasNode.projectId, params.projectId));
|
||||
await executor.delete(canvasNode).where(eq(canvasNode.projectId, params.projectId));
|
||||
}
|
||||
|
||||
for (const node of enrichedNodes) {
|
||||
@ -1710,7 +1710,7 @@ export async function syncCanvasNodesForProject(params: {
|
||||
position: node.position || null,
|
||||
size: node.size || null,
|
||||
},
|
||||
});
|
||||
}, executor);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -15,12 +15,12 @@ function withTimeout(timeoutMs: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function isRemoteUrlReachable(
|
||||
export async function probeRemoteUrl(
|
||||
url: string,
|
||||
options: UrlAccessOptions = {}
|
||||
): Promise<boolean> {
|
||||
): Promise<"reachable" | "unreachable" | "unknown"> {
|
||||
const target = String(url || "").trim();
|
||||
if (!/^https?:\/\//i.test(target)) return false;
|
||||
if (!/^https?:\/\//i.test(target)) return "unreachable";
|
||||
const timeoutMs = Math.max(800, Number(options.timeoutMs || DEFAULT_TIMEOUT_MS));
|
||||
|
||||
const headCtx = withTimeout(timeoutMs);
|
||||
@ -31,10 +31,8 @@ export async function isRemoteUrlReachable(
|
||||
cache: "no-store",
|
||||
signal: headCtx.signal,
|
||||
});
|
||||
if (headResponse.ok) return true;
|
||||
if (![403, 405, 429, 500, 501, 502, 503, 504].includes(headResponse.status)) {
|
||||
return false;
|
||||
}
|
||||
await headResponse.body?.cancel();
|
||||
if (headResponse.ok) return "reachable";
|
||||
} catch {}
|
||||
finally {
|
||||
headCtx.clear();
|
||||
@ -49,14 +47,20 @@ export async function isRemoteUrlReachable(
|
||||
cache: "no-store",
|
||||
signal: getCtx.signal,
|
||||
});
|
||||
return getResponse.ok || getResponse.status === 206;
|
||||
await getResponse.body?.cancel();
|
||||
if (getResponse.ok || getResponse.status === 206) return "reachable";
|
||||
return [400, 401, 403, 404, 410].includes(getResponse.status) ? "unreachable" : "unknown";
|
||||
} catch {
|
||||
return false;
|
||||
return "unknown";
|
||||
} finally {
|
||||
getCtx.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export async function isRemoteUrlReachable(url: string, options: UrlAccessOptions = {}): Promise<boolean> {
|
||||
return (await probeRemoteUrl(url, options)) === "reachable";
|
||||
}
|
||||
|
||||
export async function filterReachableRemoteUrls(
|
||||
urls: string[],
|
||||
options: UrlAccessOptions = {}
|
||||
|
||||
35
lib/video-canvas/angle-settings.ts
Normal file
35
lib/video-canvas/angle-settings.ts
Normal file
@ -0,0 +1,35 @@
|
||||
export const anglePresets: Record<
|
||||
string,
|
||||
{ horizontalAngle: number; verticalAngle: number; zoom: number; prompt?: string }
|
||||
> = {
|
||||
自定义: { horizontalAngle: 0, verticalAngle: 0, zoom: 50 },
|
||||
鱼眼视角: {
|
||||
horizontalAngle: 0,
|
||||
verticalAngle: 0,
|
||||
zoom: 20,
|
||||
prompt: "保持主体身份与场景,以鱼眼超广角镜头重新构图,明显桶形畸变",
|
||||
},
|
||||
倾斜视角: {
|
||||
horizontalAngle: 30,
|
||||
verticalAngle: 0,
|
||||
zoom: 50,
|
||||
prompt: "保持主体身份与场景,以荷兰角镜头拍摄,画面地平线倾斜 25 度",
|
||||
},
|
||||
正面俯拍: { horizontalAngle: 0, verticalAngle: 45, zoom: 50 },
|
||||
正面仰拍: { horizontalAngle: 0, verticalAngle: -30, zoom: 50 },
|
||||
全景俯拍: { horizontalAngle: 0, verticalAngle: 90, zoom: 0 },
|
||||
背面视角: { horizontalAngle: 180, verticalAngle: 0, zoom: 50 },
|
||||
};
|
||||
export function angleRequestParameters(settings: {
|
||||
horizontalAngle: number;
|
||||
verticalAngle: number;
|
||||
zoom: number;
|
||||
}) {
|
||||
if (![settings.horizontalAngle, settings.verticalAngle, settings.zoom].every(Number.isFinite))
|
||||
throw Error("视角参数必须是有限数值");
|
||||
return {
|
||||
horizontalAngle: ((settings.horizontalAngle % 360) + 360) % 360,
|
||||
verticalAngle: Math.max(-30, Math.min(90, settings.verticalAngle)),
|
||||
zoom: Math.max(0, Math.min(10, settings.zoom / 10)),
|
||||
};
|
||||
}
|
||||
19
lib/video-canvas/archive-result.ts
Normal file
19
lib/video-canvas/archive-result.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export type VideoArchiveMetadata = Record<string, unknown> & {
|
||||
storageStatus: "archived" | "temporary";
|
||||
temporaryVideoUrl: string;
|
||||
permanentVideoUrl?: string;
|
||||
};
|
||||
|
||||
export async function preserveGeneratedVideo(
|
||||
sourceUrl: string,
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
archive: (url: string) => Promise<string>,
|
||||
): Promise<{ url: string; metadata: VideoArchiveMetadata }> {
|
||||
try {
|
||||
const url = await archive(sourceUrl);
|
||||
if (!url || url === sourceUrl) throw new Error("No permanent copy created");
|
||||
return { url, metadata: { ...metadata, storageStatus: "archived", temporaryVideoUrl: sourceUrl, permanentVideoUrl: url } };
|
||||
} catch {
|
||||
return { url: sourceUrl, metadata: { ...metadata, storageStatus: "temporary", temporaryVideoUrl: sourceUrl, permanentVideoUrl: undefined } };
|
||||
}
|
||||
}
|
||||
35
lib/video-canvas/archive-video.ts
Normal file
35
lib/video-canvas/archive-video.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { downloadCanvasMedia } from "./download-media";
|
||||
|
||||
const probe = promisify(execFile);
|
||||
const pending = new Map<string, Promise<string>>();
|
||||
|
||||
export async function archiveCanvasVideo(url: string): Promise<string> {
|
||||
const existing = pending.get(url);
|
||||
if (existing) return existing;
|
||||
if (pending.size >= 2) throw new Error("视频保存繁忙,请稍后重试");
|
||||
const job = (async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "canvas-video-archive-"));
|
||||
try {
|
||||
const input = join(dir, "video");
|
||||
await downloadCanvasMedia(url, input);
|
||||
const { stdout } = await probe("ffprobe", ["-v", "error", "-protocol_whitelist", "file", "-format_whitelist", "mov,matroska,webm", "-show_streams", "-show_format", "-of", "json", input], { timeout: 15000, maxBuffer: 1024 * 1024 });
|
||||
const media = JSON.parse(stdout);
|
||||
if (!media.streams?.some((stream: { codec_type: string }) => stream.codec_type === "video") || !(Number(media.format?.duration) > 0)) throw new Error("视频文件不完整");
|
||||
const extension = String(media.format.format_name).includes("mov") ? "mp4" : "webm";
|
||||
const data = await readFile(input);
|
||||
const uploaded = await uploadFile(`canvas-generated-${randomUUID()}.${extension}`, new Uint8Array(data).buffer);
|
||||
return uploaded.url;
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
})();
|
||||
pending.set(url, job);
|
||||
try { return await job; } finally { pending.delete(url); }
|
||||
}
|
||||
197
lib/video-canvas/audio-contract.ts
Normal file
197
lib/video-canvas/audio-contract.ts
Normal file
@ -0,0 +1,197 @@
|
||||
import { z } from "zod";
|
||||
const qwenVoices = [
|
||||
{ id: "Cherry", name: "芊悦" },
|
||||
{ id: "Serena", name: "苏瑶" },
|
||||
];
|
||||
export const audioModelContracts = {
|
||||
"qwen3-tts-instruct-flash": {
|
||||
kind: "tts",
|
||||
label: "千问3 TTS 指令版",
|
||||
voices: qwenVoices,
|
||||
instructions: true,
|
||||
controls: false,
|
||||
formats: ["wav"],
|
||||
},
|
||||
"qwen3-tts-flash": {
|
||||
kind: "tts",
|
||||
label: "千问3 TTS",
|
||||
voices: [...qwenVoices, { id: "Ryan", name: "甜茶" }, { id: "Jennifer", name: "詹妮弗" }],
|
||||
instructions: false,
|
||||
controls: false,
|
||||
formats: ["wav"],
|
||||
},
|
||||
"qwen-audio-3.0-tts-flash": {
|
||||
kind: "tts",
|
||||
label: "千问 Audio 3.0 Flash",
|
||||
voices: [
|
||||
{ id: "longanhuan_v3.6", name: "龙安欢" },
|
||||
{ id: "longanfengyue", name: "龙安风悦" },
|
||||
{ id: "longanyuanfei", name: "龙安元妃" },
|
||||
{ id: "longanlingxi", name: "龙安灵希" },
|
||||
],
|
||||
instructions: true,
|
||||
controls: true,
|
||||
formats: ["mp3", "wav"],
|
||||
},
|
||||
"qwen-audio-3.0-tts-plus": {
|
||||
kind: "tts",
|
||||
label: "千问 Audio 3.0 Plus",
|
||||
voices: [
|
||||
{ id: "longanlingxin", name: "龙安灵心" },
|
||||
{ id: "longanlufeng", name: "龙安鲁风" },
|
||||
],
|
||||
instructions: true,
|
||||
controls: true,
|
||||
formats: ["mp3", "wav"],
|
||||
},
|
||||
"fun-music-v1": {
|
||||
kind: "music",
|
||||
label: "Fun-Music 音乐",
|
||||
voices: [],
|
||||
instructions: false,
|
||||
controls: false,
|
||||
formats: ["mp3", "wav"],
|
||||
},
|
||||
} satisfies Record<
|
||||
string,
|
||||
{
|
||||
kind: string;
|
||||
label: string;
|
||||
voices: { id: string; name: string }[];
|
||||
instructions: boolean;
|
||||
controls: boolean;
|
||||
formats: string[];
|
||||
}
|
||||
>;
|
||||
export type AudioModelId = keyof typeof audioModelContracts;
|
||||
export const audioRequestSchema = z.object({
|
||||
mode: z.enum(["tts", "music"]),
|
||||
modelRef: z.string().min(1),
|
||||
text: z.string().trim().max(2000).default(""),
|
||||
lyrics: z.string().trim().max(2000).default(""),
|
||||
voice: z.string().max(100).optional(),
|
||||
language: z
|
||||
.enum([
|
||||
"Auto",
|
||||
"Chinese",
|
||||
"English",
|
||||
"German",
|
||||
"Italian",
|
||||
"Portuguese",
|
||||
"Spanish",
|
||||
"Japanese",
|
||||
"Korean",
|
||||
"French",
|
||||
"Russian",
|
||||
])
|
||||
.default("Chinese"),
|
||||
instructions: z.string().trim().max(1200).default(""),
|
||||
format: z.enum(["mp3", "wav"]).default("wav"),
|
||||
instrumental: z.boolean().default(true),
|
||||
gender: z.enum(["male", "female"]).default("female"),
|
||||
rate: z.number().min(0.5).max(2).default(1),
|
||||
pitch: z.number().min(0.5).max(2).default(1),
|
||||
volume: z.number().int().min(0).max(100).default(50),
|
||||
});
|
||||
export type AudioRequest = z.infer<typeof audioRequestSchema>;
|
||||
export function audioContract(id: string) {
|
||||
return Object.hasOwn(audioModelContracts, id)
|
||||
? audioModelContracts[id as AudioModelId]
|
||||
: undefined;
|
||||
}
|
||||
export function officialAudioBase(base: string, modelId?: string) {
|
||||
const url = new URL(base);
|
||||
if (
|
||||
url.protocol !== "https:" ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
!/^(?:dashscope(?:-intl|-us)?\.aliyuncs\.com|[a-zA-Z0-9-]+\.(?:cn-beijing|ap-southeast-1)\.maas\.aliyuncs\.com)$/.test(
|
||||
url.hostname,
|
||||
)
|
||||
)
|
||||
throw Error("请选择阿里云官方音频服务线路");
|
||||
if (
|
||||
(modelId === "fun-music-v1" || modelId?.startsWith("qwen-audio-")) &&
|
||||
url.hostname !== "dashscope.aliyuncs.com" &&
|
||||
!url.hostname.endsWith(".cn-beijing.maas.aliyuncs.com")
|
||||
)
|
||||
throw Error("所选模型仅支持阿里云北京地域");
|
||||
return url.origin;
|
||||
}
|
||||
export function buildAudioRequest(value: unknown, modelId: string) {
|
||||
const p = audioRequestSchema.parse(value),
|
||||
contract = audioContract(modelId);
|
||||
if (!contract || contract.kind !== p.mode) throw Error("音频模式与模型不匹配");
|
||||
if (!contract.formats.includes(p.format)) throw Error("所选模型不支持这个音频格式");
|
||||
if (p.mode === "music") {
|
||||
if ((p.instrumental && !p.text) || (!p.text && !p.lyrics)) throw Error("请输入音乐描述或歌词");
|
||||
if (
|
||||
!p.instrumental &&
|
||||
p.lyrics &&
|
||||
(p.lyrics.length < 5 || (/[\u3400-\u9fff]/.test(p.lyrics) && p.lyrics.length > 350))
|
||||
)
|
||||
throw Error("中文歌词需 5–350 字,英文歌词需 5–2000 字");
|
||||
return {
|
||||
path: "/api/v1/services/audio/music/generation",
|
||||
body: {
|
||||
model: modelId,
|
||||
input: {
|
||||
...(p.text ? { prompt: p.text } : {}),
|
||||
...(!p.instrumental && p.lyrics ? { lyrics: p.lyrics } : {}),
|
||||
is_instrumental: p.instrumental,
|
||||
...(!p.instrumental ? { gender: p.gender } : {}),
|
||||
format: p.format,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!p.text || p.text.length > 600) throw Error("配音文本需 1–600 字");
|
||||
if (!contract.voices.some((v) => v.id === p.voice)) throw Error("请选择当前模型支持的音色");
|
||||
if (!contract.instructions && p.instructions) throw Error("所选模型不支持风格指令");
|
||||
if (!contract.controls && (p.rate !== 1 || p.pitch !== 1 || p.volume !== 50))
|
||||
throw Error("所选模型不支持数值语速、音调和音量参数,可改用风格指令");
|
||||
const modern = modelId.startsWith("qwen-audio-");
|
||||
if (modern && !["Chinese", "English", "Auto"].includes(p.language))
|
||||
throw Error("当前系统音色支持中文和英文");
|
||||
return {
|
||||
path: modern
|
||||
? "/api/v1/services/audio/tts/SpeechSynthesizer"
|
||||
: "/api/v1/services/aigc/multimodal-generation/generation",
|
||||
body: {
|
||||
model: modelId,
|
||||
input: {
|
||||
text: p.text,
|
||||
voice: p.voice,
|
||||
...(modern
|
||||
? {
|
||||
format: p.format,
|
||||
rate: p.rate,
|
||||
pitch: p.pitch,
|
||||
volume: p.volume,
|
||||
...(p.instructions ? { instruction: p.instructions } : {}),
|
||||
...(p.language !== "Auto"
|
||||
? { language_hints: [p.language === "Chinese" ? "zh" : "en"] }
|
||||
: {}),
|
||||
}
|
||||
: {
|
||||
language_type: p.language,
|
||||
...(p.instructions
|
||||
? { instructions: p.instructions, optimize_instructions: true }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
export const audioCheckpointSchema = z.object({
|
||||
operation: z.literal("audio"),
|
||||
modelRef: z.string(),
|
||||
modelId: z.string(),
|
||||
url: z
|
||||
.string()
|
||||
.url()
|
||||
.refine((v) => /^https?:\/\//i.test(v)),
|
||||
format: z.enum(["mp3", "wav"]),
|
||||
requestId: z.string().optional(),
|
||||
expiresAt: z.number().optional(),
|
||||
});
|
||||
132
lib/video-canvas/audio-generation.ts
Normal file
132
lib/video-canvas/audio-generation.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import "server-only";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { downloadCanvasMedia } from "./download-media";
|
||||
import {
|
||||
audioContract,
|
||||
audioCheckpointSchema,
|
||||
audioRequestSchema,
|
||||
buildAudioRequest,
|
||||
officialAudioBase,
|
||||
} from "./audio-contract";
|
||||
import { ProviderTaskUncertainError } from "./video-task-lifecycle";
|
||||
export async function listCanvasAudioModels() {
|
||||
return (await getEnabledAiModelsWithProvider()).filter(({ model, provider }) => {
|
||||
try {
|
||||
return (
|
||||
provider.type === "alibaba" &&
|
||||
!!audioContract(model.modelId) &&
|
||||
!!officialAudioBase(provider.baseUrl, model.modelId)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
export async function executeCanvasAudio(
|
||||
params: unknown,
|
||||
lifecycle: {
|
||||
resume?: unknown;
|
||||
assertActive: () => Promise<void>;
|
||||
checkpoint: (value: Record<string, unknown>) => Promise<void>;
|
||||
},
|
||||
) {
|
||||
const input = audioRequestSchema.parse(params);
|
||||
let checkpoint = lifecycle.resume ? audioCheckpointSchema.parse(lifecycle.resume) : undefined;
|
||||
if (checkpoint && checkpoint.modelRef !== input.modelRef) throw Error("音频记录与原模型不匹配");
|
||||
if (!checkpoint) {
|
||||
const item = (await listCanvasAudioModels()).find((x) => x.model.id === input.modelRef);
|
||||
if (!item) throw Error("音频模型未启用,请先在阿里云和模型设置中开通");
|
||||
const request = buildAudioRequest(input, item.model.modelId);
|
||||
await lifecycle.assertActive();
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(
|
||||
officialAudioBase(item.provider.baseUrl, item.model.modelId) + request.path,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${item.provider.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request.body),
|
||||
signal: AbortSignal.timeout(input.mode === "music" ? 600000 : 180000),
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
throw new ProviderTaskUncertainError(
|
||||
"音频请求连接中断,无法确认是否已受理;请核对服务商记录,系统不会自动重发",
|
||||
);
|
||||
}
|
||||
let data: any;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
throw new ProviderTaskUncertainError("音频响应无法解析,请核对服务商记录,系统不会自动重发");
|
||||
}
|
||||
if (!response.ok)
|
||||
throw Error(
|
||||
`阿里云音频请求失败(${response.status},${String(data.code || data.error?.code || "UnknownError").slice(0, 120)})${input.mode === "music" ? ",请确认 Fun-Music 邀测权限已开通" : ""}`,
|
||||
);
|
||||
const url = data.output?.audio?.url;
|
||||
if (typeof url !== "string" || !/^https?:\/\//i.test(url))
|
||||
throw new ProviderTaskUncertainError("音频服务未返回有效文件地址,请核对原请求,勿重复生成");
|
||||
checkpoint = {
|
||||
operation: "audio",
|
||||
url,
|
||||
format: input.format,
|
||||
modelRef: item.model.id,
|
||||
modelId: item.model.modelId,
|
||||
requestId: data.request_id,
|
||||
expiresAt:
|
||||
typeof data.output.audio.expires_at === "number" ? data.output.audio.expires_at : undefined,
|
||||
};
|
||||
try {
|
||||
await lifecycle.assertActive();
|
||||
await lifecycle.checkpoint({ upstreamAudio: checkpoint });
|
||||
} catch {
|
||||
throw new ProviderTaskUncertainError(
|
||||
"音频已生成,但保存任务记录失败;请核对原请求,系统不会自动重发",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (checkpoint.expiresAt && checkpoint.expiresAt * 1000 < Date.now())
|
||||
throw Error("阿里云音频临时地址已过期,无法继续转存");
|
||||
await lifecycle.assertActive();
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "canvas-audio-"));
|
||||
try {
|
||||
const file = path.join(dir, "audio." + checkpoint.format);
|
||||
await downloadCanvasMedia(checkpoint.url, file);
|
||||
const bytes = await readFile(file);
|
||||
if (bytes.length < 12) throw Error("音频内容为空");
|
||||
const wav =
|
||||
bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WAVE";
|
||||
const mp3 =
|
||||
bytes.toString("ascii", 0, 3) === "ID3" || (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0);
|
||||
if (!(checkpoint.format === "wav" ? wav : mp3)) throw Error("返回文件不是有效音频格式");
|
||||
await lifecycle.assertActive();
|
||||
const saved = await uploadFile(
|
||||
`audio-${crypto.randomUUID()}.${checkpoint.format}`,
|
||||
Uint8Array.from(bytes).buffer,
|
||||
);
|
||||
return {
|
||||
url: saved.url,
|
||||
modelRef: checkpoint.modelRef,
|
||||
modelId: checkpoint.modelId,
|
||||
metadata: {
|
||||
storageStatus: "archived",
|
||||
format: checkpoint.format,
|
||||
requestId: checkpoint.requestId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
throw new ProviderTaskUncertainError(
|
||||
`音频已生成,转存暂未完成,将继续转存原文件:${error instanceof Error ? error.message : "存储失败"}`,
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@ export type VideoCanvasBackendRoute =
|
||||
| "canvas-generate-image"
|
||||
| "canvas-generate-video"
|
||||
| "canvas-workflow-analyze"
|
||||
| "canvas-prompt-tools"
|
||||
| "angle-edit"
|
||||
| "enhance"
|
||||
| "enhance-pro"
|
||||
@ -95,6 +96,28 @@ export type VideoCanvasFunctionConfig = {
|
||||
};
|
||||
|
||||
const defaultFunctions: VideoCanvasFunctionDefinition[] = [
|
||||
...[
|
||||
["character-face-three-view", "角色脸部三视图", "以参考图片的角色为唯一身份依据,生成横向排布的正面、左侧面、右侧面脸部三视图。保持五官、肤色、发型、饰品和年龄一致,头肩构图,纯净背景,三个角度大小一致。"],
|
||||
["character-design-sheet", "角色设定图", "根据参考角色生成完整角色设定图,包含正面、侧面、背面全身及服饰配件细节。所有视图保持同一身份、体型和服装材质,以干净背景和清楚分区呈现。"],
|
||||
["scene-design-sheet", "场景设定图", "根据参考场景生成场景设定图,包含场景全貌、相邻机位视角、关键陈设与材质细节。保持空间布局、建筑结构、光照时段与美术风格一致。"],
|
||||
["product-design-sheet", "产品设定图", "根据参考产品生成产品设定图,包含正面、侧面、背面和关键结构细节。保持外形、数量、配色、材质、比例及已有标识,不增添不存在的部件,统一干净背景与柔和光照。"],
|
||||
].map(([key, label, prompt]): VideoCanvasFunctionDefinition => ({
|
||||
key, label, prompt: `${prompt}输出单张 16:9 图片,不添加文字或水印。`,
|
||||
kind: "image-edit", route: "canvas-generate-image", enabled: true,
|
||||
description: `${label},使用支持参考图的现有图片编辑模型。`,
|
||||
})),
|
||||
{
|
||||
key: "video-prompt-optimize", label: "视频提示词优化", kind: "multimodal",
|
||||
route: "canvas-prompt-tools", enabled: true, requiresBackend: true,
|
||||
prompt: "你是专业视频提示词编辑。忠实保留用户原有主体、动作、场景、数字、品牌、@素材引用和限制,补齐景别、机位、运镜、动作连续性、光线、材质和声音等缺失细节。不得擅自改变故事,不添加不存在的角色。只输出可直接交给视频模型的中文提示词正文,不要标题、解释、Markdown或引号。",
|
||||
description: "使用已配置的文字模型优化提示词,预览确认后填入视频节点。",
|
||||
},
|
||||
{
|
||||
key: "video-prompt-translate", label: "视频提示词翻译", kind: "multimodal",
|
||||
route: "canvas-prompt-tools", enabled: true, requiresBackend: true,
|
||||
prompt: "你是视频提示词翻译器。中文为主时译为英文,英文为主时译为中文。完整保留 @素材引用、专有名词、数字、镜头编号、参数和限制,不扩写、不删减。只输出译文,不要标题、解释、Markdown或引号。",
|
||||
description: "中英文自动识别双向翻译,预览确认后填入视频节点。",
|
||||
},
|
||||
...[
|
||||
["character-face-three-view", "角色脸部三视图", "以参考图片的角色为唯一身份依据,生成横向排布的正面、左侧面、右侧面脸部三视图。保持五官、肤色、发型、饰品和年龄一致,头肩构图,纯净背景,三个角度大小一致。"],
|
||||
["character-design-sheet", "角色设定图", "根据参考角色生成完整角色设定图,包含正面、侧面、背面全身及服饰配件细节。所有视图保持同一身份、体型和服装材质,以干净背景和清楚分区呈现。"],
|
||||
|
||||
137
lib/video-canvas/director-extensions.ts
Normal file
137
lib/video-canvas/director-extensions.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { resizeObjectTracks } from "./director-object-tracks";
|
||||
import {
|
||||
defaultDirectorMotion,
|
||||
resizeDirectorMotion,
|
||||
type DirectorPose,
|
||||
type DirectorMotion,
|
||||
} from "./director-motion";
|
||||
import type {
|
||||
DirectorScene,
|
||||
DirectorCamera,
|
||||
DirectorVector,
|
||||
DirectorObject,
|
||||
} from "./director-scene";
|
||||
export function commitDirectorScene(previous: DirectorScene, next: DirectorScene): DirectorScene {
|
||||
const motionTracks = {
|
||||
...previous.motionTracks,
|
||||
...next.motionTracks,
|
||||
[previous.activeCameraId]: previous.motion,
|
||||
};
|
||||
const motion =
|
||||
previous.activeCameraId === next.activeCameraId
|
||||
? next.motion
|
||||
: motionTracks[next.activeCameraId] || {
|
||||
...defaultDirectorMotion(),
|
||||
duration: previous.motion.duration,
|
||||
};
|
||||
motionTracks[next.activeCameraId] = motion;
|
||||
for (const id of Object.keys(motionTracks))
|
||||
if (!next.cameras.some((c) => c.id === id)) delete motionTracks[id];
|
||||
let objectTracks = next.objectTracks;
|
||||
if (
|
||||
previous.activeCameraId === next.activeCameraId &&
|
||||
previous.motion.duration !== next.motion.duration
|
||||
) {
|
||||
for (const id of Object.keys(motionTracks))
|
||||
if (id !== next.activeCameraId)
|
||||
motionTracks[id] = resizeDirectorMotion(motionTracks[id], next.motion.duration);
|
||||
objectTracks = resizeObjectTracks(objectTracks, previous.motion.duration, next.motion.duration);
|
||||
}
|
||||
objectTracks = Object.fromEntries(
|
||||
Object.entries(objectTracks || {}).filter(([id]) => next.objects.some((o) => o.id === id)),
|
||||
);
|
||||
return { ...next, motion, motionTracks, objectTracks };
|
||||
}
|
||||
export function resolveDirectorCamera(scene: DirectorScene, pose?: DirectorPose): DirectorCamera {
|
||||
const camera = scene.cameras.find((c) => c.id === scene.activeCameraId) || scene.cameras[0];
|
||||
const subject = scene.objects.find((o) => o.id === camera.followObjectId);
|
||||
const result = { ...camera, ...pose };
|
||||
if (subject) {
|
||||
const target: DirectorVector = [
|
||||
subject.position[0],
|
||||
subject.position[1] + subject.scale[1],
|
||||
subject.position[2],
|
||||
];
|
||||
if (Math.hypot(...target.map((v, i) => v - result.position[i])) > 0.001) result.target = target;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export function cameraAngles(camera: DirectorPose) {
|
||||
const [x, y, z] = camera.target.map((v, i) => v - camera.position[i]);
|
||||
return {
|
||||
yaw: (Math.atan2(x, -z) * 180) / Math.PI,
|
||||
pitch: (Math.atan2(y, Math.hypot(x, z)) * 180) / Math.PI,
|
||||
};
|
||||
}
|
||||
export function rotateDirectorCamera(
|
||||
camera: DirectorPose,
|
||||
yaw: number,
|
||||
pitch: number,
|
||||
): DirectorVector {
|
||||
const distance = Math.max(
|
||||
0.01,
|
||||
Math.hypot(...camera.target.map((v, i) => v - camera.position[i])),
|
||||
);
|
||||
const y = (yaw * Math.PI) / 180,
|
||||
p = (Math.max(-89, Math.min(89, pitch)) * Math.PI) / 180;
|
||||
return [
|
||||
camera.position[0] + distance * Math.sin(y) * Math.cos(p),
|
||||
camera.position[1] + distance * Math.sin(p),
|
||||
camera.position[2] - distance * Math.cos(y) * Math.cos(p),
|
||||
];
|
||||
}
|
||||
export const directorBodies = {
|
||||
标准: [1, 1, 1],
|
||||
高挑: [0.85, 1.15, 1],
|
||||
矮壮: [1.2, 0.85, 1.1],
|
||||
纤细: [0.7, 1, 0.95],
|
||||
健壮: [1.35, 1.08, 1],
|
||||
宽厚: [1.5, 1, 1.05],
|
||||
幼童: [0.7, 0.6, 1.4],
|
||||
大头: [1, 0.85, 1.5],
|
||||
} as const;
|
||||
export function createDirectorCrowd(
|
||||
object: DirectorObject,
|
||||
existingCount: number,
|
||||
id: () => string,
|
||||
) {
|
||||
if (existingCount + 8 > 200) throw Error("场景最多支持 200 个对象");
|
||||
return Array.from({ length: 8 }, (_, index) => {
|
||||
const cell = index + 1;
|
||||
return {
|
||||
...object,
|
||||
id: id(),
|
||||
name: `${object.name} 阵列 ${cell}`,
|
||||
position: [
|
||||
object.position[0] + (cell % 3) * 1.5,
|
||||
object.position[1],
|
||||
object.position[2] + Math.floor(cell / 3) * 1.5,
|
||||
] as DirectorVector,
|
||||
};
|
||||
});
|
||||
}
|
||||
export function transformDirectorMotion(
|
||||
motion: DirectorMotion,
|
||||
translation: DirectorVector,
|
||||
yawDegrees: number,
|
||||
): DirectorMotion {
|
||||
const origin = motion.keyframes[0]?.target || [0, 0, 0],
|
||||
angle = (yawDegrees * Math.PI) / 180;
|
||||
const transform = (v: DirectorVector): DirectorVector => {
|
||||
const x = v[0] - origin[0],
|
||||
z = v[2] - origin[2];
|
||||
return [
|
||||
origin[0] + x * Math.cos(angle) - z * Math.sin(angle) + translation[0],
|
||||
v[1] + translation[1],
|
||||
origin[2] + x * Math.sin(angle) + z * Math.cos(angle) + translation[2],
|
||||
];
|
||||
};
|
||||
return {
|
||||
...motion,
|
||||
keyframes: motion.keyframes.map((k) => ({
|
||||
...k,
|
||||
position: transform(k.position),
|
||||
target: transform(k.target),
|
||||
})),
|
||||
};
|
||||
}
|
||||
42
lib/video-canvas/director-glb.ts
Normal file
42
lib/video-canvas/director-glb.ts
Normal file
@ -0,0 +1,42 @@
|
||||
export const DIRECTOR_MAX_GLB_BYTES = 25 * 1024 * 1024;
|
||||
/** Only self-contained GLB 2.0 files are accepted; external buffers/textures are not portable. */
|
||||
export function validateDirectorGlb(buffer: ArrayBuffer) {
|
||||
if (buffer.byteLength < 20 || buffer.byteLength > DIRECTOR_MAX_GLB_BYTES)
|
||||
throw new Error("请上传 25 MiB 以内的有效 GLB 模型");
|
||||
const view = new DataView(buffer);
|
||||
if (
|
||||
view.getUint32(0, true) !== 0x46546c67 ||
|
||||
view.getUint32(4, true) !== 2 ||
|
||||
view.getUint32(8, true) !== buffer.byteLength
|
||||
)
|
||||
throw new Error("文件不是有效的 GLB 2.0 模型");
|
||||
const length = view.getUint32(12, true);
|
||||
if (
|
||||
view.getUint32(16, true) !== 0x4e4f534a ||
|
||||
length % 4 ||
|
||||
length > buffer.byteLength - 20
|
||||
)
|
||||
throw new Error("GLB 场景数据不完整");
|
||||
let document: any;
|
||||
try {
|
||||
document = JSON.parse(
|
||||
new TextDecoder().decode(new Uint8Array(buffer, 20, length)),
|
||||
);
|
||||
} catch {
|
||||
throw new Error("GLB 场景数据无效");
|
||||
}
|
||||
if (
|
||||
document?.asset?.version !== "2.0" ||
|
||||
!Array.isArray(document.meshes) ||
|
||||
!document.meshes.length
|
||||
)
|
||||
throw new Error("GLB 中没有可显示的网格");
|
||||
for (const resource of [
|
||||
...(document.buffers || []),
|
||||
...(document.images || []),
|
||||
]) {
|
||||
if (resource.uri && !/^data:/i.test(resource.uri))
|
||||
throw new Error("请将纹理和缓冲区嵌入 GLB 后重新上传");
|
||||
}
|
||||
return document;
|
||||
}
|
||||
228
lib/video-canvas/director-motion.ts
Normal file
228
lib/video-canvas/director-motion.ts
Normal file
@ -0,0 +1,228 @@
|
||||
import type { DirectorCamera, DirectorVector } from "./director-scene";
|
||||
export type DirectorPose = Pick<DirectorCamera, "position" | "target" | "fov">;
|
||||
export function mergeDirectorCameraPose(
|
||||
camera: DirectorCamera,
|
||||
pose: Partial<DirectorPose>,
|
||||
): DirectorCamera {
|
||||
return {
|
||||
...camera,
|
||||
position: pose.position ?? camera.position,
|
||||
target: pose.target ?? camera.target,
|
||||
fov: pose.fov ?? camera.fov,
|
||||
};
|
||||
}
|
||||
export type DirectorKeyframe = DirectorPose & { id: string; time: number };
|
||||
export type DirectorMotion = {
|
||||
duration: number;
|
||||
easing: "linear" | "smooth";
|
||||
loop: boolean;
|
||||
keyframes: DirectorKeyframe[];
|
||||
};
|
||||
export const defaultDirectorMotion = (): DirectorMotion => ({
|
||||
duration: 5,
|
||||
easing: "smooth",
|
||||
loop: false,
|
||||
keyframes: [],
|
||||
});
|
||||
const vector = (v: unknown): v is DirectorVector =>
|
||||
Array.isArray(v) &&
|
||||
v.length === 3 &&
|
||||
v.every(
|
||||
(n) => typeof n === "number" && Number.isFinite(n) && Math.abs(n) <= 10000,
|
||||
);
|
||||
export function validDirectorPose(p: DirectorPose) {
|
||||
return (
|
||||
!!p &&
|
||||
vector(p.position) &&
|
||||
vector(p.target) &&
|
||||
Number.isFinite(p.fov) &&
|
||||
p.fov >= 15 &&
|
||||
p.fov <= 90 &&
|
||||
Math.hypot(...p.position.map((n, i) => n - p.target[i])) > 0.001
|
||||
);
|
||||
}
|
||||
export function readDirectorMotion(input: unknown): DirectorMotion {
|
||||
const v = (input || {}) as Partial<DirectorMotion>;
|
||||
const duration = Number.isFinite(v.duration)
|
||||
? Math.max(2, Math.min(15, Number(v.duration)))
|
||||
: 5;
|
||||
const seen = new Set<string>();
|
||||
const times = new Set<number>();
|
||||
const keyframes = (Array.isArray(v.keyframes) ? v.keyframes : [])
|
||||
.filter((k) => {
|
||||
if (
|
||||
!k ||
|
||||
typeof k.id !== "string" ||
|
||||
seen.has(k.id) ||
|
||||
!Number.isFinite(k.time) ||
|
||||
k.time < 0 ||
|
||||
k.time > duration ||
|
||||
times.has(k.time) ||
|
||||
!validDirectorPose(k)
|
||||
)
|
||||
return false;
|
||||
seen.add(k.id);
|
||||
times.add(k.time);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 100)
|
||||
.map((k) => ({
|
||||
id: k.id,
|
||||
time: k.time,
|
||||
position: [...k.position] as DirectorVector,
|
||||
target: [...k.target] as DirectorVector,
|
||||
fov: k.fov,
|
||||
}))
|
||||
.sort((a, b) => a.time - b.time);
|
||||
return {
|
||||
duration,
|
||||
easing: v.easing === "linear" ? "linear" : "smooth",
|
||||
loop: v.loop === true,
|
||||
keyframes,
|
||||
};
|
||||
}
|
||||
const lerpVector = (
|
||||
a: DirectorVector,
|
||||
b: DirectorVector,
|
||||
t: number,
|
||||
): DirectorVector => a.map((n, i) => n + (b[i] - n) * t) as DirectorVector;
|
||||
export function sampleDirectorMotion(
|
||||
m: DirectorMotion,
|
||||
time: number,
|
||||
): DirectorPose | null {
|
||||
const keys = m.keyframes;
|
||||
if (!keys.length) return null;
|
||||
const t = Number.isFinite(time) ? Math.max(0, Math.min(m.duration, time)) : 0;
|
||||
const right = keys.findIndex((k) => k.time > t);
|
||||
const a = right < 0 ? keys[keys.length - 1] : keys[Math.max(0, right - 1)];
|
||||
const b = right < 0 ? a : keys[right];
|
||||
let u =
|
||||
a === b ? 0 : Math.max(0, Math.min(1, (t - a.time) / (b.time - a.time)));
|
||||
if (m.easing === "smooth") u = u * u * (3 - 2 * u);
|
||||
const pose = {
|
||||
position: lerpVector(a.position, b.position, u),
|
||||
target: lerpVector(a.target, b.target, u),
|
||||
fov: a.fov + (b.fov - a.fov) * u,
|
||||
};
|
||||
// A path crossing its look-at target has no defined viewing direction.
|
||||
if (!validDirectorPose(pose)) {
|
||||
const direction = a.position.map((n, i) => n - a.target[i]);
|
||||
const length = Math.hypot(...direction);
|
||||
pose.target = pose.position.map(
|
||||
(n, i) => n - (direction[i] / length) * 0.01,
|
||||
) as DirectorVector;
|
||||
}
|
||||
return pose;
|
||||
}
|
||||
export function resizeDirectorMotion(
|
||||
m: DirectorMotion,
|
||||
duration: number,
|
||||
): DirectorMotion {
|
||||
const next = Math.max(2, Math.min(15, duration));
|
||||
return {
|
||||
...m,
|
||||
duration: next,
|
||||
keyframes: m.keyframes.map((k) => ({
|
||||
...k,
|
||||
time: (k.time / m.duration) * next,
|
||||
})),
|
||||
};
|
||||
}
|
||||
export function presetDirectorMotion(
|
||||
pose: DirectorPose,
|
||||
kind: "push" | "pull" | "orbit" | "pan",
|
||||
duration = 5,
|
||||
): DirectorMotion {
|
||||
const m = { ...defaultDirectorMotion(), duration };
|
||||
const offset = pose.position.map(
|
||||
(n, i) => n - pose.target[i],
|
||||
) as DirectorVector;
|
||||
const count = kind === "orbit" ? 9 : 2;
|
||||
m.keyframes = Array.from({ length: count }, (_, i) => {
|
||||
const u = i / (count - 1);
|
||||
let position: DirectorVector = [...pose.position],
|
||||
target: DirectorVector = [...pose.target];
|
||||
if (kind === "push" || kind === "pull")
|
||||
position = pose.target.map(
|
||||
(n, j) => n + offset[j] * (1 + u * (kind === "push" ? -0.5 : 0.5)),
|
||||
) as DirectorVector;
|
||||
if (kind === "orbit") {
|
||||
const angle = (u * Math.PI) / 2;
|
||||
position = [
|
||||
pose.target[0] +
|
||||
offset[0] * Math.cos(angle) +
|
||||
offset[2] * Math.sin(angle),
|
||||
pose.position[1],
|
||||
pose.target[2] -
|
||||
offset[0] * Math.sin(angle) +
|
||||
offset[2] * Math.cos(angle),
|
||||
];
|
||||
}
|
||||
if (kind === "pan") {
|
||||
const length = Math.hypot(offset[0], offset[2]) || 1;
|
||||
const shift: DirectorVector = [
|
||||
(offset[2] / length) * 2 * u,
|
||||
0,
|
||||
(-offset[0] / length) * 2 * u,
|
||||
];
|
||||
position = position.map((n, j) => n + shift[j]) as DirectorVector;
|
||||
target = target.map((n, j) => n + shift[j]) as DirectorVector;
|
||||
}
|
||||
return {
|
||||
id: `${kind}-${i}`,
|
||||
time: u * duration,
|
||||
position,
|
||||
target,
|
||||
fov: pose.fov,
|
||||
};
|
||||
});
|
||||
if (kind === "orbit") m.easing = "linear";
|
||||
return m;
|
||||
}
|
||||
export function directorMotionPrompt(m: DirectorMotion) {
|
||||
if (m.keyframes.length < 2) return "";
|
||||
// Keep the model prompt compact; the full trajectory remains in the canvas data.
|
||||
const keys =
|
||||
m.keyframes.length <= 9
|
||||
? m.keyframes
|
||||
: Array.from({ length: 9 }, (_, i) => ({
|
||||
time: (m.duration * i) / 8,
|
||||
...sampleDirectorMotion(m, (m.duration * i) / 8)!,
|
||||
}));
|
||||
const lines = keys.slice(1).map((b, i) => {
|
||||
const a = keys[i],
|
||||
av = a.position.map((n, j) => n - a.target[j]),
|
||||
bv = b.position.map((n, j) => n - b.target[j]);
|
||||
const ar = Math.hypot(...av),
|
||||
br = Math.hypot(...bv),
|
||||
angle =
|
||||
(Math.acos(
|
||||
Math.max(
|
||||
-1,
|
||||
Math.min(
|
||||
1,
|
||||
av.reduce((sum, n, j) => sum + n * bv[j], 0) / (ar * br),
|
||||
),
|
||||
),
|
||||
) *
|
||||
180) /
|
||||
Math.PI;
|
||||
const descriptions: string[] = [];
|
||||
if (angle > 2)
|
||||
descriptions.push(`围绕注视主体转动约 ${Math.round(angle)} 度`);
|
||||
if (br < ar * 0.98) descriptions.push("向主体推进");
|
||||
else if (br > ar * 1.02) descriptions.push("远离主体拉远");
|
||||
if (Math.hypot(...b.target.map((n, j) => n - a.target[j])) > 0.05)
|
||||
descriptions.push("注视点随镜头平移");
|
||||
if (Math.abs(b.position[1] - a.position[1]) > 0.05)
|
||||
descriptions.push(
|
||||
b.position[1] > a.position[1] ? "相机升高" : "相机降低",
|
||||
);
|
||||
if (Math.abs(b.fov - a.fov) > 0.5)
|
||||
descriptions.push(
|
||||
`视角从 ${a.fov.toFixed(0)}° 变至 ${b.fov.toFixed(0)}°`,
|
||||
);
|
||||
return `${a.time.toFixed(2)}–${b.time.toFixed(2)}秒:${descriptions.join(",") || "保持机位和构图"}。`;
|
||||
});
|
||||
return `单一连续镜头,不切镜,总时长 ${m.duration} 秒;镜头${m.easing === "smooth" ? "平滑缓入缓出" : "匀速"}运动。\n${lines.join("\n")}\n保持参考图中的主体和场景一致,镜头连续运动。`;
|
||||
}
|
||||
147
lib/video-canvas/director-object-tracks.ts
Normal file
147
lib/video-canvas/director-object-tracks.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import type { DirectorScene, DirectorObject, DirectorVector } from "./director-scene";
|
||||
export type DirectorObjectFrame = {
|
||||
id: string;
|
||||
time: number;
|
||||
position: DirectorVector;
|
||||
rotation: DirectorVector;
|
||||
scale: DirectorVector;
|
||||
visible: boolean;
|
||||
};
|
||||
export type DirectorObjectTrack = {
|
||||
muted: boolean;
|
||||
easing: "linear" | "smooth";
|
||||
keyframes: DirectorObjectFrame[];
|
||||
};
|
||||
export function readObjectTracks(
|
||||
value: unknown,
|
||||
objects: DirectorObject[],
|
||||
duration: number,
|
||||
): Record<string, DirectorObjectTrack> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
const vector = (v: any, positive = false) =>
|
||||
Array.isArray(v) &&
|
||||
v.length === 3 &&
|
||||
v.every(
|
||||
(n: any) =>
|
||||
typeof n === "number" && Number.isFinite(n) && Math.abs(n) <= 10000 && (!positive || n > 0),
|
||||
);
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([id]) => objects.some((o) => o.id === id))
|
||||
.map(([id, raw]) => {
|
||||
const t = raw as any,
|
||||
seen = new Set<number>(),
|
||||
ids = new Set<string>();
|
||||
const keyframes = (Array.isArray(t?.keyframes) ? t.keyframes : [])
|
||||
.filter((k: any) => {
|
||||
if (
|
||||
!k ||
|
||||
typeof k.id !== "string" ||
|
||||
ids.has(k.id) ||
|
||||
typeof k.time !== "number" ||
|
||||
!Number.isFinite(k.time) ||
|
||||
k.time < 0 ||
|
||||
k.time > duration ||
|
||||
seen.has(k.time) ||
|
||||
!vector(k.position) ||
|
||||
!vector(k.rotation) ||
|
||||
!vector(k.scale, true)
|
||||
)
|
||||
return false;
|
||||
seen.add(k.time);
|
||||
ids.add(k.id);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 100)
|
||||
.map((k: any) => ({
|
||||
id: k.id,
|
||||
time: k.time,
|
||||
position: [...k.position],
|
||||
rotation: [...k.rotation],
|
||||
scale: [...k.scale],
|
||||
visible: k.visible !== false,
|
||||
}))
|
||||
.sort((a: any, b: any) => a.time - b.time);
|
||||
return [
|
||||
id,
|
||||
{
|
||||
muted: t?.muted === true,
|
||||
easing: t?.easing === "smooth" ? "smooth" : "linear",
|
||||
keyframes,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
export function sampleDirectorObjects(scene: DirectorScene, time: number | null): DirectorObject[] {
|
||||
if (time === null) return scene.objects;
|
||||
return scene.objects.map((object) => {
|
||||
const track = scene.objectTracks?.[object.id];
|
||||
if (!track || track.muted || !track.keyframes.length) return object;
|
||||
const keys = track.keyframes,
|
||||
t = Math.max(0, Math.min(scene.motion.duration, time)),
|
||||
right = keys.findIndex((k) => k.time > t);
|
||||
const a = right < 0 ? keys[keys.length - 1] : keys[Math.max(0, right - 1)],
|
||||
b = right < 0 ? a : keys[right];
|
||||
let u = a === b ? 0 : Math.max(0, Math.min(1, (t - a.time) / (b.time - a.time)));
|
||||
if (track.easing === "smooth") u = u * u * (3 - 2 * u);
|
||||
const vector = (x: DirectorVector, y: DirectorVector, rotation = false): DirectorVector =>
|
||||
x.map((n, i) => {
|
||||
let delta = y[i] - n;
|
||||
if (rotation) delta = Math.atan2(Math.sin(delta), Math.cos(delta));
|
||||
return n + delta * u;
|
||||
}) as DirectorVector;
|
||||
return {
|
||||
...object,
|
||||
position: vector(a.position, b.position),
|
||||
rotation: vector(a.rotation, b.rotation, true),
|
||||
scale: vector(a.scale, b.scale),
|
||||
visible: a.visible,
|
||||
};
|
||||
});
|
||||
}
|
||||
export function recordObjectFrames(
|
||||
scene: DirectorScene,
|
||||
ids: string[],
|
||||
time: number,
|
||||
id: () => string,
|
||||
): DirectorScene {
|
||||
if (!Number.isFinite(time) || time < 0 || time > scene.motion.duration)
|
||||
throw Error("关键帧时间超出时间轴");
|
||||
const tracks = { ...scene.objectTracks };
|
||||
for (const object of scene.objects.filter((o) => ids.includes(o.id))) {
|
||||
const track = tracks[object.id] || { muted: false, easing: "linear" as const, keyframes: [] };
|
||||
const existing = track.keyframes.find((k) => Math.abs(k.time - time) < 0.01);
|
||||
if (!existing && track.keyframes.length >= 100) throw Error("每个对象最多 100 个关键帧");
|
||||
const frame = {
|
||||
id: existing?.id || id(),
|
||||
time: existing?.time ?? Math.round(time * 100) / 100,
|
||||
position: [...object.position] as DirectorVector,
|
||||
rotation: [...object.rotation] as DirectorVector,
|
||||
scale: [...object.scale] as DirectorVector,
|
||||
visible: object.visible !== false,
|
||||
};
|
||||
tracks[object.id] = {
|
||||
...track,
|
||||
keyframes: [...track.keyframes.filter((k) => k.id !== frame.id), frame].sort(
|
||||
(a, b) => a.time - b.time,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { ...scene, objectTracks: tracks };
|
||||
}
|
||||
export function resizeObjectTracks(
|
||||
tracks: Record<string, DirectorObjectTrack> | undefined,
|
||||
oldDuration: number,
|
||||
newDuration: number,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(tracks || {}).map(([id, t]) => [
|
||||
id,
|
||||
{
|
||||
...t,
|
||||
keyframes: t.keyframes.map((k) => ({ ...k, time: (k.time * newDuration) / oldDuration })),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
41
lib/video-canvas/director-poses.ts
Normal file
41
lib/video-canvas/director-poses.ts
Normal file
@ -0,0 +1,41 @@
|
||||
// Angles in degrees. Each joint is local to its parent, so limbs remain connected.
|
||||
export const directorPoses = {
|
||||
站立: { arms: [0, 0], elbows: [0, 0], legs: [0, 0], knees: [0, 0], lean: 0 },
|
||||
"T 型": { arms: [0, 0], elbows: [0, 0], legs: [0, 0], knees: [0, 0], lean: 0, spread: 90 },
|
||||
行走: { arms: [-30, 30], elbows: [15, 15], legs: [30, -30], knees: [10, 35], lean: 0 },
|
||||
跑步: { arms: [-55, 55], elbows: [80, 80], legs: [60, -45], knees: [70, 100], lean: 12 },
|
||||
坐姿: {
|
||||
arms: [10, 10],
|
||||
elbows: [75, 75],
|
||||
legs: [-90, -90],
|
||||
knees: [90, 90],
|
||||
lean: 0,
|
||||
height: -0.45,
|
||||
},
|
||||
蹲下: {
|
||||
arms: [-65, -65],
|
||||
elbows: [15, 15],
|
||||
legs: [-95, -95],
|
||||
knees: [125, 125],
|
||||
lean: 25,
|
||||
height: -0.65,
|
||||
},
|
||||
招手: { arms: [0, -165], elbows: [0, 30], legs: [0, 0], knees: [0, 0], lean: 0 },
|
||||
伸手: { arms: [-90, -90], elbows: [0, 0], legs: [0, 0], knees: [0, 0], lean: 0 },
|
||||
鞠躬: { arms: [-20, -20], elbows: [0, 0], legs: [0, 0], knees: [0, 0], lean: 40 },
|
||||
格斗: { arms: [-50, -75], elbows: [100, 90], legs: [-20, 20], knees: [20, 35], lean: 5 },
|
||||
踢球: { arms: [-15, 30], elbows: [20, 20], legs: [-70, 5], knees: [10, 10], lean: -10 },
|
||||
看手机: { arms: [-30, -30], elbows: [105, 105], legs: [0, 0], knees: [0, 0], lean: 8 },
|
||||
} satisfies Record<
|
||||
string,
|
||||
{
|
||||
arms: number[];
|
||||
elbows: number[];
|
||||
legs: number[];
|
||||
knees: number[];
|
||||
lean: number;
|
||||
spread?: number;
|
||||
height?: number;
|
||||
}
|
||||
>;
|
||||
export type DirectorPoseName = keyof typeof directorPoses;
|
||||
69
lib/video-canvas/director-reconstruction.ts
Normal file
69
lib/video-canvas/director-reconstruction.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { z } from "zod";
|
||||
import { defaultDirectorScene, type DirectorScene } from "./director-scene";
|
||||
import { validDirectorPose } from "./director-motion";
|
||||
const vector = z.tuple([
|
||||
z.number().finite().min(-100).max(100),
|
||||
z.number().finite().min(-100).max(100),
|
||||
z.number().finite().min(-100).max(100),
|
||||
]);
|
||||
const scale = z.tuple([
|
||||
z.number().min(0.05).max(30),
|
||||
z.number().min(0.05).max(30),
|
||||
z.number().min(0.05).max(30),
|
||||
]);
|
||||
const reconstructionSchema = z.object({
|
||||
summary: z.string().min(1).max(2000),
|
||||
objects: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1).max(80),
|
||||
kind: z.enum(["actor", "box", "sphere"]),
|
||||
position: vector,
|
||||
rotation: vector,
|
||||
scale,
|
||||
color: z.string().regex(/^#[0-9a-f]{6}$/i),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(30),
|
||||
camera: z
|
||||
.object({ position: vector, target: vector, fov: z.number().min(15).max(90) })
|
||||
.refine(validDirectorPose, "相机不能与注视点重合"),
|
||||
});
|
||||
export function reconstructionPrompt(prompt: string) {
|
||||
return `根据参考图重建可编辑的3D布景(灰盒),不是输出图片或GLB。只输出JSON,字段:summary、objects、camera。只重建看得见的主要人物和物体,忽略细碎纹理,不编造遮挡后的形状。以米为近似单位,Y轴向上,地面y=0;对象局部几何 actor 约2米高,box边长1米,sphere直径1米,scale必须据此设置;对象中心/脚底位置遵循 actor脚底、box和sphere中心。rotation是弧度。所有坐标在±100内,scale在0.05到30内。objects最多30项,每项{name,kind:"actor"|"box"|"sphere",position:[x,y,z],rotation:[rx,ry,rz],scale:[sx,sy,sz],color:"#RRGGBB"}。camera={position:[x,y,z],target:[x,y,z],fov:15到90},还原参考图的构图。summary说明不确定的比例与缺失信息。用户布景要求:${prompt || "尽量保留原图主体布局与机位"}`;
|
||||
}
|
||||
export function parseReconstructedScene(value: unknown): {
|
||||
summary: string;
|
||||
directorScene: DirectorScene;
|
||||
} {
|
||||
const parsed = reconstructionSchema.parse(value),
|
||||
base = defaultDirectorScene();
|
||||
return {
|
||||
summary: parsed.summary,
|
||||
directorScene: {
|
||||
...base,
|
||||
objects: parsed.objects.map((o, i) => ({ ...o, id: `reconstructed-${i}` })),
|
||||
cameras: [{ ...parsed.camera, id: "reconstructed-camera", name: "参考图机位" }],
|
||||
activeCameraId: "reconstructed-camera",
|
||||
},
|
||||
};
|
||||
}
|
||||
export function insertReconstructedScene(
|
||||
current: DirectorScene,
|
||||
generated: DirectorScene,
|
||||
replace: boolean,
|
||||
id: () => string,
|
||||
): DirectorScene {
|
||||
const objects = generated.objects.map((o) => ({ ...o, id: id() }));
|
||||
if ((replace ? 0 : current.objects.length) + objects.length > 200)
|
||||
throw Error("场景最多支持 200 个对象");
|
||||
const camera = { ...generated.cameras[0], id: id(), followObjectId: undefined };
|
||||
return {
|
||||
...current,
|
||||
objects: replace ? objects : [...current.objects, ...objects],
|
||||
cameras: [...current.cameras, camera],
|
||||
activeCameraId: camera.id,
|
||||
...(replace ? { objectTracks: {} } : {}),
|
||||
};
|
||||
}
|
||||
124
lib/video-canvas/director-recording.ts
Normal file
124
lib/video-canvas/director-recording.ts
Normal file
@ -0,0 +1,124 @@
|
||||
/** Record the rendered camera path, never an AI approximation. */
|
||||
export async function recordDirectorVideo({
|
||||
canvas,
|
||||
duration,
|
||||
render,
|
||||
signal,
|
||||
onProgress,
|
||||
}: {
|
||||
canvas: HTMLCanvasElement;
|
||||
duration: number;
|
||||
render: (time: number) => void;
|
||||
signal: AbortSignal;
|
||||
onProgress?: (time: number) => void;
|
||||
}): Promise<Blob> {
|
||||
const mimeType = [
|
||||
"video/mp4",
|
||||
"video/webm;codecs=vp9",
|
||||
"video/webm;codecs=vp8",
|
||||
"video/webm",
|
||||
].find((t) => typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(t));
|
||||
if (!mimeType || !canvas.captureStream)
|
||||
throw Error("当前浏览器不支持视频录制,请更换支持视频录制的浏览器");
|
||||
if (signal.aborted || document.hidden) throw Error("请保持当前页面可见后导出");
|
||||
// Let WebGL present the first frame before opening the encoder stream.
|
||||
render(0);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let frame = 0;
|
||||
const finish = (error?: Error) => {
|
||||
cancelAnimationFrame(frame);
|
||||
clearTimeout(timeout);
|
||||
signal.removeEventListener("abort", cancel);
|
||||
document.removeEventListener("visibilitychange", hidden);
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
const cancel = () => finish(Error("已取消轨迹视频导出"));
|
||||
const hidden = () => {
|
||||
if (document.hidden) finish(Error("导出时切换了页面,请回到当前页面重新导出"));
|
||||
};
|
||||
const timeout = setTimeout(() => finish(Error("画面准备超时,请保持页面可见后重试")), 2000);
|
||||
signal.addEventListener("abort", cancel, { once: true });
|
||||
document.addEventListener("visibilitychange", hidden);
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = requestAnimationFrame(() => finish());
|
||||
});
|
||||
});
|
||||
if (signal.aborted || document.hidden) throw Error("请保持当前页面可见后导出");
|
||||
const stream = canvas.captureStream(30);
|
||||
try {
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
const recorder = new MediaRecorder(stream, {
|
||||
mimeType,
|
||||
videoBitsPerSecond: 6_000_000,
|
||||
});
|
||||
const chunks: Blob[] = [];
|
||||
let frame = 0,
|
||||
started = 0,
|
||||
failure: Error | undefined,
|
||||
finished = false;
|
||||
const cleanup = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
clearTimeout(timeout);
|
||||
signal.removeEventListener("abort", cancel);
|
||||
document.removeEventListener("visibilitychange", visibility);
|
||||
};
|
||||
const stop = (error?: Error) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
failure = error;
|
||||
cleanup();
|
||||
if (recorder.state !== "inactive") recorder.stop();
|
||||
else reject(error || Error("视频录制未启动"));
|
||||
};
|
||||
const cancel = () => stop(Error("已取消轨迹视频导出"));
|
||||
const visibility = () => {
|
||||
if (document.hidden) stop(Error("导出时切换了页面,请回到当前页面重新导出"));
|
||||
};
|
||||
const timeout = setTimeout(
|
||||
() => stop(Error("轨迹导出超时,请重试")),
|
||||
duration * 1000 + 15000,
|
||||
);
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size) chunks.push(e.data);
|
||||
};
|
||||
recorder.onerror = () => stop(Error("浏览器视频编码失败"));
|
||||
recorder.onstop = () => {
|
||||
cleanup();
|
||||
if (failure) reject(failure);
|
||||
else if (!chunks.length) reject(Error("未生成有效视频"));
|
||||
else resolve(new Blob(chunks, { type: recorder.mimeType }));
|
||||
};
|
||||
signal.addEventListener("abort", cancel, { once: true });
|
||||
document.addEventListener("visibilitychange", visibility);
|
||||
const tick = (now: number) => {
|
||||
if (finished) return;
|
||||
if (!started) started = now;
|
||||
const t = Math.min(duration, (now - started) / 1000);
|
||||
try {
|
||||
render(t);
|
||||
onProgress?.(t);
|
||||
} catch (e) {
|
||||
stop(e instanceof Error ? e : Error("轨迹渲染失败"));
|
||||
return;
|
||||
}
|
||||
// MediaRecorder consumes the canvas asynchronously. Stopping in the rendering
|
||||
// callback drops the final pose; allow two presentation frames to flush it.
|
||||
if (t >= duration)
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = requestAnimationFrame(() => stop());
|
||||
});
|
||||
else frame = requestAnimationFrame(tick);
|
||||
};
|
||||
try {
|
||||
render(0);
|
||||
recorder.start(100);
|
||||
frame = requestAnimationFrame(tick);
|
||||
} catch (e) {
|
||||
stop(e instanceof Error ? e : Error("视频编码失败"));
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
}
|
||||
}
|
||||
183
lib/video-canvas/director-scene.ts
Normal file
183
lib/video-canvas/director-scene.ts
Normal file
@ -0,0 +1,183 @@
|
||||
import { readObjectTracks, type DirectorObjectTrack } from "./director-object-tracks";
|
||||
import { directorBodies } from "./director-extensions";
|
||||
import { directorPoses, type DirectorPoseName } from "./director-poses";
|
||||
import {
|
||||
defaultDirectorMotion,
|
||||
readDirectorMotion,
|
||||
validDirectorPose,
|
||||
type DirectorMotion,
|
||||
} from "./director-motion";
|
||||
export type DirectorVector = [number, number, number];
|
||||
export type DirectorObject = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: "actor" | "box" | "sphere" | "model";
|
||||
url?: string;
|
||||
color?: string;
|
||||
visible?: boolean;
|
||||
locked?: boolean;
|
||||
pose?: DirectorPoseName;
|
||||
body?: keyof typeof directorBodies;
|
||||
position: DirectorVector;
|
||||
rotation: DirectorVector;
|
||||
scale: DirectorVector;
|
||||
};
|
||||
export type DirectorCamera = {
|
||||
followObjectId?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
position: DirectorVector;
|
||||
target: DirectorVector;
|
||||
fov: number;
|
||||
};
|
||||
export type DirectorSnapshot = {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
cameraId: string;
|
||||
};
|
||||
export type DirectorScene = {
|
||||
objects: DirectorObject[];
|
||||
cameras: DirectorCamera[];
|
||||
activeCameraId: string;
|
||||
ratio: string;
|
||||
snapshots: DirectorSnapshot[];
|
||||
motion: DirectorMotion;
|
||||
motionTracks?: Record<string, DirectorMotion>;
|
||||
objectTracks?: Record<string, DirectorObjectTrack>;
|
||||
backgroundUrl?: string;
|
||||
backgroundHistory?: string[];
|
||||
};
|
||||
export const directorRatios = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"];
|
||||
export function directorImageSize(ratio: string) {
|
||||
const [w, h] = (directorRatios.includes(ratio) ? ratio : "16:9").split(":").map(Number);
|
||||
return w >= h
|
||||
? { width: 1280, height: Math.round((1280 * h) / w) }
|
||||
: { width: Math.round((1280 * w) / h), height: 1280 };
|
||||
}
|
||||
export function defaultDirectorScene(): DirectorScene {
|
||||
return {
|
||||
objects: [
|
||||
{
|
||||
id: "actor-a",
|
||||
name: "基础人偶",
|
||||
kind: "actor",
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
],
|
||||
cameras: [
|
||||
{
|
||||
id: "camera-1",
|
||||
name: "机位1",
|
||||
position: [4, 3, 7],
|
||||
target: [0, 1, 0],
|
||||
fov: 50,
|
||||
},
|
||||
],
|
||||
activeCameraId: "camera-1",
|
||||
ratio: "16:9",
|
||||
snapshots: [],
|
||||
motion: defaultDirectorMotion(),
|
||||
motionTracks: {},
|
||||
objectTracks: {},
|
||||
};
|
||||
}
|
||||
export function readDirectorScene(value: unknown): DirectorScene {
|
||||
const fallback = defaultDirectorScene();
|
||||
if (!value || typeof value !== "object") return fallback;
|
||||
const v = value as DirectorScene;
|
||||
const vector = (a: unknown): a is DirectorVector =>
|
||||
Array.isArray(a) &&
|
||||
a.length === 3 &&
|
||||
a.every((n) => typeof n === "number" && Number.isFinite(n));
|
||||
const cameras = Array.isArray(v.cameras)
|
||||
? v.cameras.filter(
|
||||
(c) =>
|
||||
c &&
|
||||
typeof c.id === "string" &&
|
||||
typeof c.name === "string" &&
|
||||
vector(c.position) &&
|
||||
vector(c.target) &&
|
||||
Number.isFinite(c.fov) &&
|
||||
c.fov >= 15 &&
|
||||
c.fov <= 90 &&
|
||||
validDirectorPose(c),
|
||||
)
|
||||
: [];
|
||||
const objects = Array.isArray(v.objects)
|
||||
? v.objects.filter(
|
||||
(o) =>
|
||||
o &&
|
||||
typeof o.id === "string" &&
|
||||
typeof o.name === "string" &&
|
||||
["actor", "box", "sphere", "model"].includes(o.kind) &&
|
||||
vector(o.position) &&
|
||||
vector(o.rotation) &&
|
||||
vector(o.scale) &&
|
||||
o.scale.every((n) => n > 0) &&
|
||||
(o.kind !== "model" || /^https?:\/\//i.test(o.url || "")),
|
||||
)
|
||||
: fallback.objects;
|
||||
const snapshots = Array.isArray(v.snapshots)
|
||||
? v.snapshots.filter(
|
||||
(s) =>
|
||||
s &&
|
||||
typeof s.id === "string" &&
|
||||
typeof s.title === "string" &&
|
||||
/^https?:\/\//i.test(s.url) &&
|
||||
s.width > 0 &&
|
||||
s.height > 0,
|
||||
)
|
||||
: [];
|
||||
const validCameras = cameras.length ? cameras : fallback.cameras;
|
||||
return {
|
||||
objects: objects.map((o) => {
|
||||
const clean = { ...o };
|
||||
if (
|
||||
o.color !== undefined &&
|
||||
(typeof o.color !== "string" || !/^#[0-9a-f]{6}$/i.test(o.color))
|
||||
)
|
||||
delete clean.color;
|
||||
if (o.visible !== undefined) clean.visible = o.visible !== false;
|
||||
if (o.locked !== undefined) clean.locked = o.locked === true;
|
||||
if (o.pose !== undefined && !Object.hasOwn(directorPoses, o.pose)) delete clean.pose;
|
||||
if (o.body !== undefined && !Object.hasOwn(directorBodies, o.body)) delete clean.body;
|
||||
return clean;
|
||||
}),
|
||||
cameras: validCameras.map((c) => {
|
||||
const clean = { ...c };
|
||||
if (!objects.some((o) => o.id === c.followObjectId)) delete clean.followObjectId;
|
||||
return clean;
|
||||
}),
|
||||
activeCameraId: validCameras.some((c) => c.id === v.activeCameraId)
|
||||
? v.activeCameraId
|
||||
: validCameras[0].id,
|
||||
ratio: directorRatios.includes(v.ratio) ? v.ratio : "16:9",
|
||||
snapshots,
|
||||
motion: readDirectorMotion(v.motion),
|
||||
...(typeof v.backgroundUrl === "string" && /^https?:\/\//i.test(v.backgroundUrl)
|
||||
? { backgroundUrl: v.backgroundUrl }
|
||||
: {}),
|
||||
...(Array.isArray(v.backgroundHistory)
|
||||
? {
|
||||
backgroundHistory: [
|
||||
...new Set(
|
||||
v.backgroundHistory.filter(
|
||||
(url) => typeof url === "string" && /^https?:\/\//i.test(url),
|
||||
),
|
||||
),
|
||||
].slice(-20),
|
||||
}
|
||||
: {}),
|
||||
objectTracks: readObjectTracks(v.objectTracks, objects, readDirectorMotion(v.motion).duration),
|
||||
motionTracks: Object.fromEntries(
|
||||
Object.entries(v.motionTracks || {})
|
||||
.filter(([id]) => validCameras.some((c) => c.id === id))
|
||||
.map(([id, motion]) => [id, readDirectorMotion(motion)]),
|
||||
),
|
||||
};
|
||||
}
|
||||
35
lib/video-canvas/download.ts
Normal file
35
lib/video-canvas/download.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/** Downloads through the authenticated canvas export route, including cross-origin media. */
|
||||
export async function downloadCanvasMedia(files: Array<{ url: string; name: string }>) {
|
||||
if (!files.length) throw Error("没有可下载的素材");
|
||||
if (files.length > 200) throw Error("每次最多下载 200 项,请分批选择");
|
||||
const batch = files.length > 1;
|
||||
const response = await fetch(batch ? "/api/canvas/download-zip" : "/api/canvas/download-image", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(batch ? { files, folderName: "画布素材" } : files[0]),
|
||||
signal: AbortSignal.timeout(300000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw Error(data.error || "素材下载失败");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (!blob.size) throw Error("返回的下载文件为空");
|
||||
const encodedName = response.headers
|
||||
.get("content-disposition")
|
||||
?.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
||||
const filename = encodedName
|
||||
? decodeURIComponent(encodedName)
|
||||
: batch
|
||||
? "画布素材.zip"
|
||||
: files[0].name;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
return { failed: Number(response.headers.get("X-Download-Failed-Count") || 0) };
|
||||
}
|
||||
29
lib/video-canvas/generation-contract.ts
Normal file
29
lib/video-canvas/generation-contract.ts
Normal file
@ -0,0 +1,29 @@
|
||||
export function canvasGenerationCount(value: unknown) {
|
||||
const count = Number.parseInt(String(value), 10);
|
||||
return [1, 2, 4].includes(count) ? count : 1;
|
||||
}
|
||||
export function canvasImageRequest(
|
||||
settings: { model?: string; ratio: string; resolution: string; count: string },
|
||||
references: string[],
|
||||
wide = false,
|
||||
) {
|
||||
const images = [...new Set(references.map((url) => url.trim()).filter(Boolean))].slice(0, 12);
|
||||
return {
|
||||
model: settings.model,
|
||||
aspectRatio: wide ? "16:9" : settings.ratio,
|
||||
imageSize: settings.resolution,
|
||||
imageCount: wide ? 1 : canvasGenerationCount(settings.count),
|
||||
referenceImage: images[0],
|
||||
referenceImages: images.slice(1),
|
||||
};
|
||||
}
|
||||
export function canvasImageResultUrls(response: {
|
||||
success: boolean;
|
||||
data?: { images?: Array<{ originalUrl?: string; thumbnailUrl?: string }> };
|
||||
}) {
|
||||
return response.success
|
||||
? (response.data?.images || [])
|
||||
.map((image) => image.originalUrl || image.thumbnailUrl || "")
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
141
lib/video-canvas/image-operation.ts
Normal file
141
lib/video-canvas/image-operation.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const imageOperationSchema = z
|
||||
.object({
|
||||
operation: z.enum(["enhance", "extend", "remove-bg", "angle-edit"]),
|
||||
imageUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.refine((value) => /^https?:\/\//i.test(value), "请使用已上传的图片地址"),
|
||||
prompt: z.string().max(16000).optional(),
|
||||
scaleFactor: z.number().finite().min(1).max(8).optional(),
|
||||
xScale: z.number().finite().positive().max(4).optional(),
|
||||
yScale: z.number().finite().positive().max(4).optional(),
|
||||
horizontalAngle: z.number().finite().min(0).max(360).optional(),
|
||||
verticalAngle: z.number().finite().min(-30).max(90).optional(),
|
||||
zoom: z.number().finite().min(0).max(10).optional(),
|
||||
})
|
||||
.superRefine((input, ctx) => {
|
||||
if (
|
||||
input.operation === "angle-edit" &&
|
||||
[input.horizontalAngle, input.verticalAngle, input.zoom].some((value) => value === undefined)
|
||||
)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "角度参数不完整" });
|
||||
});
|
||||
export type ImageOperation = z.infer<typeof imageOperationSchema>;
|
||||
export class ImageOperationUncertainError extends Error {}
|
||||
export class CanvasTaskLeaseLostError extends Error {}
|
||||
type Handler = (
|
||||
request: Request,
|
||||
userId: string,
|
||||
onTaskCreated?: (taskId: string) => Promise<void>,
|
||||
) => Promise<Response>;
|
||||
export async function executeImageOperation(
|
||||
params: unknown,
|
||||
userId: string,
|
||||
handlers: {
|
||||
enhance: Handler;
|
||||
extend: Handler;
|
||||
removeBackground: Handler;
|
||||
angleEdit: Handler;
|
||||
pollEnhance: Handler;
|
||||
},
|
||||
options: {
|
||||
checkpoint?: (data: Record<string, unknown>) => Promise<void>;
|
||||
wait?: (ms: number) => Promise<void>;
|
||||
maxPolls?: number;
|
||||
resume?: { operation: "enhance"; taskId: string; sourceImageUrl: string } | null;
|
||||
assertLease?: () => Promise<void>;
|
||||
} = {},
|
||||
) {
|
||||
const input = imageOperationSchema.parse(params);
|
||||
const payload =
|
||||
input.operation === "extend"
|
||||
? {
|
||||
image_url: input.imageUrl,
|
||||
prompt: input.prompt || "图像拓展处理",
|
||||
includeBase64: false,
|
||||
xScale: input.xScale,
|
||||
yScale: input.yScale,
|
||||
}
|
||||
: input.operation === "angle-edit"
|
||||
? {
|
||||
image_url: input.imageUrl,
|
||||
horizontal_angle: input.horizontalAngle,
|
||||
vertical_angle: input.verticalAngle,
|
||||
zoom: input.zoom,
|
||||
}
|
||||
: { imageUrl: input.imageUrl, scaleFactor: input.scaleFactor };
|
||||
const handler = {
|
||||
enhance: handlers.enhance,
|
||||
extend: handlers.extend,
|
||||
"remove-bg": handlers.removeBackground,
|
||||
"angle-edit": handlers.angleEdit,
|
||||
}[input.operation];
|
||||
const checkpoint = async (taskId: string) => {
|
||||
await options.assertLease?.();
|
||||
await options.checkpoint?.({
|
||||
upstreamTask: { operation: "enhance", taskId, sourceImageUrl: input.imageUrl },
|
||||
});
|
||||
};
|
||||
await options.assertLease?.();
|
||||
if (
|
||||
options.resume &&
|
||||
(input.operation !== "enhance" ||
|
||||
options.resume.operation !== "enhance" ||
|
||||
!options.resume.taskId?.trim() ||
|
||||
options.resume.sourceImageUrl !== input.imageUrl)
|
||||
)
|
||||
throw Error("已保存的高清任务与原请求不匹配,已停止恢复");
|
||||
let response = options.resume
|
||||
? Response.json({ processing: true, taskId: options.resume.taskId }, { status: 202 })
|
||||
: await handler(
|
||||
new Request("http://canvas.internal/operation", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
userId,
|
||||
checkpoint,
|
||||
);
|
||||
let body = await response.json();
|
||||
if (response.status === 202 || body.processing) {
|
||||
if (input.operation !== "enhance" || !body.taskId)
|
||||
throw new ImageOperationUncertainError("图片任务已受理但缺少可查询的任务标识,请勿重复提交");
|
||||
const taskId = String(body.taskId);
|
||||
try {
|
||||
await checkpoint(taskId);
|
||||
} catch (error) {
|
||||
if (error instanceof CanvasTaskLeaseLostError) throw error;
|
||||
throw new ImageOperationUncertainError("高清任务已受理,但任务标识保存失败,请勿重复提交");
|
||||
}
|
||||
const query = new URLSearchParams({ taskId, sourceImageUrl: input.imageUrl });
|
||||
for (let i = 0; i < (options.maxPolls ?? 30); i++) {
|
||||
await (options.wait || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))))(1200);
|
||||
await options.assertLease?.();
|
||||
try {
|
||||
response = await handlers.pollEnhance(
|
||||
new Request(`http://canvas.internal/operation?${query}`),
|
||||
userId,
|
||||
);
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new ImageOperationUncertainError(
|
||||
"高清任务查询连接中断,已保留任务标识,请勿重复提交",
|
||||
);
|
||||
}
|
||||
if (!response.ok && body.terminal) throw Error(body.detail || body.error || "高清任务失败");
|
||||
if (!response.ok)
|
||||
throw new ImageOperationUncertainError(body.error || "高清任务暂时无法查询,请勿重复提交");
|
||||
if (response.status !== 202 && !body.processing) break;
|
||||
}
|
||||
if (response.status === 202 || body.processing)
|
||||
throw new ImageOperationUncertainError(
|
||||
"高清任务仍在上游处理中,已保留任务标识,请勿重复提交",
|
||||
);
|
||||
}
|
||||
const url = body.original_url || body.image_url;
|
||||
if (!response.ok || !body.success || typeof url !== "string" || !/^https?:\/\//i.test(url))
|
||||
throw Error(body.detail || body.error || "图片处理没有返回有效结果");
|
||||
return { images: [{ originalUrl: url, thumbnailUrl: body.thumbnail_url || url }] };
|
||||
}
|
||||
47
lib/video-canvas/media-contract.ts
Normal file
47
lib/video-canvas/media-contract.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const fraction = z.number().finite().min(0).max(1);
|
||||
export const videoTimeRangeSchema = z
|
||||
.object({ start: fraction, end: fraction })
|
||||
.refine((range) => range.end > range.start, "结束位置必须晚于开始位置");
|
||||
export type VideoTimeRange = z.infer<typeof videoTimeRangeSchema>;
|
||||
|
||||
export const videoProcessSchema = z.discriminatedUnion("operation", [
|
||||
z.object({
|
||||
operation: z.literal("crop"),
|
||||
url: z.string().url(),
|
||||
rect: z
|
||||
.object({
|
||||
x: fraction,
|
||||
y: fraction,
|
||||
width: fraction.positive(),
|
||||
height: fraction.positive(),
|
||||
})
|
||||
.refine(
|
||||
(rect) => rect.x + rect.width <= 1.001 && rect.y + rect.height <= 1.001,
|
||||
"裁剪区域超出画面",
|
||||
),
|
||||
}),
|
||||
z.object({ operation: z.literal("audio-video"), url: z.string().url() }),
|
||||
z.object({
|
||||
operation: z.literal("frame"),
|
||||
url: z.string().url(),
|
||||
kind: z.enum(["first", "last", "current"]),
|
||||
time: z.number().finite().min(0).default(0),
|
||||
}),
|
||||
z.object({
|
||||
operation: z.literal("slow"),
|
||||
url: z.string().url(),
|
||||
factor: z.union([z.literal(2), z.literal(3), z.literal(5)]),
|
||||
}),
|
||||
z.object({ operation: z.literal("trim"), url: z.string().url(), range: videoTimeRangeSchema }),
|
||||
]);
|
||||
|
||||
export function toVideoTimeRange(range: VideoTimeRange, duration: number) {
|
||||
if (!videoTimeRangeSchema.safeParse(range).success || !Number.isFinite(duration) || duration <= 0)
|
||||
throw new Error("剪辑范围无效");
|
||||
const start = range.start * duration;
|
||||
const end = range.end * duration;
|
||||
if (end - start < 0.1) throw new Error("剪辑时长不能少于 0.1 秒");
|
||||
return { start, end, duration: end - start };
|
||||
}
|
||||
@ -1,37 +1,213 @@
|
||||
import {execFile} from 'node:child_process';
|
||||
import {promisify} from 'node:util';
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { toVideoTimeRange, type VideoTimeRange } from "./media-contract";
|
||||
|
||||
export type MediaRect = {x:number;y:number;width:number;height:number};
|
||||
export function toVideoPixelRect(rect:MediaRect,width:number,height:number) {
|
||||
if (![rect.x,rect.y,rect.width,rect.height,width,height].every(Number.isFinite) || rect.x<0 || rect.y<0 || rect.width<=0 || rect.height<=0 || rect.x+rect.width>1.001 || rect.y+rect.height>1.001) throw new Error('裁剪区域无效');
|
||||
const even=(n:number)=>Math.floor(n/2)*2;
|
||||
const x=even(rect.x*width), y=even(rect.y*height);
|
||||
const w=Math.min(even(rect.width*width),even(width-x)), h=Math.min(even(rect.height*height),even(height-y));
|
||||
if(w<2||h<2) throw new Error('裁剪区域太小');
|
||||
return {x,y,width:w,height:h};
|
||||
export type MediaRect = { x: number; y: number; width: number; height: number };
|
||||
export function toVideoPixelRect(rect: MediaRect, width: number, height: number) {
|
||||
if (
|
||||
![rect.x, rect.y, rect.width, rect.height, width, height].every(Number.isFinite) ||
|
||||
rect.x < 0 ||
|
||||
rect.y < 0 ||
|
||||
rect.width <= 0 ||
|
||||
rect.height <= 0 ||
|
||||
rect.x + rect.width > 1.001 ||
|
||||
rect.y + rect.height > 1.001
|
||||
)
|
||||
throw new Error("裁剪区域无效");
|
||||
const even = (n: number) => Math.floor(n / 2) * 2;
|
||||
const x = even(rect.x * width),
|
||||
y = even(rect.y * height);
|
||||
const w = Math.min(even(rect.width * width), even(width - x)),
|
||||
h = Math.min(even(rect.height * height), even(height - y));
|
||||
if (w < 2 || h < 2) throw new Error("裁剪区域太小");
|
||||
return { x, y, width: w, height: h };
|
||||
}
|
||||
const run=promisify(execFile);
|
||||
const inputSafety=['-protocol_whitelist','file','-format_whitelist','mov,matroska,webm,avi,mpeg,mpegts,flv,ogg'];
|
||||
export async function processVideoFile(input:string,output:string,operation:'crop'|'extract-audio'|'silent-video',rect?:MediaRect) {
|
||||
const {stdout}=await run('ffprobe',['-v','error',...inputSafety,'-show_streams','-show_format','-of','json',input],{timeout:15000,maxBuffer:1024*1024});
|
||||
const info=JSON.parse(stdout), video=info.streams?.find((s:any)=>s.codec_type==='video');
|
||||
if(!video) throw new Error('素材中没有视频画面');
|
||||
if (!(Number(video.width)>0 && Number(video.height)>0) || Number(video.width)*Number(video.height)>33554432) throw new Error('视频尺寸不受支持,请使用 8K 以内的视频');
|
||||
const duration=Number(info.format?.duration || 0);
|
||||
if(!Number.isFinite(duration)||duration<=0||duration>600) throw new Error('本地视频处理支持 10 分钟以内的视频');
|
||||
let args:string[];
|
||||
let dimensions={width:Number(video.width),height:Number(video.height)};
|
||||
if(operation==='extract-audio') {
|
||||
if(!info.streams.some((s:any)=>s.codec_type==='audio')) throw new Error('原视频不包含音轨');
|
||||
args=['-map','0:a:0','-vn','-c:a','aac','-b:a','192k'];
|
||||
} else if(operation==='silent-video') {
|
||||
args=['-map','0:v:0','-an','-c:v','copy','-movflags','+faststart'];
|
||||
const run = promisify(execFile);
|
||||
const inputSafety = [
|
||||
"-protocol_whitelist",
|
||||
"file",
|
||||
"-format_whitelist",
|
||||
"mov,matroska,webm,avi,mpeg,mpegts,flv,ogg",
|
||||
];
|
||||
export async function processVideoFile(
|
||||
input: string,
|
||||
output: string,
|
||||
operation: "crop" | "extract-audio" | "silent-video" | "trim" | "frame" | "slow",
|
||||
rect?: MediaRect,
|
||||
range?: VideoTimeRange,
|
||||
options?: { kind?: "first" | "last" | "current"; time?: number; factor?: number },
|
||||
) {
|
||||
const { stdout } = await run(
|
||||
"ffprobe",
|
||||
["-v", "error", ...inputSafety, "-show_streams", "-show_format", "-of", "json", input],
|
||||
{ timeout: 15000, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
const info = JSON.parse(stdout),
|
||||
video = info.streams?.find((s: any) => s.codec_type === "video");
|
||||
if (!video) throw new Error("素材中没有视频画面");
|
||||
if (
|
||||
!(Number(video.width) > 0 && Number(video.height) > 0) ||
|
||||
Number(video.width) * Number(video.height) > 33554432
|
||||
)
|
||||
throw new Error("视频尺寸不受支持,请使用 8K 以内的视频");
|
||||
const duration = Number(video.duration || info.format?.duration || 0);
|
||||
if (!Number.isFinite(duration) || duration <= 0 || duration > 600)
|
||||
throw new Error("本地视频处理支持 10 分钟以内的视频");
|
||||
let args: string[];
|
||||
let dimensions = { width: Number(video.width), height: Number(video.height) };
|
||||
let outputDuration = duration;
|
||||
if (operation === "frame") {
|
||||
const [numerator, denominator] = String(video.avg_frame_rate || "30/1")
|
||||
.split("/")
|
||||
.map(Number);
|
||||
const fps = numerator / denominator;
|
||||
const last = Math.max(0, duration - 1 / (Number.isFinite(fps) && fps > 0 ? fps : 30));
|
||||
const time =
|
||||
options?.kind === "last"
|
||||
? last
|
||||
: options?.kind === "current"
|
||||
? Math.min(last, Math.max(0, Number(options.time) || 0))
|
||||
: 0;
|
||||
args = ["-ss", String(time), "-map", "0:v:0", "-frames:v", "1", "-an", "-c:v", "png"];
|
||||
outputDuration = 0;
|
||||
} else if (operation === "slow") {
|
||||
const factor = options?.factor;
|
||||
if (!factor || ![2, 3, 5].includes(factor)) throw Error("慢放倍数无效");
|
||||
outputDuration = duration * factor;
|
||||
if (outputDuration > 600) throw Error("慢放后视频不能超过 10 分钟");
|
||||
let speed = 1 / factor;
|
||||
const filters: string[] = [];
|
||||
while (speed < 0.5) {
|
||||
filters.push("atempo=0.5");
|
||||
speed /= 0.5;
|
||||
}
|
||||
filters.push(`atempo=${speed}`);
|
||||
args = [
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"0:a?",
|
||||
"-vf",
|
||||
`setpts=${factor}*(PTS-STARTPTS),scale=trunc(iw/2)*2:trunc(ih/2)*2`,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"20",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
];
|
||||
if (info.streams.some((stream: any) => stream.codec_type === "audio"))
|
||||
args.push("-af", filters.join(","), "-c:a", "aac");
|
||||
args.push("-movflags", "+faststart");
|
||||
dimensions = {
|
||||
width: Math.floor(dimensions.width / 2) * 2,
|
||||
height: Math.floor(dimensions.height / 2) * 2,
|
||||
};
|
||||
} else if (operation === "extract-audio") {
|
||||
if (!info.streams.some((s: any) => s.codec_type === "audio"))
|
||||
throw new Error("原视频不包含音轨");
|
||||
args = ["-map", "0:a:0", "-vn", "-c:a", "aac", "-b:a", "192k"];
|
||||
} else if (operation === "silent-video") {
|
||||
args = ["-map", "0:v:0", "-an", "-c:v", "copy", "-movflags", "+faststart"];
|
||||
} else if (operation === "trim") {
|
||||
if (!range) throw new Error("请选择剪辑范围");
|
||||
const selected = toVideoTimeRange(range, duration);
|
||||
outputDuration = selected.duration;
|
||||
dimensions = {
|
||||
width: Math.floor(dimensions.width / 2) * 2,
|
||||
height: Math.floor(dimensions.height / 2) * 2,
|
||||
};
|
||||
args = [
|
||||
"-ss",
|
||||
String(selected.start),
|
||||
"-t",
|
||||
String(selected.duration),
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"0:a?",
|
||||
"-vf",
|
||||
"scale=trunc(iw/2)*2:trunc(ih/2)*2",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"20",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
];
|
||||
} else {
|
||||
if(!rect) throw new Error('请选择裁剪区域');
|
||||
const crop=toVideoPixelRect(rect,dimensions.width,dimensions.height);
|
||||
dimensions=crop;
|
||||
args=['-map','0:v:0','-map','0:a?','-vf',`crop=${crop.width}:${crop.height}:${crop.x}:${crop.y}`,'-c:v','libx264','-preset','veryfast','-crf','20','-pix_fmt','yuv420p','-c:a','aac','-movflags','+faststart'];
|
||||
if (!rect) throw new Error("请选择裁剪区域");
|
||||
const crop = toVideoPixelRect(rect, dimensions.width, dimensions.height);
|
||||
dimensions = crop;
|
||||
args = [
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"0:a?",
|
||||
"-vf",
|
||||
`crop=${crop.width}:${crop.height}:${crop.x}:${crop.y}`,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"20",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
];
|
||||
}
|
||||
await run('ffmpeg',['-nostdin','-y','-v','error','-threads','2','-filter_threads','1',...inputSafety,'-i',input,...args,'-threads','2',output],{timeout:180000,maxBuffer:1024*1024});
|
||||
return {duration,...dimensions};
|
||||
await run(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-nostdin",
|
||||
"-y",
|
||||
"-v",
|
||||
"error",
|
||||
"-threads",
|
||||
"2",
|
||||
"-filter_threads",
|
||||
"1",
|
||||
...inputSafety,
|
||||
"-i",
|
||||
input,
|
||||
...args,
|
||||
"-threads",
|
||||
"2",
|
||||
output,
|
||||
],
|
||||
{ timeout: 180000, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
if (operation === "frame") {
|
||||
const probe = await run(
|
||||
"ffprobe",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=width,height",
|
||||
"-of",
|
||||
"json",
|
||||
output,
|
||||
],
|
||||
{ timeout: 15000, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
const frame = JSON.parse(probe.stdout).streams?.[0];
|
||||
if (!frame?.width || !frame?.height) throw Error("未截取到有效视频帧");
|
||||
dimensions = { width: Number(frame.width), height: Number(frame.height) };
|
||||
}
|
||||
return { duration: outputDuration, ...dimensions };
|
||||
}
|
||||
|
||||
111
lib/video-canvas/merge-snapshot.ts
Normal file
111
lib/video-canvas/merge-snapshot.ts
Normal file
@ -0,0 +1,111 @@
|
||||
export type CanvasConflictChoice = "local" | "remote";
|
||||
const absent = Symbol("absent");
|
||||
const record = (v: any) => v !== absent && v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
const same = (a: any, b: any): boolean => {
|
||||
if (a === b) return true;
|
||||
if (record(a) && record(b)) {
|
||||
const keys = Object.keys(a);
|
||||
return (
|
||||
keys.length === Object.keys(b).length &&
|
||||
keys.every((k) => Object.hasOwn(b, k) && same(a[k], b[k]))
|
||||
);
|
||||
}
|
||||
return (
|
||||
Array.isArray(a) &&
|
||||
Array.isArray(b) &&
|
||||
a.length === b.length &&
|
||||
a.every((v, i) => same(v, b[i]))
|
||||
);
|
||||
};
|
||||
/** Three-way merge. Never resolve edits to the same field implicitly. */
|
||||
export function mergeCanvasSnapshots(
|
||||
baseText: string,
|
||||
localText: string,
|
||||
remoteText: string,
|
||||
choice?: CanvasConflictChoice,
|
||||
) {
|
||||
const conflicts: string[] = [];
|
||||
function merge(base: any, local: any, remote: any, path: string): any {
|
||||
// View and selection belong to this tab, not shared document content.
|
||||
if (["canvasData.viewport", "canvasData.selectedNodeIds", "canvasData.tool"].includes(path))
|
||||
return local;
|
||||
if (same(local, remote) || same(base, remote)) return local;
|
||||
if (same(base, local)) return remote;
|
||||
if (record(base) && record(local) && record(remote)) {
|
||||
const output: Record<string, any> = {};
|
||||
for (const key of new Set([
|
||||
...Object.keys(base),
|
||||
...Object.keys(local),
|
||||
...Object.keys(remote),
|
||||
])) {
|
||||
if (["__proto__", "prototype", "constructor"].includes(key)) continue;
|
||||
const result = merge(
|
||||
Object.hasOwn(base, key) ? base[key] : absent,
|
||||
Object.hasOwn(local, key) ? local[key] : absent,
|
||||
Object.hasOwn(remote, key) ? remote[key] : absent,
|
||||
path ? `${path}.${key}` : key,
|
||||
);
|
||||
if (result !== absent) output[key] = result;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
const keyed = (v: any) =>
|
||||
Array.isArray(v) &&
|
||||
v.every((item) => record(item) && typeof item.id === "string") &&
|
||||
new Set(v.map((item) => item.id)).size === v.length;
|
||||
if (keyed(base) && keyed(local) && keyed(remote)) {
|
||||
const b = new Map(base.map((n: any) => [n.id, n])),
|
||||
l = new Map(local.map((n: any) => [n.id, n])),
|
||||
r = new Map(remote.map((n: any) => [n.id, n]));
|
||||
const order = (items: any[]) =>
|
||||
items.filter((n) => b.has(n.id) && l.has(n.id) && r.has(n.id)).map((n) => n.id);
|
||||
const bo = order(base),
|
||||
lo = order(local),
|
||||
ro = order(remote);
|
||||
let primary = local;
|
||||
if (same(lo, bo)) primary = remote;
|
||||
else if (!same(ro, bo) && !same(lo, ro)) {
|
||||
conflicts.push(`${path}.顺序`);
|
||||
primary = choice === "remote" ? remote : local;
|
||||
}
|
||||
const ids = new Set([
|
||||
...primary.map((n: any) => n.id),
|
||||
...remote.map((n: any) => n.id),
|
||||
...local.map((n: any) => n.id),
|
||||
...base.map((n: any) => n.id),
|
||||
]);
|
||||
return [...ids]
|
||||
.map((id) =>
|
||||
merge(
|
||||
b.has(id) ? b.get(id) : absent,
|
||||
l.has(id) ? l.get(id) : absent,
|
||||
r.has(id) ? r.get(id) : absent,
|
||||
`${path}[${id}]`,
|
||||
),
|
||||
)
|
||||
.filter((v) => v !== absent);
|
||||
}
|
||||
conflicts.push(path || "画布");
|
||||
return choice === "remote" ? remote : local;
|
||||
}
|
||||
const parse = (text: string) => {
|
||||
const value = JSON.parse(text);
|
||||
for (const node of value.canvasData?.nodes || []) {
|
||||
delete node.createdAt;
|
||||
delete node.updatedAt;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const local = parse(localText);
|
||||
const snapshot = merge(parse(baseText), local, parse(remoteText), "");
|
||||
if (snapshot.canvasData && local.canvasData)
|
||||
for (const key of ["viewport", "selectedNodeIds", "tool"])
|
||||
if (Object.hasOwn(local.canvasData, key)) snapshot.canvasData[key] = local.canvasData[key];
|
||||
if (Array.isArray(snapshot.canvasData?.nodes) && Array.isArray(snapshot.canvasData?.edges)) {
|
||||
const ids = new Set(snapshot.canvasData.nodes.map((n: any) => n.id));
|
||||
snapshot.canvasData.edges = snapshot.canvasData.edges.filter(
|
||||
(e: any) => ids.has(e.source) && ids.has(e.target),
|
||||
);
|
||||
}
|
||||
return { snapshot: JSON.stringify(snapshot), conflicts };
|
||||
}
|
||||
@ -56,6 +56,7 @@ export function getCanvasVideoModes(model: CanvasModelDescriptor): VideoGenerati
|
||||
// Only adapters actually handled by the canvas video endpoint are selectable here.
|
||||
export function isExecutableCanvasVideoModel(model: CanvasModelDescriptor) {
|
||||
if (!isVideoModelDescriptor(model)) return false;
|
||||
if (model.providerType === "qianwen-video" && model.modelId === "wan2.6-i2v-flash") return true;
|
||||
if (["volcano-video", "doubao-video", "atlascloud", "vip98k"].includes(model.providerType || "")) return true;
|
||||
const caps = record(model.capabilities);
|
||||
return model.providerType === "custom" && caps.kind === "video" &&
|
||||
@ -107,3 +108,16 @@ export function validateCanvasVideoInputs(mode: VideoGenerationMode, images: str
|
||||
if (mode === "first-last-frame" && images.length !== 2) throw new Error("首尾帧生视频需要恰好两张参考图片");
|
||||
if (mode === "reference-images" && images.length === 0) throw new Error("参考生视频需要至少一张参考图片");
|
||||
}
|
||||
|
||||
export function chooseStoryVideoSettings(models: CanvasModelDescriptor[], hasImage: boolean, seconds: number, preferredRef?: string) {
|
||||
const mode:VideoGenerationMode = hasImage ? "first-frame" : "text-to-video";
|
||||
const choices = filterCanvasModels(models,"video",mode);
|
||||
const model = choices.find(item=>item.id===preferredRef||item.modelId===preferredRef) || choices[0];
|
||||
if (!model) throw Error(hasImage ? "没有可用的首帧视频模型" : "镜头缺少首帧,且没有可用的文生视频模型");
|
||||
const options = getCanvasVideoParameters(model,mode);
|
||||
if (!options.durations.length || !options.ratios.length || !options.resolutions.length) throw Error("视频模型缺少已确认的时长、比例或清晰度规格,请先完善模型配置");
|
||||
const duration = options.durations.reduce((best,value)=>Math.abs(value-seconds)<Math.abs(best-seconds)?value:best,options.durations[0]);
|
||||
return {mode,model:model.id||model.modelId||"",duration,
|
||||
ratio:options.ratios.includes("16:9")?"16:9":options.ratios[0],
|
||||
resolution:options.resolutions.includes("720p")?"720p":options.resolutions[0],generateAudio:options.supportsAudio};
|
||||
}
|
||||
|
||||
32
lib/video-canvas/prompt-tools.ts
Normal file
32
lib/video-canvas/prompt-tools.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { z } from "zod";
|
||||
import { isCanvasLanguageModel, type CanvasModelDescriptor } from "./model-matching";
|
||||
|
||||
export const promptToolSchema = z.object({
|
||||
mode: z.enum(["optimize", "translate"]),
|
||||
prompt: z.string().trim().min(1, "请先输入提示词").max(12000, "提示词不能超过 12000 字"),
|
||||
model: z.string().trim().max(200).optional(),
|
||||
});
|
||||
|
||||
export type PromptToolMode = z.infer<typeof promptToolSchema>["mode"];
|
||||
export const promptToolFunctionKey = (mode: PromptToolMode) => `video-prompt-${mode}`;
|
||||
|
||||
export function selectPromptToolModel<T extends CanvasModelDescriptor>(models: T[], requested?: string) {
|
||||
const candidates = models.filter(model => model.isEnabled !== false && isCanvasLanguageModel(model));
|
||||
const ref = requested?.trim();
|
||||
const model = ref && ref !== "auto"
|
||||
? candidates.find(item => item.id === ref || item.modelId === ref)
|
||||
: candidates[0];
|
||||
if (!model) throw new Error(ref && ref !== "auto"
|
||||
? "所选提示词模型不可用或不是文字模型,请重新选择"
|
||||
: "当前没有可用的提示词处理模型");
|
||||
return model;
|
||||
}
|
||||
|
||||
export function promptToolErrorMessage(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (/overdue|good standing|insufficient.*balance|欠费/i.test(message)) {
|
||||
return "模型服务商账户余额或状态异常,请管理员检查。原提示词已保留。";
|
||||
}
|
||||
if (/abort|timeout|timed out/i.test(message)) return "提示词处理超时,请重试。原提示词已保留。";
|
||||
return "提示词模型调用失败,请稍后重试或选择其他文字模型。原提示词已保留。";
|
||||
}
|
||||
25
lib/video-canvas/reference-input.ts
Normal file
25
lib/video-canvas/reference-input.ts
Normal file
@ -0,0 +1,25 @@
|
||||
export type ReferenceAccess = "reachable" | "unreachable" | "unknown";
|
||||
|
||||
export async function validateVideoReferences(
|
||||
input: unknown,
|
||||
probe: (url: string) => Promise<ReferenceAccess>,
|
||||
): Promise<{ images: string[]; error?: string; status?: number }> {
|
||||
if (input === undefined) return { images: [] };
|
||||
if (!Array.isArray(input) || input.length > 8) return { images: [], error: "请提供不超过 8 张参考图", status: 400 };
|
||||
const images: string[] = [];
|
||||
for (const value of input) {
|
||||
try {
|
||||
if (typeof value !== "string") throw new Error();
|
||||
const parsed = new URL(value.trim());
|
||||
if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password) throw new Error();
|
||||
images.push(value.trim());
|
||||
} catch {
|
||||
return { images: [], error: "参考图地址无效,请重新上传", status: 400 };
|
||||
}
|
||||
}
|
||||
const checks = await Promise.all([...new Set(images)].map(url => probe(url).catch(() => "unknown" as const)));
|
||||
if (checks.includes("unreachable")) return { images: [], error: "参考图片链接已失效或无访问权限,请重新上传", status: 400 };
|
||||
if (checks.includes("unknown")) return { images: [], error: "参考图片连接暂时超时或服务繁忙,请重试;尚未提交生成任务", status: 503 };
|
||||
// First and last frames are positional; identical URLs must remain two entries.
|
||||
return { images };
|
||||
}
|
||||
156
lib/video-canvas/save-queue.ts
Normal file
156
lib/video-canvas/save-queue.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import type { CanvasConflictChoice } from "./merge-snapshot";
|
||||
export type VideoCanvasSaveStatus = "saved" | "pending" | "saving" | "error";
|
||||
|
||||
/** Keep edits made during a request; never retry a stale revision as an overwrite. */
|
||||
export class VideoCanvasSaveQueue {
|
||||
private latest: string;
|
||||
private saved: string;
|
||||
private running: Promise<void> | null = null;
|
||||
private blocked = false;
|
||||
private conflict: CanvasRevisionConflict | null = null;
|
||||
private status: VideoCanvasSaveStatus = "saved";
|
||||
|
||||
constructor(
|
||||
baseline: string,
|
||||
private revision: string,
|
||||
private readonly write: (snapshot: string, revision: string) => Promise<string>,
|
||||
private readonly changed: (status: VideoCanvasSaveStatus, message?: string) => void,
|
||||
private readonly reconciliation?: {
|
||||
merge: (
|
||||
base: string,
|
||||
local: string,
|
||||
remote: string,
|
||||
choice?: CanvasConflictChoice,
|
||||
) => { snapshot: string; conflicts: string[] };
|
||||
apply: (snapshot: string) => void;
|
||||
conflict: (fields: string[]) => void;
|
||||
},
|
||||
) {
|
||||
this.latest = baseline;
|
||||
this.saved = baseline;
|
||||
}
|
||||
|
||||
get backup() {
|
||||
return { base: this.saved, snapshot: this.latest, revision: this.revision };
|
||||
}
|
||||
restoreBackup(base: string, snapshot: string) {
|
||||
if (!this.reconciliation || snapshot === base) return;
|
||||
const remote = new CanvasRevisionConflict(
|
||||
"检测到未保存草稿与服务器有冲突,请选择保留哪一处修改",
|
||||
this.saved,
|
||||
this.revision,
|
||||
);
|
||||
const merged = this.reconciliation.merge(base, snapshot, this.saved);
|
||||
if (merged.conflicts.length) {
|
||||
this.saved = base;
|
||||
this.latest = snapshot;
|
||||
this.conflict = remote;
|
||||
this.blocked = true;
|
||||
this.reconciliation.apply(snapshot);
|
||||
this.reconciliation.conflict(merged.conflicts);
|
||||
this.setStatus("error", remote.message);
|
||||
} else {
|
||||
this.rebase(merged.snapshot, remote);
|
||||
this.setStatus(this.latest === this.saved ? "saved" : "pending");
|
||||
}
|
||||
}
|
||||
|
||||
get failed() {
|
||||
return this.status === "error";
|
||||
}
|
||||
|
||||
get dirty() {
|
||||
return this.latest !== this.saved || this.running !== null;
|
||||
}
|
||||
|
||||
update(snapshot: string) {
|
||||
this.latest = snapshot;
|
||||
if (!this.running && this.status !== "error") {
|
||||
this.setStatus(this.dirty ? "pending" : "saved");
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(status: VideoCanvasSaveStatus, message?: string) {
|
||||
this.status = status;
|
||||
this.changed(status, message);
|
||||
}
|
||||
|
||||
flush(): Promise<void> {
|
||||
if (this.running) return this.running;
|
||||
if (this.blocked || !this.dirty) return Promise.resolve();
|
||||
this.running = this.drain().finally(() => {
|
||||
this.running = null;
|
||||
});
|
||||
return this.running;
|
||||
}
|
||||
|
||||
private rebase(snapshot: string, conflict: CanvasRevisionConflict) {
|
||||
this.saved = conflict.remoteSnapshot!;
|
||||
this.revision = conflict.remoteRevision!;
|
||||
this.latest = snapshot;
|
||||
this.conflict = null;
|
||||
this.blocked = false;
|
||||
this.reconciliation?.conflict([]);
|
||||
this.reconciliation?.apply(snapshot);
|
||||
}
|
||||
async resolveConflict(choice: CanvasConflictChoice) {
|
||||
await this.running;
|
||||
if (!this.conflict?.remoteSnapshot || !this.reconciliation) return;
|
||||
const merged = this.reconciliation.merge(
|
||||
this.saved,
|
||||
this.latest,
|
||||
this.conflict.remoteSnapshot,
|
||||
choice,
|
||||
);
|
||||
this.rebase(merged.snapshot, this.conflict);
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
private async drain() {
|
||||
this.setStatus("saving");
|
||||
try {
|
||||
let merges = 0;
|
||||
while (this.latest !== this.saved) {
|
||||
const snapshot = this.latest;
|
||||
try {
|
||||
this.revision = await this.write(snapshot, this.revision);
|
||||
this.saved = snapshot;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof CanvasRevisionConflict &&
|
||||
error.remoteSnapshot &&
|
||||
error.remoteRevision &&
|
||||
this.reconciliation
|
||||
) {
|
||||
this.conflict = error;
|
||||
const merged = this.reconciliation.merge(this.saved, this.latest, error.remoteSnapshot);
|
||||
if (merged.conflicts.length) {
|
||||
this.reconciliation.conflict(merged.conflicts);
|
||||
throw error;
|
||||
}
|
||||
this.rebase(merged.snapshot, error);
|
||||
if (merges++ >= 3) {
|
||||
throw new Error("其他页面正在频繁保存,当前修改已保留,请稍后重试保存");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
this.setStatus("saved");
|
||||
} catch (error) {
|
||||
this.blocked = error instanceof CanvasRevisionConflict;
|
||||
this.setStatus("error", error instanceof Error ? error.message : "保存失败,请重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CanvasRevisionConflict extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public remoteSnapshot?: string,
|
||||
public remoteRevision?: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
258
lib/video-canvas/smart-edit/contract.ts
Normal file
258
lib/video-canvas/smart-edit/contract.ts
Normal file
@ -0,0 +1,258 @@
|
||||
import { z } from "zod";
|
||||
export const SECOND = 1_000_000;
|
||||
const time = z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(600 * SECOND);
|
||||
const id = z.string().min(1).max(180);
|
||||
const url = z
|
||||
.string()
|
||||
.url()
|
||||
.max(4096)
|
||||
.refine((value) => /^https?:\/\//i.test(value));
|
||||
export const subtitleSchema = z
|
||||
.object({ start: time, end: time, text: z.string().max(500) })
|
||||
.refine((x) => x.end > x.start);
|
||||
export const assetInputSchema = z.object({
|
||||
id,
|
||||
url,
|
||||
name: z.string().max(200),
|
||||
subtitles: z.array(subtitleSchema).max(500).default([]),
|
||||
});
|
||||
export const editAssetSchema = assetInputSchema.extend({
|
||||
duration: time,
|
||||
width: z.number().int().positive().max(8192),
|
||||
height: z.number().int().positive().max(8192),
|
||||
hasAudio: z.boolean(),
|
||||
hash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
});
|
||||
export const candidateSchema = z
|
||||
.object({ id, assetId: id, start: time, end: time, text: z.string().max(1500).default("") })
|
||||
.refine((x) => x.end - x.start >= 200000);
|
||||
export const editClipSchema = z
|
||||
.object({
|
||||
id,
|
||||
assetId: id,
|
||||
sourceIn: time,
|
||||
sourceOut: time,
|
||||
speed: z.number().min(0.5).max(2).default(1),
|
||||
gain: z.number().min(0).max(1).default(1),
|
||||
label: z.string().max(300).default(""),
|
||||
})
|
||||
.refine((x) => x.sourceOut - x.sourceIn >= 200000);
|
||||
export const editPlanSchema = z.object({
|
||||
version: z.literal(1),
|
||||
title: z.string().max(200),
|
||||
assets: z.array(editAssetSchema).min(1).max(6),
|
||||
clips: z.array(editClipSchema).min(1).max(12),
|
||||
ratio: z.enum(["16:9", "9:16", "1:1"]),
|
||||
resolution: z.enum(["720p", "1080p"]).default("720p"),
|
||||
transition: z.number().min(0).max(0.5).default(0),
|
||||
subtitles: z.boolean().default(true),
|
||||
music: z
|
||||
.object({ url, gain: z.number().min(0).max(1).default(0.2), start: time.default(0) })
|
||||
.optional(),
|
||||
warnings: z.array(z.string().max(500)).max(30).default([]),
|
||||
});
|
||||
export type EditPlan = z.infer<typeof editPlanSchema>;
|
||||
export type EditAsset = z.infer<typeof editAssetSchema>;
|
||||
export type EditCandidate = z.infer<typeof candidateSchema>;
|
||||
export const smartEditRequestSchema = z.discriminatedUnion("operation", [
|
||||
z.object({
|
||||
operation: z.literal("plan"),
|
||||
assets: z.array(assetInputSchema).min(1).max(6),
|
||||
mode: z.enum(["montage", "talk", "explain", "ads"]).default("montage"),
|
||||
prompt: z.string().max(3000).default(""),
|
||||
targetDuration: z.number().int().min(3).max(90).default(20),
|
||||
ratio: z.enum(["16:9", "9:16", "1:1"]).default("16:9"),
|
||||
resolution: z.enum(["720p", "1080p"]).default("720p"),
|
||||
removeSilence: z.boolean().default(false),
|
||||
transcribe: z.boolean().default(false),
|
||||
semantic: z.boolean().default(true),
|
||||
preferredModelRef: z.string().max(180).optional(),
|
||||
music: z
|
||||
.object({ url, gain: z.number().min(0).max(1).default(0.2), start: time.default(0) })
|
||||
.optional(),
|
||||
beatSync: z.boolean().default(false),
|
||||
beatsPerCut: z.enum(["2", "4"]).default("4"),
|
||||
variants: z.number().int().min(1).max(3).default(1),
|
||||
}),
|
||||
z.object({
|
||||
operation: z.literal("render"),
|
||||
plan: editPlanSchema,
|
||||
sourceNodeId: z.string().max(180).optional(),
|
||||
}),
|
||||
]);
|
||||
export type SmartEditRequest = z.infer<typeof smartEditRequestSchema>;
|
||||
export function planTimeline(input: EditPlan) {
|
||||
const plan = editPlanSchema.parse(input),
|
||||
ids = new Set<string>();
|
||||
let cursor = 0;
|
||||
const clips = plan.clips.map((clip, index) => {
|
||||
if (ids.has(clip.id)) throw Error("片段 ID 重复");
|
||||
ids.add(clip.id);
|
||||
const asset = plan.assets.find((a) => a.id === clip.assetId);
|
||||
if (!asset || clip.sourceOut > asset.duration) throw Error("片段超出原始素材范围");
|
||||
const duration = Math.round(
|
||||
(Math.round(((clip.sourceOut - clip.sourceIn) / clip.speed / SECOND) * 30) * SECOND) / 30,
|
||||
);
|
||||
const previous = index ? plan.clips[index - 1] : null;
|
||||
const overlap = previous
|
||||
? Math.min(
|
||||
Math.round(plan.transition * SECOND),
|
||||
Math.floor(duration / 3),
|
||||
Math.floor((previous.sourceOut - previous.sourceIn) / previous.speed / 3),
|
||||
)
|
||||
: 0;
|
||||
cursor -= overlap;
|
||||
const start = cursor;
|
||||
cursor += duration;
|
||||
return { ...clip, start, end: cursor, duration, overlap };
|
||||
});
|
||||
if (new Set(plan.assets.map((a) => a.id)).size !== plan.assets.length)
|
||||
throw Error("素材 ID 重复");
|
||||
if (cursor > 90 * SECOND || cursor < 200000) throw Error("成片时长需在 0.2–90 秒内");
|
||||
return { clips, duration: cursor };
|
||||
}
|
||||
export function compiledSubtitles(plan: EditPlan) {
|
||||
if (!plan.subtitles) return [];
|
||||
return planTimeline(plan).clips.flatMap((clip) =>
|
||||
plan.assets
|
||||
.find((a) => a.id === clip.assetId)!
|
||||
.subtitles.flatMap((s) => {
|
||||
const a = Math.max(s.start, clip.sourceIn),
|
||||
b = Math.min(s.end, clip.sourceOut);
|
||||
if (b <= a) return [];
|
||||
// A crossfade's incoming subtitle starts when the outgoing subtitle ends.
|
||||
const start = Math.max(
|
||||
clip.start + clip.overlap,
|
||||
clip.start + Math.round((a - clip.sourceIn) / clip.speed),
|
||||
);
|
||||
const end = Math.min(clip.end, clip.start + Math.round((b - clip.sourceIn) / clip.speed));
|
||||
return end > start ? [{ start, end, text: s.text }] : [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
export function subtitlesToSrt(subtitles: z.infer<typeof subtitleSchema>[]) {
|
||||
const stamp = (v: number) => {
|
||||
const ms = Math.round(v / 1000);
|
||||
return `${String(Math.floor(ms / 3600000)).padStart(2, "0")}:${String(Math.floor(ms / 60000) % 60).padStart(2, "0")}:${String(Math.floor(ms / 1000) % 60).padStart(2, "0")},${String(ms % 1000).padStart(3, "0")}`;
|
||||
};
|
||||
return subtitles
|
||||
.map(
|
||||
(s, i) =>
|
||||
`${i + 1}\n${stamp(s.start)} --> ${stamp(s.end)}\n${s.text.replace(/\r/g, "").replace(/\n\s*\n/g, "\n")}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
}
|
||||
export function parseSrt(text: string) {
|
||||
if (text.length > 200000) throw Error("字幕文件过大");
|
||||
const stamp = (v: string) => {
|
||||
const [h, m, s] = v.replace(",", ".").split(":").map(Number);
|
||||
return Math.round((h * 3600 + m * 60 + s) * SECOND);
|
||||
};
|
||||
const rows = text
|
||||
.replace(/^\uFEFF/, "")
|
||||
.replace(/\r/g, "")
|
||||
.trim()
|
||||
.split(/\n\s*\n/)
|
||||
.filter(Boolean)
|
||||
.map((block) => {
|
||||
const lines = block.split("\n"),
|
||||
i = lines.findIndex((l) => l.includes("-->"));
|
||||
const match = lines[i]?.match(
|
||||
/(\d{2}:\d{2}:\d{2}[,.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,.]\d{3})/,
|
||||
);
|
||||
if (!match) throw Error("SRT 时间格式无效");
|
||||
return subtitleSchema.parse({
|
||||
start: stamp(match[1]),
|
||||
end: stamp(match[2]),
|
||||
text: lines.slice(i + 1).join("\n"),
|
||||
});
|
||||
});
|
||||
if (rows.length > 500) throw Error("字幕最多 500 条");
|
||||
return rows;
|
||||
}
|
||||
export function buildEditPlans(
|
||||
params: Extract<SmartEditRequest, { operation: "plan" }>,
|
||||
assets: EditAsset[],
|
||||
candidates: EditCandidate[],
|
||||
order: string[],
|
||||
beats: number[],
|
||||
warnings: string[],
|
||||
): EditPlan[] {
|
||||
if (!candidates.length) throw Error("没有可剪辑片段,请关闭去静音或更换素材");
|
||||
const valid = candidates.map((c) => candidateSchema.parse(c));
|
||||
const seen = new Set<string>();
|
||||
const ordered = order.map((id) => {
|
||||
const c = valid.find((c) => c.id === id);
|
||||
if (!c || seen.has(id)) throw Error("选片模型返回了无效或重复片段 ID");
|
||||
seen.add(id);
|
||||
return c;
|
||||
});
|
||||
if (!ordered.length) throw Error("没有选中可用片段");
|
||||
return Array.from({ length: params.mode === "ads" ? params.variants : 1 }, (_, variant) => {
|
||||
const list = variant
|
||||
? [...ordered.slice(variant % ordered.length), ...ordered.slice(0, variant % ordered.length)]
|
||||
: ordered;
|
||||
const clips: EditPlan["clips"] = [];
|
||||
let duration = 0;
|
||||
for (const candidate of list) {
|
||||
if (duration >= params.targetDuration * SECOND || clips.length >= 12) break;
|
||||
const available = candidate.end - candidate.start,
|
||||
remaining = params.targetDuration * SECOND - duration;
|
||||
const speech = params.mode === "talk" || params.mode === "explain";
|
||||
// Speech selections keep their sentence boundary even if it slightly misses requested length.
|
||||
let length = speech ? available : Math.min(available, remaining, 5 * SECOND);
|
||||
if (speech && duration + length > 90 * SECOND) continue;
|
||||
if (params.beatSync && !speech && beats.length) {
|
||||
const stride = Number(params.beatsPerCut);
|
||||
const desired = Math.min(
|
||||
5 * SECOND,
|
||||
available,
|
||||
remaining / Math.max(1, Math.min(12 - clips.length, list.length - clips.length)),
|
||||
);
|
||||
const next = beats
|
||||
.filter((_, i) => i % stride === 0)
|
||||
.map((t) => t - params.music!.start)
|
||||
.filter(
|
||||
(t) => t - duration >= 0.5 * SECOND && t - duration <= Math.min(available, remaining),
|
||||
)
|
||||
.sort((a, b) => Math.abs(a - duration - desired) - Math.abs(b - duration - desired))[0];
|
||||
if (next !== undefined) length = next - duration;
|
||||
}
|
||||
if (length < 200000) continue;
|
||||
clips.push({
|
||||
id: `clip-${variant}-${clips.length}`,
|
||||
assetId: candidate.assetId,
|
||||
sourceIn: candidate.start,
|
||||
sourceOut: candidate.start + length,
|
||||
speed: 1,
|
||||
gain: 1,
|
||||
label: candidate.text.slice(0, 300) || `镜头 ${clips.length + 1}`,
|
||||
});
|
||||
duration += length;
|
||||
}
|
||||
const plan: EditPlan = {
|
||||
version: 1,
|
||||
title: `${{ montage: "素材混剪", talk: "口播视频", explain: "讲解视频", ads: "批量广告" }[params.mode]}${variant ? ` ${variant + 1}` : ""}`,
|
||||
assets,
|
||||
clips,
|
||||
ratio: params.ratio,
|
||||
resolution: params.resolution,
|
||||
transition: 0,
|
||||
subtitles: true,
|
||||
music: params.music,
|
||||
warnings: [...warnings],
|
||||
};
|
||||
const actual = planTimeline(plan).duration / SECOND;
|
||||
if (Math.abs(actual - params.targetDuration) > 0.5)
|
||||
plan.warnings.push(
|
||||
`目标 ${params.targetDuration} 秒,实际 ${actual.toFixed(2)} 秒;保留完整语句或受素材长度限制。`,
|
||||
);
|
||||
if (params.mode === "ads" && params.variants > ordered.length)
|
||||
plan.warnings.push("候选镜头不足,部分广告版本可能相同。");
|
||||
return plan;
|
||||
});
|
||||
}
|
||||
298
lib/video-canvas/smart-edit/execute.ts
Normal file
298
lib/video-canvas/smart-edit/execute.ts
Normal file
@ -0,0 +1,298 @@
|
||||
import "server-only";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
SECOND,
|
||||
smartEditRequestSchema,
|
||||
buildEditPlans,
|
||||
planTimeline,
|
||||
editPlanSchema,
|
||||
type EditAsset,
|
||||
type EditCandidate,
|
||||
type EditPlan,
|
||||
} from "./contract";
|
||||
import {
|
||||
materialize,
|
||||
probeMedia,
|
||||
prepareAnalysis,
|
||||
pythonAnalysis,
|
||||
prepareAudio,
|
||||
ffmpeg,
|
||||
pruneSmartEditCache,
|
||||
} from "./local";
|
||||
import { selectEditCandidates } from "./semantic";
|
||||
import { renderJob } from "./render-job";
|
||||
import { remoteRender, remoteRenderConfig, type RemoteRenderCheckpoint } from "./remote-render";
|
||||
import { ProviderTaskUncertainError } from "../video-task-lifecycle";
|
||||
export type SmartEditCheckpoint = {
|
||||
operation: "smart-edit";
|
||||
action: "plan" | "render";
|
||||
phase: "local" | "analyzed" | "selecting" | "selected" | "remote-render";
|
||||
remote?: RemoteRenderCheckpoint;
|
||||
stage: string;
|
||||
assets?: EditAsset[];
|
||||
candidates?: EditCandidate[];
|
||||
beats?: number[];
|
||||
warnings?: string[];
|
||||
plans?: EditPlan[];
|
||||
};
|
||||
export async function executeSmartEdit(
|
||||
value: unknown,
|
||||
userId: string,
|
||||
lifecycle: {
|
||||
taskId: string;
|
||||
resume?: SmartEditCheckpoint | null;
|
||||
assertActive: () => Promise<void>;
|
||||
checkpoint: (result: Record<string, unknown>) => Promise<void>;
|
||||
},
|
||||
) {
|
||||
const params = smartEditRequestSchema.parse(value);
|
||||
let state: SmartEditCheckpoint = lifecycle.resume || {
|
||||
operation: "smart-edit",
|
||||
action: params.operation,
|
||||
phase: "local",
|
||||
stage: "等待处理",
|
||||
};
|
||||
const save = async (patch: Partial<SmartEditCheckpoint>) => {
|
||||
await lifecycle.assertActive();
|
||||
state = { ...state, ...patch };
|
||||
await lifecycle.checkpoint({ smartEdit: state });
|
||||
};
|
||||
await save({});
|
||||
const upload = async (file: string, suffix: string) => {
|
||||
await lifecycle.assertActive();
|
||||
const bytes = await readFile(file);
|
||||
return (await uploadFile(`smart-edit-${randomUUID()}.${suffix}`, Uint8Array.from(bytes).buffer))
|
||||
.url;
|
||||
};
|
||||
if (params.operation === "render" && (state.remote || remoteRenderConfig())) {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "remote-render-"));
|
||||
try {
|
||||
const rendered = await remoteRender(params.plan, userId, lifecycle.taskId, dir, {
|
||||
resume: state.remote,
|
||||
active: lifecycle.assertActive,
|
||||
save: (remote, stage) => save({ phase: "remote-render", remote, stage }),
|
||||
});
|
||||
const actual = await probeMedia(rendered.file);
|
||||
if (
|
||||
Math.abs(actual.duration / SECOND - planTimeline(params.plan).duration / SECOND) > 0.15 ||
|
||||
actual.width !== rendered.width ||
|
||||
actual.height !== rendered.height
|
||||
)
|
||||
throw Error("远程成片与剪辑方案不一致");
|
||||
await save({ stage: "转存成片" });
|
||||
const url = await upload(rendered.file, "mp4"),
|
||||
subtitleUrl = await upload(rendered.srt, "srt");
|
||||
return {
|
||||
url,
|
||||
subtitleUrl,
|
||||
plan: params.plan,
|
||||
metadata: {
|
||||
width: actual.width,
|
||||
height: actual.height,
|
||||
durationSeconds: actual.duration / SECOND,
|
||||
storageStatus: "archived",
|
||||
renderBackend: "remote",
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
return db.transaction(async (tx) => {
|
||||
const rows = await tx.execute(sql`select pg_try_advisory_xact_lock(91827, 1) as acquired`);
|
||||
if (!(rows as any)[0]?.acquired)
|
||||
throw new ProviderTaskUncertainError("剪辑处理器繁忙,任务已保存并等待继续");
|
||||
await pruneSmartEditCache();
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "smart-edit-"));
|
||||
try {
|
||||
if (params.operation === "plan") {
|
||||
if (state.phase === "selected" && state.plans)
|
||||
return { plans: state.plans, summary: "剪辑方案已生成" };
|
||||
if (state.phase === "selecting")
|
||||
throw new ProviderTaskUncertainError(
|
||||
"智能选片请求状态未确认,禁止自动重发;请核对模型记录",
|
||||
);
|
||||
let assets = state.assets || [],
|
||||
candidates = state.candidates || [],
|
||||
beats = state.beats || [],
|
||||
warnings = state.warnings || [];
|
||||
if (state.phase === "local") {
|
||||
assets = [];
|
||||
candidates = [];
|
||||
warnings = [];
|
||||
beats = [];
|
||||
let total = 0;
|
||||
for (const input of params.assets) {
|
||||
await save({ stage: `解析素材 ${assets.length + 1}/${params.assets.length}` });
|
||||
const media = await materialize(input.url, userId),
|
||||
meta = await probeMedia(media.file);
|
||||
if (!meta.width || !meta.height) throw Error(`${input.name} 不包含视频画面`);
|
||||
total += meta.duration;
|
||||
if (total > 600 * SECOND) throw Error("素材总时长不能超过 10 分钟");
|
||||
const prepared = await prepareAnalysis(
|
||||
media.file,
|
||||
media.hash,
|
||||
media.dir,
|
||||
meta.hasAudio,
|
||||
);
|
||||
const analysis = await pythonAnalysis(
|
||||
{
|
||||
version: 1,
|
||||
video: prepared.proxy,
|
||||
audio: prepared.audio,
|
||||
duration: meta.duration,
|
||||
transcribe: params.transcribe && !input.subtitles.length,
|
||||
},
|
||||
media.dir,
|
||||
);
|
||||
const subtitles = input.subtitles.length ? input.subtitles : analysis.subtitles;
|
||||
if (subtitles.some((s) => s.end > meta.duration + 100000))
|
||||
throw Error(`${input.name} 的字幕时间超出视频长度`);
|
||||
const asset: EditAsset = { ...input, ...meta, hash: media.hash, subtitles };
|
||||
assets.push(asset);
|
||||
warnings.push(...analysis.warnings.map((w) => `${input.name}:${w}`));
|
||||
const speech = params.mode === "talk" || params.mode === "explain";
|
||||
if (speech && !subtitles.length)
|
||||
warnings.push(
|
||||
`${input.name} 没有语句时间戳,使用画面镜头选片。可导入 SRT 或开启自动转写。`,
|
||||
);
|
||||
const regions =
|
||||
speech && subtitles.length
|
||||
? subtitles.map((s) => ({ start: s.start, end: s.end, text: s.text }))
|
||||
: analysis.scenes.map(([start, end]) => ({
|
||||
start,
|
||||
end: Math.min(end, meta.duration),
|
||||
text: "",
|
||||
}));
|
||||
for (const region of regions) {
|
||||
const ranges =
|
||||
params.removeSilence && meta.hasAudio && !(speech && subtitles.length)
|
||||
? analysis.active.map(([a, b]) => ({
|
||||
start: Math.max(region.start, a),
|
||||
end: Math.min(region.end, b),
|
||||
text: region.text,
|
||||
}))
|
||||
: [region];
|
||||
for (const range of ranges) {
|
||||
// Keep full speech sentences; split long visual scenes into bounded candidates.
|
||||
for (let start = range.start; start < range.end; ) {
|
||||
const end =
|
||||
speech && subtitles.length
|
||||
? range.end
|
||||
: Math.min(range.end, start + 5 * SECOND);
|
||||
if (end - start >= 200000)
|
||||
candidates.push({
|
||||
id: `candidate-${candidates.length}`,
|
||||
assetId: asset.id,
|
||||
start,
|
||||
end,
|
||||
text: range.text,
|
||||
});
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bound model context while sampling every source fairly.
|
||||
const fair: EditCandidate[] = [];
|
||||
const grouped = assets.map((a) => candidates.filter((c) => c.assetId === a.id));
|
||||
for (let i = 0; fair.length < 24 && grouped.some((g) => i < g.length); i++)
|
||||
for (const group of grouped) if (group[i] && fair.length < 24) fair.push(group[i]);
|
||||
if (fair.length < candidates.length)
|
||||
warnings.push(`共识别 ${candidates.length} 个候选,本轮按素材轮询选取 24 个进入选片。`);
|
||||
candidates = fair;
|
||||
if (params.beatSync && params.music) {
|
||||
await save({ stage: "检测音乐节拍" });
|
||||
const music = await materialize(params.music.url, userId),
|
||||
meta = await probeMedia(music.file);
|
||||
if (!meta.hasAudio) throw Error("背景音乐没有音轨");
|
||||
if (params.music.start >= meta.duration) throw Error("音乐开始时间超出文件");
|
||||
const audio = await prepareAudio(music.file, path.join(music.dir, music.hash + ".wav"));
|
||||
const analysis = await pythonAnalysis({ version: 1, audio, beats: true }, music.dir);
|
||||
beats = analysis.beats;
|
||||
warnings.push(...analysis.warnings);
|
||||
} else if (params.beatSync) warnings.push("未选择音乐,跳过音乐卡点。");
|
||||
await save({
|
||||
phase: "analyzed",
|
||||
stage: "素材解析完成",
|
||||
assets,
|
||||
candidates,
|
||||
beats,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
if (!candidates.length) throw Error("没有可用片段,请关闭去静音或更换素材");
|
||||
let order = candidates.map((c) => c.id);
|
||||
if (params.semantic) {
|
||||
const images: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
await lifecycle.assertActive();
|
||||
const asset = assets.find((a) => a.id === candidate.assetId)!;
|
||||
const media = await materialize(asset.url, userId);
|
||||
if (media.hash !== asset.hash) throw Error("原素材内容已变化,请重新解析");
|
||||
await prepareAnalysis(media.file, media.hash, media.dir, false);
|
||||
const frame = path.join(dir, `${images.length}.jpg`);
|
||||
await ffmpeg([
|
||||
"-ss",
|
||||
String((candidate.start + candidate.end) / 2 / SECOND),
|
||||
"-i",
|
||||
path.join(media.dir, media.hash + ".proxy.mp4"),
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-vf",
|
||||
"scale=320:180:force_original_aspect_ratio=decrease",
|
||||
frame,
|
||||
]);
|
||||
images.push("data:image/jpeg;base64," + (await readFile(frame)).toString("base64"));
|
||||
}
|
||||
await save({ phase: "selecting", stage: "千问智能选片" });
|
||||
try {
|
||||
order = (
|
||||
await selectEditCandidates(
|
||||
candidates,
|
||||
images,
|
||||
params.prompt,
|
||||
params.preferredModelRef,
|
||||
)
|
||||
).order;
|
||||
} catch (error) {
|
||||
throw new ProviderTaskUncertainError(
|
||||
`智能选片未完成,系统不会自动重复调用:${error instanceof Error ? error.message : "请求中断"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const plans = buildEditPlans(params, assets, candidates, order, beats, warnings);
|
||||
await save({ phase: "selected", stage: "剪辑方案已生成", plans });
|
||||
return { plans, summary: "剪辑方案已生成" };
|
||||
}
|
||||
const plan = editPlanSchema.parse(params.plan);
|
||||
planTimeline(plan);
|
||||
const rendered = await renderJob(plan, userId, dir, (stage) => save({ stage }));
|
||||
const actual = await probeMedia(rendered.file);
|
||||
if (Math.abs(actual.duration / SECOND - rendered.duration) > 0.15)
|
||||
throw Error("导出时长与剪辑方案不一致,未发布结果");
|
||||
await save({ stage: "转存成片" });
|
||||
const url = await upload(rendered.file, "mp4"),
|
||||
subtitleUrl = await upload(rendered.srt, "srt");
|
||||
return {
|
||||
url,
|
||||
subtitleUrl,
|
||||
plan,
|
||||
metadata: {
|
||||
width: rendered.width,
|
||||
height: rendered.height,
|
||||
durationSeconds: actual.duration / SECOND,
|
||||
storageStatus: "archived",
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
196
lib/video-canvas/smart-edit/local.ts
Normal file
196
lib/video-canvas/smart-edit/local.ts
Normal file
@ -0,0 +1,196 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { readFile, writeFile, mkdir, stat, rename, readdir, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { downloadCanvasMedia } from "../download-media";
|
||||
import { SECOND, subtitleSchema, type EditAsset } from "./contract";
|
||||
const exec = promisify(execFile);
|
||||
export const mediaSafety = [
|
||||
"-protocol_whitelist",
|
||||
"file",
|
||||
"-format_whitelist",
|
||||
"mov,matroska,webm,avi,mpeg,mpegts,flv,ogg,wav,mp3,flac,aac",
|
||||
];
|
||||
export async function ffmpeg(args: string[], cwd?: string) {
|
||||
await exec(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-nostdin",
|
||||
"-v",
|
||||
"error",
|
||||
"-y",
|
||||
"-threads",
|
||||
"2",
|
||||
"-filter_threads",
|
||||
"1",
|
||||
"-filter_complex_threads",
|
||||
"1",
|
||||
...args,
|
||||
],
|
||||
{ cwd, timeout: 240000, maxBuffer: 2 * 1024 * 1024 },
|
||||
);
|
||||
}
|
||||
export async function probeMedia(file: string) {
|
||||
const { stdout } = await exec(
|
||||
"ffprobe",
|
||||
["-v", "error", ...mediaSafety, "-show_streams", "-show_format", "-of", "json", file],
|
||||
{ timeout: 15000, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
const info = JSON.parse(stdout),
|
||||
v = info.streams?.find((s: any) => s.codec_type === "video");
|
||||
const duration = Math.round(Number(info.format?.duration || v?.duration) * SECOND);
|
||||
if (!Number.isFinite(duration) || duration <= 0 || duration > 180 * SECOND)
|
||||
throw Error("单个素材需在 0–180 秒以内");
|
||||
if (v && (!v.width || !v.height || v.width * v.height > 33554432))
|
||||
throw Error("素材画面尺寸超过处理上限");
|
||||
return {
|
||||
duration,
|
||||
width: Number(v?.width || 0),
|
||||
height: Number(v?.height || 0),
|
||||
hasAudio: info.streams?.some((s: any) => s.codec_type === "audio") === true,
|
||||
};
|
||||
}
|
||||
export function cacheRoot() {
|
||||
return path.resolve(process.env.SMART_EDIT_CACHE_DIR || ".cache/smart-edit");
|
||||
}
|
||||
export async function materialize(url: string, userId: string) {
|
||||
const root = cacheRoot();
|
||||
await mkdir(root, { recursive: true, mode: 0o700 });
|
||||
const user = createHash("sha256").update(userId).digest("hex").slice(0, 20);
|
||||
const dir = path.join(root, user);
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
const key = createHash("sha256").update(url).digest("hex");
|
||||
const mapFile = path.join(dir, key + ".json");
|
||||
try {
|
||||
const map = JSON.parse(await readFile(mapFile, "utf8"));
|
||||
if (/^[a-f0-9]{64}$/.test(map.hash) && Date.now() - map.created < 86400000) {
|
||||
const file = path.join(dir, map.hash);
|
||||
await stat(file);
|
||||
return { file, hash: map.hash as string, dir };
|
||||
}
|
||||
} catch {}
|
||||
const temp = path.join(dir, randomUUID() + ".part");
|
||||
try {
|
||||
await downloadCanvasMedia(url, temp);
|
||||
const bytes = await readFile(temp);
|
||||
const hash = createHash("sha256").update(bytes).digest("hex"),
|
||||
file = path.join(dir, hash);
|
||||
await rename(temp, file);
|
||||
await writeFile(mapFile, JSON.stringify({ hash, created: Date.now() }), { mode: 0o600 });
|
||||
return { file, hash, dir };
|
||||
} finally {
|
||||
await rm(temp, { force: true });
|
||||
}
|
||||
}
|
||||
export async function pruneSmartEditCache() {
|
||||
const root = cacheRoot();
|
||||
await mkdir(root, { recursive: true, mode: 0o700 });
|
||||
const files: { file: string; size: number; mtime: number }[] = [];
|
||||
for (const name of await readdir(root)) {
|
||||
if (!/^[a-f0-9]{20}$/.test(name)) continue;
|
||||
const dir = path.join(root, name);
|
||||
for (const item of await readdir(dir)) {
|
||||
const file = path.join(dir, item),
|
||||
s = await stat(file);
|
||||
if (s.isFile()) files.push({ file, size: s.size, mtime: s.mtimeMs });
|
||||
}
|
||||
}
|
||||
let total = files.reduce((sum, f) => sum + f.size, 0);
|
||||
for (const f of files.sort((a, b) => a.mtime - b.mtime))
|
||||
if (Date.now() - f.mtime > 7 * 86400000 || total > 4 * 1024 ** 3) {
|
||||
await rm(f.file, { force: true });
|
||||
total -= f.size;
|
||||
}
|
||||
}
|
||||
export type LocalAnalysis = {
|
||||
scenes: number[][];
|
||||
active: number[][];
|
||||
subtitles: EditAsset["subtitles"];
|
||||
beats: number[];
|
||||
warnings: string[];
|
||||
};
|
||||
export async function pythonAnalysis(
|
||||
data: Record<string, unknown>,
|
||||
dir: string,
|
||||
): Promise<LocalAnalysis> {
|
||||
const key = createHash("sha256").update(JSON.stringify(data)).digest("hex"),
|
||||
input = path.join(dir, key + ".analysis-input.json"),
|
||||
output = path.join(dir, key + ".analysis.json");
|
||||
try {
|
||||
const value = JSON.parse(await readFile(output, "utf8"));
|
||||
if (!value.error && Array.isArray(value.scenes)) return value;
|
||||
} catch {}
|
||||
await writeFile(input, JSON.stringify(data), { mode: 0o600 });
|
||||
try {
|
||||
await exec(
|
||||
process.env.SMART_EDIT_PYTHON || "python3",
|
||||
[path.resolve("services/smart-edit/analyze.py"), input, output],
|
||||
{
|
||||
timeout: 300000,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
env: { ...process.env, OMP_NUM_THREADS: "2", OPENBLAS_NUM_THREADS: "2" },
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
const value = JSON.parse(
|
||||
await readFile(output, "utf8").catch(
|
||||
() => '{"error":"分析运行环境不可用,请安装 services/smart-edit 依赖"}',
|
||||
),
|
||||
);
|
||||
throw Error(value.error || "素材分析失败或超时");
|
||||
}
|
||||
const value = JSON.parse(await readFile(output, "utf8"));
|
||||
if (value.error) throw Error(value.error);
|
||||
value.subtitles = (value.subtitles || [])
|
||||
.map((s: unknown) => subtitleSchema.parse(s))
|
||||
.slice(0, 500);
|
||||
return value;
|
||||
}
|
||||
export async function prepareAnalysis(file: string, hash: string, dir: string, hasAudio: boolean) {
|
||||
const proxy = path.join(dir, hash + ".proxy.mp4"),
|
||||
audio = path.join(dir, hash + ".wav");
|
||||
try {
|
||||
await stat(proxy);
|
||||
} catch {
|
||||
const temp = proxy + ".part.mp4";
|
||||
await ffmpeg([
|
||||
...mediaSafety,
|
||||
"-i",
|
||||
file,
|
||||
"-an",
|
||||
"-vf",
|
||||
"scale=640:360:force_original_aspect_ratio=decrease,fps=15",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
temp,
|
||||
]);
|
||||
await rename(temp, proxy);
|
||||
}
|
||||
if (hasAudio) await prepareAudio(file, audio);
|
||||
return { proxy, audio: hasAudio ? audio : undefined };
|
||||
}
|
||||
export async function prepareAudio(file: string, audio: string) {
|
||||
try {
|
||||
await stat(audio);
|
||||
} catch {
|
||||
const temp = audio + ".part.wav";
|
||||
await ffmpeg([
|
||||
...mediaSafety,
|
||||
"-i",
|
||||
file,
|
||||
"-vn",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
"22050",
|
||||
"-c:a",
|
||||
"pcm_s16le",
|
||||
temp,
|
||||
]);
|
||||
await rename(temp, audio);
|
||||
}
|
||||
return audio;
|
||||
}
|
||||
146
lib/video-canvas/smart-edit/remote-render.ts
Normal file
146
lib/video-canvas/smart-edit/remote-render.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { EditPlan } from "./contract";
|
||||
import { ProviderTaskUncertainError } from "../video-task-lifecycle";
|
||||
|
||||
export type RemoteRenderCheckpoint = { id: string; endpointHash: string };
|
||||
const sha = (value: string) => createHash("sha256").update(value).digest("hex");
|
||||
export function remoteRenderConfig() {
|
||||
const value = process.env.SMART_EDIT_RENDER_URL;
|
||||
if (!value) return null;
|
||||
const url = new URL(value);
|
||||
if (
|
||||
!["http:", "https:"].includes(url.protocol) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash
|
||||
)
|
||||
throw Error("视频合成服务地址配置无效");
|
||||
const base = value.replace(/\/$/, ""),
|
||||
token = process.env.SMART_EDIT_RENDER_TOKEN;
|
||||
if (!token || token.length < 32) throw Error("视频合成服务认证未配置");
|
||||
return { base, token };
|
||||
}
|
||||
const statusSchema = z.object({
|
||||
id: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
status: z.enum(["queued", "running", "succeeded", "failed"]),
|
||||
stage: z.string().max(300),
|
||||
error: z.string().max(500).optional(),
|
||||
result: z
|
||||
.object({
|
||||
width: z.number().int().positive().max(1920),
|
||||
height: z.number().int().positive().max(1920),
|
||||
durationSeconds: z.number().positive().max(90.2),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export async function remoteRender(
|
||||
plan: EditPlan,
|
||||
userId: string,
|
||||
taskId: string,
|
||||
dir: string,
|
||||
lifecycle: {
|
||||
resume?: RemoteRenderCheckpoint;
|
||||
active: () => Promise<void>;
|
||||
save: (checkpoint: RemoteRenderCheckpoint, stage: string) => Promise<void>;
|
||||
},
|
||||
) {
|
||||
const config = remoteRenderConfig();
|
||||
if (!config) throw Error("远程合成任务的服务配置已移除,不能回退本机重复合成");
|
||||
const checkpoint = lifecycle.resume || {
|
||||
id: sha("video-render-v1:" + userId + ":" + taskId),
|
||||
endpointHash: sha(config.base),
|
||||
};
|
||||
if (!/^[a-f0-9]{64}$/.test(checkpoint.id) || checkpoint.endpointHash !== sha(config.base))
|
||||
throw Error("合成任务所属服务器已变化,请恢复原服务配置后继续");
|
||||
await lifecycle.save(checkpoint, "提交算力服务器");
|
||||
const headers = { authorization: "Bearer " + config.token, "content-type": "application/json" };
|
||||
async function request(route: string, body?: unknown) {
|
||||
await lifecycle.active();
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(config!.base + route, {
|
||||
method: body ? "POST" : "GET",
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(20000),
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
throw new ProviderTaskUncertainError("算力服务器连接中断,任务已保存,可继续查询");
|
||||
}
|
||||
if ([429, 500, 502, 503, 504].includes(response.status))
|
||||
throw new ProviderTaskUncertainError("算力服务器繁忙,任务已保存等待继续");
|
||||
if (!response.ok)
|
||||
throw Error(`合成服务请求失败(${response.status}),请检查服务状态或任务参数`);
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
throw new ProviderTaskUncertainError("合成服务响应中断,将查询同一任务");
|
||||
}
|
||||
const result = statusSchema.parse(data);
|
||||
if (result.id !== checkpoint.id) throw Error("合成服务返回了不匹配的任务编号");
|
||||
return result;
|
||||
}
|
||||
let status = await request("/v1/renders", { id: checkpoint.id, tenant: sha(userId), plan });
|
||||
const deadline = Date.now() + 8 * 60000;
|
||||
while (status.status === "queued" || status.status === "running") {
|
||||
await lifecycle.save(checkpoint, status.stage);
|
||||
if (Date.now() > deadline)
|
||||
throw new ProviderTaskUncertainError("远程合成仍在运行,已保存任务进度");
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
status = await request("/v1/renders/" + checkpoint.id);
|
||||
}
|
||||
if (status.status === "failed") throw Error(status.error || "算力服务器合成失败");
|
||||
if (!status.result) throw Error("合成服务未返回成片参数");
|
||||
await lifecycle.save(checkpoint, "读取远程成片");
|
||||
async function download(kind: string, destination: string, maxBytes: number) {
|
||||
await lifecycle.active();
|
||||
try {
|
||||
const response = await fetch(config!.base + "/v1/renders/" + checkpoint.id + "/" + kind, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(180000),
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (
|
||||
!response.ok ||
|
||||
!response.body ||
|
||||
Number(response.headers.get("content-length")) > maxBytes
|
||||
)
|
||||
throw Error("远程成片不可读取或超过处理上限");
|
||||
let size = 0;
|
||||
const limit = new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
size += chunk.length;
|
||||
callback(size > maxBytes ? Error("远程成片超过处理上限") : null, chunk);
|
||||
},
|
||||
});
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body as any),
|
||||
limit,
|
||||
createWriteStream(destination, { mode: 0o600 }),
|
||||
);
|
||||
} catch {
|
||||
throw new ProviderTaskUncertainError("成片已生成但读取中断,将继续读取同一任务");
|
||||
}
|
||||
}
|
||||
const file = path.join(dir, "result.mp4"),
|
||||
srt = path.join(dir, "subtitles.srt");
|
||||
await download("video", file, 300 * 1024 * 1024);
|
||||
await download("subtitles", srt, 1024 * 1024);
|
||||
return {
|
||||
file,
|
||||
srt,
|
||||
width: status.result.width,
|
||||
height: status.result.height,
|
||||
duration: status.result.durationSeconds,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user