fix(video-canvas): persist image operations and story subtasks
This commit is contained in:
parent
0d17638a8d
commit
86b66172b1
@ -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);
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { imageOperationSchema } from "@/lib/video-canvas/image-operation";
|
||||
import { after, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { z } from "zod";
|
||||
@ -39,6 +40,17 @@ export async function POST(request: Request) {
|
||||
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 });
|
||||
const userId = await owner(parsed.data.projectId);
|
||||
const task = await createCanvasGenerationTask(userId, parsed.data);
|
||||
kick(userId, parsed.data.projectId);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,6 +2,8 @@
|
||||
|
||||
承接 `23f86dfc` 的全面扫描。用户补充“浏览器刷新一下就好了”,本轮重点修复任务结果与页面状态脱节,同时处理 F02–F07 中已确认的入口问题。本记录是分支修复与本地验收,未部署 48 服务器,也没有把原 231 项清单改成全部通过。
|
||||
|
||||
> 前半部分保留 0d17638a 阶段记录;本轮新增修复和验证见文末“后续修复”。
|
||||
|
||||
## 修复范围
|
||||
|
||||
| 扫描项 | 本轮改动 | 验证及边界 |
|
||||
@ -49,3 +51,20 @@ 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 项全部完成。
|
||||
|
||||
@ -145,6 +145,7 @@ export function useVideoCanvasTasks<T extends Node>(
|
||||
kind: CanvasTaskRequest["kind"],
|
||||
params: Record<string, unknown>,
|
||||
edges: Edge[] = [],
|
||||
attachment?: CanvasTaskRequest["attachment"],
|
||||
) => {
|
||||
if (!loaded || loaded.id !== projectId) throw Error("画布尚未加载完成,请稍后提交");
|
||||
const requestId = crypto.randomUUID();
|
||||
@ -168,6 +169,7 @@ export function useVideoCanvasTasks<T extends Node>(
|
||||
node: snapshot,
|
||||
params,
|
||||
edges,
|
||||
attachment,
|
||||
};
|
||||
// Persist before POST: a refresh during an uncertain network response retries the same ID.
|
||||
remember(request);
|
||||
|
||||
@ -3,6 +3,7 @@ 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";
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
1410
lib/canvas/operations/enhance.ts
Normal file
1410
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
115
lib/video-canvas/image-operation.ts
Normal file
115
lib/video-canvas/image-operation.ts
Normal file
@ -0,0 +1,115 @@
|
||||
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 {}
|
||||
type Handler = (request: Request, userId: string) => 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;
|
||||
} = {},
|
||||
) {
|
||||
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];
|
||||
let response = await handler(
|
||||
new Request("http://canvas.internal/operation", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
userId,
|
||||
);
|
||||
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 options.checkpoint?.({
|
||||
upstreamTask: { operation: "enhance", taskId, sourceImageUrl: input.imageUrl },
|
||||
});
|
||||
} catch {
|
||||
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);
|
||||
try {
|
||||
response = await handlers.pollEnhance(
|
||||
new Request(`http://canvas.internal/operation?${query}`),
|
||||
userId,
|
||||
);
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new ImageOperationUncertainError(
|
||||
"高清任务查询连接中断,已保留任务标识,请勿重复提交",
|
||||
);
|
||||
}
|
||||
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 }] };
|
||||
}
|
||||
96
lib/video-canvas/story-task-attachment.ts
Normal file
96
lib/video-canvas/story-task-attachment.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { z } from "zod";
|
||||
export const storyTaskAttachmentSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("story-asset"),
|
||||
nodeId: z.string().min(1),
|
||||
asset: z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
imageUrl: z.string(),
|
||||
type: z.string(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("story-prompts"),
|
||||
nodeId: z.string().min(1),
|
||||
rows: z
|
||||
.array(z.object({ id: z.string().min(1) }).catchall(z.unknown()))
|
||||
.min(1)
|
||||
.max(120),
|
||||
}),
|
||||
]);
|
||||
export type StoryTaskAttachment = z.infer<typeof storyTaskAttachmentSchema>;
|
||||
export function requireStoryPromptResult(data: any, attachment?: StoryTaskAttachment) {
|
||||
if (attachment?.type !== "story-prompts") return;
|
||||
if (
|
||||
!Array.isArray(data?.analysisRows) ||
|
||||
data.analysisRows.length !== attachment.rows.length ||
|
||||
data.analysisRows.some(
|
||||
(row: any) => !String(row.imagePrompt || "").trim() || !String(row.motionPrompt || "").trim(),
|
||||
)
|
||||
)
|
||||
throw Error("未返回完整镜头提示词,原内容已保留");
|
||||
}
|
||||
export function mergeStoryTaskResult(
|
||||
draft: Record<string, any>,
|
||||
attachment: StoryTaskAttachment,
|
||||
data: any,
|
||||
taskId: string,
|
||||
) {
|
||||
if (draft.storyTaskResults?.[taskId]) return draft;
|
||||
let conflict = false;
|
||||
let patch: Record<string, any> = {};
|
||||
if (attachment.type === "story-asset") {
|
||||
let found = false;
|
||||
const url = data.images?.[0]?.originalUrl || data.images?.[0]?.thumbnailUrl;
|
||||
patch.storyAssets = (draft.storyAssets || []).map((asset: any) => {
|
||||
if (asset.id !== attachment.asset.id) return asset;
|
||||
found = true;
|
||||
if (
|
||||
!url ||
|
||||
["name", "description", "type", "imageUrl"].some(
|
||||
(key) => asset[key] !== attachment.asset[key as keyof typeof attachment.asset],
|
||||
)
|
||||
) {
|
||||
conflict = true;
|
||||
return asset;
|
||||
}
|
||||
return { ...asset, imageUrl: url };
|
||||
});
|
||||
if (!found) conflict = true;
|
||||
} else {
|
||||
requireStoryPromptResult(data, attachment);
|
||||
const byId = new Map(attachment.rows.map((row, index) => [row.id, { row, index }]));
|
||||
let found = 0;
|
||||
patch.rows = (draft.rows || []).map((row: any) => {
|
||||
const expected = byId.get(row.id);
|
||||
if (!expected) return row;
|
||||
found++;
|
||||
if (
|
||||
Object.keys(expected.row).some(
|
||||
(key) => JSON.stringify(row[key]) !== JSON.stringify(expected.row[key]),
|
||||
)
|
||||
) {
|
||||
conflict = true;
|
||||
return row;
|
||||
}
|
||||
const generated = data.analysisRows[expected.index];
|
||||
return { ...row, imagePrompt: generated.imagePrompt, motionPrompt: generated.motionPrompt };
|
||||
});
|
||||
if (found !== attachment.rows.length) conflict = true;
|
||||
}
|
||||
return {
|
||||
...draft,
|
||||
...patch,
|
||||
storyTaskResults: {
|
||||
...draft.storyTaskResults,
|
||||
[taskId]: {
|
||||
status: conflict ? "conflict" : "applied",
|
||||
message: conflict
|
||||
? "生成期间内容有修改或删除,已保留原内容;新结果在画布的生成节点中。"
|
||||
: "生成结果已同步到脚本。",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1,9 +1,10 @@
|
||||
import { storyTaskAttachmentSchema, mergeStoryTaskResult } from "./story-task-attachment";
|
||||
import { z } from "zod";
|
||||
|
||||
export const canvasTaskRequestSchema = z.object({
|
||||
requestId: z.string().uuid(),
|
||||
projectId: z.string().uuid(),
|
||||
kind: z.enum(["video", "image", "analysis"]),
|
||||
kind: z.enum(["video", "image", "analysis", "image-operation"]),
|
||||
node: z
|
||||
.object({
|
||||
id: z.string().min(1).max(180),
|
||||
@ -18,6 +19,7 @@ export const canvasTaskRequestSchema = z.object({
|
||||
})
|
||||
.passthrough(),
|
||||
params: z.record(z.unknown()),
|
||||
attachment: storyTaskAttachmentSchema.optional(),
|
||||
hiddenNodeIds: z.array(z.string()).max(20).optional(),
|
||||
edges: z
|
||||
.array(z.object({ id: z.string(), source: z.string(), target: z.string() }))
|
||||
@ -123,7 +125,7 @@ export function reconcileCanvasTasks<
|
||||
subtitle = "生成结果";
|
||||
if (task.request.kind === "video")
|
||||
Object.assign(nextDraft, { mediaUrl: data.url, url: [data.url], videoMeta: data.metadata });
|
||||
if (task.request.kind === "image") {
|
||||
if (task.request.kind === "image" || task.request.kind === "image-operation") {
|
||||
const urls = (data.images || [])
|
||||
.map((image: any) => image.originalUrl || image.thumbnailUrl)
|
||||
.filter(Boolean);
|
||||
@ -163,14 +165,19 @@ export function reconcileCanvasTasks<
|
||||
task.error ||
|
||||
(task.status === "cancelled" ? "已取消排队,未提交供应商" : "任务失败,请查看任务状态"),
|
||||
});
|
||||
subtitle = task.status === "cancelled" ? "已取消" : "生成失败";
|
||||
subtitle =
|
||||
task.status === "cancelled"
|
||||
? "已取消"
|
||||
: task.status === "interrupted"
|
||||
? "待核实"
|
||||
: "生成失败";
|
||||
}
|
||||
result = result.map((item, i) =>
|
||||
i === index
|
||||
? {
|
||||
...item,
|
||||
...(task.status === "succeeded" && task.request.kind !== "analysis"
|
||||
? { kind: "media", inputType: task.request.kind }
|
||||
? { kind: "media", inputType: task.request.kind === "video" ? "video" : "image" }
|
||||
: {}),
|
||||
subtitle,
|
||||
draftValues: nextDraft,
|
||||
@ -178,5 +185,24 @@ export function reconcileCanvasTasks<
|
||||
: item,
|
||||
);
|
||||
}
|
||||
for (const task of tasks) {
|
||||
const attachment = task.request.attachment;
|
||||
if (
|
||||
task.status !== "succeeded" ||
|
||||
!attachment ||
|
||||
task.request.hiddenNodeIds?.includes(task.request.node.id) ||
|
||||
!result.some((node) => node.id === task.request.node.id)
|
||||
)
|
||||
continue;
|
||||
const targetIndex = result.findIndex((node) => node.id === attachment.nodeId);
|
||||
if (targetIndex < 0) continue;
|
||||
const node = result[targetIndex],
|
||||
draft = node.draftValues || {};
|
||||
const next = mergeStoryTaskResult(draft, attachment, task.result || {}, task.id);
|
||||
if (next !== draft)
|
||||
result = result.map((item, index) =>
|
||||
index === targetIndex ? { ...item, draftValues: next } : item,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -52,6 +52,8 @@ export async function createCanvasGenerationTask(userId: string, request: Canvas
|
||||
if (
|
||||
old.node.id !== request.node.id ||
|
||||
old.kind !== request.kind ||
|
||||
JSON.stringify(sortObject(old.attachment)) !==
|
||||
JSON.stringify(sortObject(request.attachment)) ||
|
||||
JSON.stringify(sortObject(old.params)) !== JSON.stringify(sortObject(request.params))
|
||||
)
|
||||
throw Object.assign(Error("同一任务 ID 不能用于不同请求"), { status: 409 });
|
||||
@ -225,3 +227,16 @@ export async function hideCanvasGenerationNode(
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkpointCanvasGenerationTask(id: string, result: Record<string, unknown>) {
|
||||
await db
|
||||
.update(table)
|
||||
.set({ result, updatedAt: new Date() })
|
||||
.where(and(eq(table.id, id), eq(table.status, "running")));
|
||||
}
|
||||
export async function interruptCanvasGenerationTask(id: string, error: string) {
|
||||
await db
|
||||
.update(table)
|
||||
.set({ status: "interrupted", error, updatedAt: new Date() })
|
||||
.where(and(eq(table.id, id), eq(table.status, "running")));
|
||||
}
|
||||
|
||||
@ -1,3 +1,10 @@
|
||||
import { requireStoryPromptResult } from "./story-task-attachment";
|
||||
import { executeImageOperation, ImageOperationUncertainError } from "./image-operation";
|
||||
import { executeEnhancePOST, executeEnhanceGET } from "@/lib/canvas/operations/enhance";
|
||||
import { executeExtendPOST } from "@/lib/canvas/operations/extend";
|
||||
import { executeRemoveBackgroundPOST } from "@/lib/canvas/operations/rmbg";
|
||||
import { executeAngleEditPOST } from "@/lib/canvas/operations/angle-edit";
|
||||
import { checkpointCanvasGenerationTask, interruptCanvasGenerationTask } from "./task-store";
|
||||
import "server-only";
|
||||
import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video";
|
||||
import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response";
|
||||
@ -11,8 +18,21 @@ import {
|
||||
heartbeatCanvasGenerationTask,
|
||||
} from "./task-store";
|
||||
|
||||
async function execute(request: CanvasTaskRequest, userId: string) {
|
||||
async function execute(request: CanvasTaskRequest, userId: string, taskId: string) {
|
||||
const params = request.params;
|
||||
if (request.kind === "image-operation")
|
||||
return executeImageOperation(
|
||||
params,
|
||||
userId,
|
||||
{
|
||||
enhance: executeEnhancePOST,
|
||||
extend: executeExtendPOST,
|
||||
removeBackground: executeRemoveBackgroundPOST,
|
||||
angleEdit: executeAngleEditPOST,
|
||||
pollEnhance: executeEnhanceGET,
|
||||
},
|
||||
{ checkpoint: (result) => checkpointCanvasGenerationTask(taskId, result) },
|
||||
);
|
||||
if (request.kind === "video") {
|
||||
const checked = await validateVideoReferences(params.images, (url) =>
|
||||
probeRemoteUrl(url, { timeoutMs: 8000 }),
|
||||
@ -52,6 +72,7 @@ async function execute(request: CanvasTaskRequest, userId: string) {
|
||||
)
|
||||
throw Error("图片生成没有返回结果");
|
||||
if (request.kind === "analysis") requireAnalysisResult(body.data, String(params.mode));
|
||||
requireStoryPromptResult(body.data, request.attachment);
|
||||
return body.data;
|
||||
}
|
||||
export async function runCanvasGenerationQueue(userId: string, projectId: string) {
|
||||
@ -64,8 +85,12 @@ export async function runCanvasGenerationQueue(userId: string, projectId: string
|
||||
20000,
|
||||
);
|
||||
try {
|
||||
await finishCanvasGenerationTask(task.id, await execute(task.request, userId));
|
||||
await finishCanvasGenerationTask(task.id, await execute(task.request, userId, task.id));
|
||||
} catch (error) {
|
||||
if (error instanceof ImageOperationUncertainError) {
|
||||
await interruptCanvasGenerationTask(task.id, error.message);
|
||||
continue;
|
||||
}
|
||||
await finishCanvasGenerationTask(
|
||||
task.id,
|
||||
undefined,
|
||||
|
||||
@ -134,11 +134,34 @@ const fs = require("fs"),
|
||||
"interrupted",
|
||||
);
|
||||
assert.equal(await store.claimCanvasGenerationTask(user, project), null);
|
||||
await store.checkpointCanvasGenerationTask(stale.id, {
|
||||
upstreamTask: { taskId: "ignored-after-interruption" },
|
||||
});
|
||||
assert.equal(
|
||||
(await store.listCanvasGenerationTasks(user, project)).find((t) => t.id === stale.id).result,
|
||||
null,
|
||||
);
|
||||
await store.finishCanvasGenerationTask(stale.id, { url: "https://example.com/late.mp4" });
|
||||
assert.equal(
|
||||
(await store.listCanvasGenerationTasks(user, project)).find((t) => t.id === stale.id).status,
|
||||
"succeeded",
|
||||
);
|
||||
const upstream = await store.createCanvasGenerationTask(user, {
|
||||
...request,
|
||||
requestId: crypto.randomUUID(),
|
||||
node: { ...request.node, id: "node-upstream" },
|
||||
});
|
||||
await store.claimCanvasGenerationTask(user, project);
|
||||
await store.checkpointCanvasGenerationTask(upstream.id, {
|
||||
upstreamTask: { operation: "enhance", taskId: "provider-123" },
|
||||
});
|
||||
await store.interruptCanvasGenerationTask(upstream.id, "查询连接中断");
|
||||
const recovered = (await store.listCanvasGenerationTasks(user, project)).find(
|
||||
(t) => t.id === upstream.id,
|
||||
);
|
||||
assert.equal(recovered.status, "interrupted");
|
||||
assert.equal(recovered.result.upstreamTask.taskId, "provider-123");
|
||||
assert.equal(await store.claimCanvasGenerationTask(user, project), null);
|
||||
console.log(
|
||||
"PASS: real PostgreSQL ownership, idempotency (10 submits), exclusive claiming (10 workers), serial queue, cancellation CAS, durable results, hidden nodes, interrupted task without resubmission, late-result recovery.",
|
||||
);
|
||||
|
||||
@ -1,3 +1,12 @@
|
||||
import {
|
||||
executeImageOperation,
|
||||
imageOperationSchema,
|
||||
ImageOperationUncertainError,
|
||||
} from "@/lib/video-canvas/image-operation";
|
||||
import {
|
||||
mergeStoryTaskResult,
|
||||
requireStoryPromptResult,
|
||||
} from "@/lib/video-canvas/story-task-attachment";
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
canvasTaskRequestSchema,
|
||||
@ -221,3 +230,196 @@ test("a rerun retains distinct extra results without reusing the previous reques
|
||||
expect(next.find((n) => n.id === "n1-result-task2-1")?.draftValues?.mediaUrl).toBe("new2");
|
||||
expect(reconcileCanvasTasks<TaskNode>(next, [task, newer], "2026-09-17T01:02:00Z")).toBe(next);
|
||||
});
|
||||
|
||||
test("image postprocessing validates provider parameters before dispatch", () => {
|
||||
expect(
|
||||
imageOperationSchema.safeParse({
|
||||
operation: "angle-edit",
|
||||
imageUrl: "https://example.com/a.png",
|
||||
horizontalAngle: 180,
|
||||
verticalAngle: -30,
|
||||
zoom: 5,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
for (const value of [
|
||||
{ operation: "shell", imageUrl: "https://example.com/a.png" },
|
||||
{ operation: "enhance", imageUrl: "file:///etc/passwd" },
|
||||
{ operation: "angle-edit", imageUrl: "https://example.com/a.png" },
|
||||
{ operation: "enhance", imageUrl: "https://example.com/a.png", scaleFactor: 100 },
|
||||
])
|
||||
expect(imageOperationSchema.safeParse(value).success).toBe(false);
|
||||
});
|
||||
test("an accepted enhance task is polled without repeating POST and retains its upstream identity", async () => {
|
||||
let submits = 0,
|
||||
polls = 0;
|
||||
const checkpoints: any[] = [];
|
||||
const handler = async () =>
|
||||
Response.json({ success: true, original_url: "https://example.com/result.png" });
|
||||
const result = await executeImageOperation(
|
||||
{ operation: "enhance", imageUrl: "https://example.com/source.png", scaleFactor: 4 },
|
||||
"owner",
|
||||
{
|
||||
enhance: async (req, user) => {
|
||||
submits++;
|
||||
expect(user).toBe("owner");
|
||||
expect(await req.json()).toMatchObject({ scaleFactor: 4 });
|
||||
return Response.json(
|
||||
{ success: true, processing: true, taskId: "upstream-1" },
|
||||
{ status: 202 },
|
||||
);
|
||||
},
|
||||
extend: handler,
|
||||
removeBackground: handler,
|
||||
angleEdit: handler,
|
||||
pollEnhance: async (req, user) => {
|
||||
polls++;
|
||||
expect(user).toBe("owner");
|
||||
expect(new URL(req.url).searchParams.get("taskId")).toBe("upstream-1");
|
||||
return polls < 2 ? Response.json({ processing: true }, { status: 202 }) : handler();
|
||||
},
|
||||
},
|
||||
{
|
||||
wait: async () => {},
|
||||
checkpoint: async (value) => {
|
||||
checkpoints.push(value);
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(submits).toBe(1);
|
||||
expect(polls).toBe(2);
|
||||
expect(checkpoints[0].upstreamTask.taskId).toBe("upstream-1");
|
||||
expect(result.images[0].originalUrl).toBe("https://example.com/result.png");
|
||||
});
|
||||
test("a pending or disconnected upstream query is reported as uncertain rather than submitted again", async () => {
|
||||
const handler = async () =>
|
||||
Response.json({ processing: true, taskId: "upstream-1" }, { status: 202 });
|
||||
for (const pollEnhance of [
|
||||
handler,
|
||||
async () => {
|
||||
throw Error("connection lost");
|
||||
},
|
||||
async () => Response.json({ error: "upstream temporarily unavailable" }, { status: 502 }),
|
||||
]) {
|
||||
await expect(
|
||||
executeImageOperation(
|
||||
{ operation: "enhance", imageUrl: "https://example.com/source.png" },
|
||||
"owner",
|
||||
{
|
||||
enhance: handler,
|
||||
extend: handler,
|
||||
removeBackground: handler,
|
||||
angleEdit: handler,
|
||||
pollEnhance,
|
||||
},
|
||||
{ wait: async () => {}, maxPolls: 2 },
|
||||
),
|
||||
).rejects.toBeInstanceOf(ImageOperationUncertainError);
|
||||
}
|
||||
});
|
||||
test("angle operation sends normalized numeric fields and accepts the dedicated response shape", async () => {
|
||||
const handler = async (req: Request) => {
|
||||
expect(await req.json()).toEqual({
|
||||
image_url: "https://example.com/source.png",
|
||||
horizontal_angle: 180,
|
||||
vertical_angle: 45,
|
||||
zoom: 5,
|
||||
});
|
||||
return Response.json({ success: true, image_url: "https://example.com/angle.png" });
|
||||
};
|
||||
const result = await executeImageOperation(
|
||||
{
|
||||
operation: "angle-edit",
|
||||
imageUrl: "https://example.com/source.png",
|
||||
horizontalAngle: 180,
|
||||
verticalAngle: 45,
|
||||
zoom: 5,
|
||||
},
|
||||
"owner",
|
||||
{
|
||||
enhance: handler,
|
||||
extend: handler,
|
||||
removeBackground: handler,
|
||||
angleEdit: handler,
|
||||
pollEnhance: handler,
|
||||
},
|
||||
);
|
||||
expect(result.images[0].originalUrl).toContain("angle.png");
|
||||
expect(
|
||||
reconcileCanvasTasks<TaskNode>(
|
||||
[node],
|
||||
[{ ...task, request: { ...task.request, kind: "image-operation" }, result }],
|
||||
task.createdAt,
|
||||
)[0].inputType,
|
||||
).toBe("image");
|
||||
});
|
||||
test("postprocessing preserves upstream failures and rejects an empty success", async () => {
|
||||
for (const payload of [{ success: false, error: "积分不足" }, { success: true }]) {
|
||||
const handler = async () => Response.json(payload);
|
||||
await expect(
|
||||
executeImageOperation(
|
||||
{ operation: "remove-bg", imageUrl: "https://example.com/a.png" },
|
||||
"owner",
|
||||
{
|
||||
enhance: handler,
|
||||
extend: handler,
|
||||
removeBackground: handler,
|
||||
angleEdit: handler,
|
||||
pollEnhance: handler,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
const asset = { id: "asset-1", name: "杯子", description: "白色", type: "prop", imageUrl: "" };
|
||||
const attachment = { type: "story-asset" as const, nodeId: "script", asset };
|
||||
test("asset results merge by stable identity after reordering and are only applied once", () => {
|
||||
const draft = { storyAssets: [{ ...asset, id: "other" }, asset] };
|
||||
const result = mergeStoryTaskResult(draft, attachment, task.result, "job");
|
||||
expect(result.storyAssets[1].imageUrl).toBe("https://example.com/1.png");
|
||||
expect(result.storyAssets[0].imageUrl).toBe("");
|
||||
expect(mergeStoryTaskResult(result, attachment, task.result, "job")).toBe(result);
|
||||
});
|
||||
test("late asset results preserve user edits and deletions", () => {
|
||||
for (const storyAssets of [
|
||||
[{ ...asset, imageUrl: "user-upload" }],
|
||||
[{ ...asset, description: "changed" }],
|
||||
[],
|
||||
]) {
|
||||
const result = mergeStoryTaskResult({ storyAssets }, attachment, task.result, "job");
|
||||
expect(result.storyAssets).toEqual(storyAssets);
|
||||
expect(result.storyTaskResults.job.status).toBe("conflict");
|
||||
}
|
||||
});
|
||||
test("prompt results merge only their original rows without replacing dialogue or edited rows", () => {
|
||||
const rows = [
|
||||
{ id: "r1", dialogue: "原对白", imagePrompt: "", motionPrompt: "" },
|
||||
{ id: "r2", dialogue: "第二行", imagePrompt: "", motionPrompt: "" },
|
||||
];
|
||||
const target = { type: "story-prompts" as const, nodeId: "script", rows };
|
||||
const data = {
|
||||
analysisRows: [
|
||||
{ imagePrompt: "image 1", motionPrompt: "move 1" },
|
||||
{ imagePrompt: "image 2", motionPrompt: "move 2" },
|
||||
],
|
||||
};
|
||||
const current = [{ ...rows[1], dialogue: "用户修改" }, rows[0]];
|
||||
const result = mergeStoryTaskResult({ rows: current }, target, data, "job");
|
||||
expect(result.rows[0]).toEqual(current[0]);
|
||||
expect(result.rows[1]).toEqual({ ...rows[0], imagePrompt: "image 1", motionPrompt: "move 1" });
|
||||
expect(result.storyTaskResults.job.status).toBe("conflict");
|
||||
});
|
||||
test("incomplete prompt batches cannot be marked successful", () => {
|
||||
const target = { type: "story-prompts" as const, nodeId: "script", rows: [{ id: "r1" }] };
|
||||
for (const data of [{}, { analysisRows: [] }, { analysisRows: [{ imagePrompt: "x" }] }])
|
||||
expect(() => requireStoryPromptResult(data, target)).toThrow();
|
||||
});
|
||||
test("recovered task results populate a script while retaining the independent output node", () => {
|
||||
const script = { ...node, id: "script", draftValues: { storyAssets: [asset] } };
|
||||
const attached = { ...task, request: { ...task.request, attachment } };
|
||||
const result = reconcileCanvasTasks<TaskNode>([script], [attached], "2026-09-17T01:00:00Z");
|
||||
expect(result.find((n) => n.id === "script")?.draftValues?.storyAssets[0].imageUrl).toBe(
|
||||
"https://example.com/1.png",
|
||||
);
|
||||
expect(result.some((n) => n.id === "n1")).toBe(true);
|
||||
expect(reconcileCanvasTasks<TaskNode>(result, [attached], "2026-09-17T01:00:00Z")).toBe(result);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user