fix(video-canvas): persist image operations and story subtasks

This commit is contained in:
134 2026-09-17 13:30:32 +08:00
parent 0d17638a8d
commit 86b66172b1
20 changed files with 3302 additions and 2624 deletions

View File

@ -1,385 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import {
createMediaAssetRelation,
getMediaAssetsByUserAndUrls,
getPointsSystemConfig,
getSystemSetting,
upsertMediaAsset,
} from "@/lib/db/queries";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service"; import { executeAngleEditPOST } from "@/lib/canvas/operations/angle-edit";
import { db } from "@/lib/db"; export const runtime = "nodejs";
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秒)
export const maxDuration = 180; export const maxDuration = 180;
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);
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 }
);
}
} }

View File

@ -1,3 +1,4 @@
import { imageOperationSchema } from "@/lib/video-canvas/image-operation";
import { after, NextResponse } from "next/server"; import { after, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { z } from "zod"; import { z } from "zod";
@ -39,6 +40,17 @@ export async function POST(request: Request) {
throw Object.assign(Error("任务数据过大,请使用已上传的素材链接"), { status: 413 }); throw Object.assign(Error("任务数据过大,请使用已上传的素材链接"), { status: 413 });
const parsed = canvasTaskRequestSchema.safeParse(JSON.parse(text)); const parsed = canvasTaskRequestSchema.safeParse(JSON.parse(text));
if (!parsed.success) throw Object.assign(Error("任务参数不合法"), { status: 400 }); 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 userId = await owner(parsed.data.projectId);
const task = await createCanvasGenerationTask(userId, parsed.data); const task = await createCanvasGenerationTask(userId, parsed.data);
kick(userId, parsed.data.projectId); kick(userId, parsed.data.projectId);

File diff suppressed because it is too large Load Diff

View File

@ -1,239 +1,10 @@
import { type NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { ExtendServiceError, executeExtendJob } from "@/lib/ai/extend/service"; import { executeExtendPOST } from "@/lib/canvas/operations/extend";
import { getPointsSystemConfig } from "@/lib/db/queries"; export const runtime = "nodejs";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
import {
getFriendlyRateLimitMessage,
getUserFacingRequestErrorMessage,
isRateLimitError,
} from "@/lib/request-error";
export const maxDuration = 180; export const maxDuration = 180;
export async function POST(request: Request) {
function parseIncludeBase64(value: unknown): boolean { const session = await auth();
if (typeof value === "boolean") return value; if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
const normalized = String(value ?? "").trim().toLowerCase(); return executeExtendPOST(request, session.user.id);
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 },
);
}
} }

View File

@ -1,138 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { import { executeRemoveBackgroundPOST } from "@/lib/canvas/operations/rmbg";
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 const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 60; export const maxDuration = 60;
export async function POST(request: Request) {
export async function POST(request: NextRequest) { const session = await auth();
try { if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
const session = await auth(); return executeRemoveBackgroundPOST(request, session.user.id);
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 }
);
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,8 @@
承接 `23f86dfc` 的全面扫描。用户补充“浏览器刷新一下就好了”,本轮重点修复任务结果与页面状态脱节,同时处理 F02–F07 中已确认的入口问题。本记录是分支修复与本地验收,未部署 48 服务器,也没有把原 231 项清单改成全部通过。 承接 `23f86dfc` 的全面扫描。用户补充“浏览器刷新一下就好了”,本轮重点修复任务结果与页面状态脱节,同时处理 F02–F07 中已确认的入口问题。本记录是分支修复与本地验收,未部署 48 服务器,也没有把原 231 项清单改成全部通过。
> 前半部分保留 0d17638a 阶段记录;本轮新增修复和验证见文末“后续修复”。
## 修复范围 ## 修复范围
| 扫描项 | 本轮改动 | 验证及边界 | | 扫描项 | 本轮改动 | 验证及边界 |
@ -49,3 +51,20 @@ node scripts/qa/video-canvas-task-db.cjs
``` ```
浏览器验收脚手架位于忽略目录 `.qa/video-durable` 和 `.qa/video-repair`;上述记录保留测试方法和模拟边界,不能代替后续部署验收。 浏览器验收脚手架位于忽略目录 `.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 项全部完成。

View File

@ -145,6 +145,7 @@ export function useVideoCanvasTasks<T extends Node>(
kind: CanvasTaskRequest["kind"], kind: CanvasTaskRequest["kind"],
params: Record<string, unknown>, params: Record<string, unknown>,
edges: Edge[] = [], edges: Edge[] = [],
attachment?: CanvasTaskRequest["attachment"],
) => { ) => {
if (!loaded || loaded.id !== projectId) throw Error("画布尚未加载完成,请稍后提交"); if (!loaded || loaded.id !== projectId) throw Error("画布尚未加载完成,请稍后提交");
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
@ -168,6 +169,7 @@ export function useVideoCanvasTasks<T extends Node>(
node: snapshot, node: snapshot,
params, params,
edges, edges,
attachment,
}; };
// Persist before POST: a refresh during an uncertain network response retries the same ID. // Persist before POST: a refresh during an uncertain network response retries the same ID.
remember(request); remember(request);

View File

@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
import { import {
createCanvasImageTask, createCanvasImageTask,
enqueueCanvasImageTask, enqueueCanvasImageTask,
getCanvasImageTaskForUser,
} from "@/lib/canvas/image-task-queue"; } from "@/lib/canvas/image-task-queue";
import { generateCanvasImage, type CanvasGenerateImageRequest } from "@/lib/canvas/generate-image"; import { generateCanvasImage, type CanvasGenerateImageRequest } from "@/lib/canvas/generate-image";
import { getPointsSystemConfig } from "@/lib/db/queries"; import { getPointsSystemConfig } from "@/lib/db/queries";

View 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 }
);
}
}

File diff suppressed because it is too large Load Diff

View 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 },
);
}
}

View 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 }
);
}
}

View 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 }] };
}

View 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
? "生成期间内容有修改或删除,已保留原内容;新结果在画布的生成节点中。"
: "生成结果已同步到脚本。",
},
},
};
}

View File

@ -1,9 +1,10 @@
import { storyTaskAttachmentSchema, mergeStoryTaskResult } from "./story-task-attachment";
import { z } from "zod"; import { z } from "zod";
export const canvasTaskRequestSchema = z.object({ export const canvasTaskRequestSchema = z.object({
requestId: z.string().uuid(), requestId: z.string().uuid(),
projectId: z.string().uuid(), projectId: z.string().uuid(),
kind: z.enum(["video", "image", "analysis"]), kind: z.enum(["video", "image", "analysis", "image-operation"]),
node: z node: z
.object({ .object({
id: z.string().min(1).max(180), id: z.string().min(1).max(180),
@ -18,6 +19,7 @@ export const canvasTaskRequestSchema = z.object({
}) })
.passthrough(), .passthrough(),
params: z.record(z.unknown()), params: z.record(z.unknown()),
attachment: storyTaskAttachmentSchema.optional(),
hiddenNodeIds: z.array(z.string()).max(20).optional(), hiddenNodeIds: z.array(z.string()).max(20).optional(),
edges: z edges: z
.array(z.object({ id: z.string(), source: z.string(), target: z.string() })) .array(z.object({ id: z.string(), source: z.string(), target: z.string() }))
@ -123,7 +125,7 @@ export function reconcileCanvasTasks<
subtitle = "生成结果"; subtitle = "生成结果";
if (task.request.kind === "video") if (task.request.kind === "video")
Object.assign(nextDraft, { mediaUrl: data.url, url: [data.url], videoMeta: data.metadata }); 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 || []) const urls = (data.images || [])
.map((image: any) => image.originalUrl || image.thumbnailUrl) .map((image: any) => image.originalUrl || image.thumbnailUrl)
.filter(Boolean); .filter(Boolean);
@ -163,14 +165,19 @@ export function reconcileCanvasTasks<
task.error || task.error ||
(task.status === "cancelled" ? "已取消排队,未提交供应商" : "任务失败,请查看任务状态"), (task.status === "cancelled" ? "已取消排队,未提交供应商" : "任务失败,请查看任务状态"),
}); });
subtitle = task.status === "cancelled" ? "已取消" : "生成失败"; subtitle =
task.status === "cancelled"
? "已取消"
: task.status === "interrupted"
? "待核实"
: "生成失败";
} }
result = result.map((item, i) => result = result.map((item, i) =>
i === index i === index
? { ? {
...item, ...item,
...(task.status === "succeeded" && task.request.kind !== "analysis" ...(task.status === "succeeded" && task.request.kind !== "analysis"
? { kind: "media", inputType: task.request.kind } ? { kind: "media", inputType: task.request.kind === "video" ? "video" : "image" }
: {}), : {}),
subtitle, subtitle,
draftValues: nextDraft, draftValues: nextDraft,
@ -178,5 +185,24 @@ export function reconcileCanvasTasks<
: item, : 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; return result;
} }

View File

@ -52,6 +52,8 @@ export async function createCanvasGenerationTask(userId: string, request: Canvas
if ( if (
old.node.id !== request.node.id || old.node.id !== request.node.id ||
old.kind !== request.kind || old.kind !== request.kind ||
JSON.stringify(sortObject(old.attachment)) !==
JSON.stringify(sortObject(request.attachment)) ||
JSON.stringify(sortObject(old.params)) !== JSON.stringify(sortObject(request.params)) JSON.stringify(sortObject(old.params)) !== JSON.stringify(sortObject(request.params))
) )
throw Object.assign(Error("同一任务 ID 不能用于不同请求"), { status: 409 }); throw Object.assign(Error("同一任务 ID 不能用于不同请求"), { status: 409 });
@ -225,3 +227,16 @@ export async function hideCanvasGenerationNode(
return true; 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")));
}

View File

@ -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 "server-only";
import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video"; import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video";
import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response"; import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response";
@ -11,8 +18,21 @@ import {
heartbeatCanvasGenerationTask, heartbeatCanvasGenerationTask,
} from "./task-store"; } from "./task-store";
async function execute(request: CanvasTaskRequest, userId: string) { async function execute(request: CanvasTaskRequest, userId: string, taskId: string) {
const params = request.params; 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") { if (request.kind === "video") {
const checked = await validateVideoReferences(params.images, (url) => const checked = await validateVideoReferences(params.images, (url) =>
probeRemoteUrl(url, { timeoutMs: 8000 }), probeRemoteUrl(url, { timeoutMs: 8000 }),
@ -52,6 +72,7 @@ async function execute(request: CanvasTaskRequest, userId: string) {
) )
throw Error("图片生成没有返回结果"); throw Error("图片生成没有返回结果");
if (request.kind === "analysis") requireAnalysisResult(body.data, String(params.mode)); if (request.kind === "analysis") requireAnalysisResult(body.data, String(params.mode));
requireStoryPromptResult(body.data, request.attachment);
return body.data; return body.data;
} }
export async function runCanvasGenerationQueue(userId: string, projectId: string) { export async function runCanvasGenerationQueue(userId: string, projectId: string) {
@ -64,8 +85,12 @@ export async function runCanvasGenerationQueue(userId: string, projectId: string
20000, 20000,
); );
try { try {
await finishCanvasGenerationTask(task.id, await execute(task.request, userId)); await finishCanvasGenerationTask(task.id, await execute(task.request, userId, task.id));
} catch (error) { } catch (error) {
if (error instanceof ImageOperationUncertainError) {
await interruptCanvasGenerationTask(task.id, error.message);
continue;
}
await finishCanvasGenerationTask( await finishCanvasGenerationTask(
task.id, task.id,
undefined, undefined,

View File

@ -134,11 +134,34 @@ const fs = require("fs"),
"interrupted", "interrupted",
); );
assert.equal(await store.claimCanvasGenerationTask(user, project), null); 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" }); await store.finishCanvasGenerationTask(stale.id, { url: "https://example.com/late.mp4" });
assert.equal( assert.equal(
(await store.listCanvasGenerationTasks(user, project)).find((t) => t.id === stale.id).status, (await store.listCanvasGenerationTasks(user, project)).find((t) => t.id === stale.id).status,
"succeeded", "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( 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.", "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.",
); );

View File

@ -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 { test, expect } from "@playwright/test";
import { import {
canvasTaskRequestSchema, 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(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); 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);
});