289 lines
8.5 KiB
TypeScript
289 lines
8.5 KiB
TypeScript
import "server-only";
|
||
|
||
import { generateCanvasImage } from "@/lib/canvas/generate-image";
|
||
import {
|
||
getVideoStoryboardTaskDetail,
|
||
markVideoStoryboardAwaitingConfirmation,
|
||
markVideoStoryboardDraftForRevision,
|
||
markVideoStoryboardHdCompleted,
|
||
markVideoStoryboardHdFailed,
|
||
markVideoStoryboardHdRunning,
|
||
syncVideoStoryboardHdStageStatus,
|
||
updateVideoStoryboardShotPrompt,
|
||
} from "@/lib/video-agent/task-store";
|
||
import {
|
||
interpretStoryboardCommand,
|
||
parseStoryboardRevisionCommand as parseStoryboardRevisionCommandWithContext,
|
||
type StoryboardRevisionCommand,
|
||
} from "@/lib/video-agent/storyboard-command-interpreter";
|
||
import type {
|
||
StoryboardGridType,
|
||
StoryboardProgressEvent,
|
||
} from "@/lib/video-agent/types";
|
||
|
||
function normalizeText(value: unknown) {
|
||
return String(value || "").trim();
|
||
}
|
||
|
||
function buildAwaitingConfirmationEvent(params: {
|
||
taskId: string;
|
||
detail: NonNullable<Awaited<ReturnType<typeof getVideoStoryboardTaskDetail>>>;
|
||
message: string;
|
||
}): StoryboardProgressEvent {
|
||
const task = params.detail.task as typeof params.detail.task & {
|
||
gridType?: StoryboardGridType | null;
|
||
gridRows?: number | null;
|
||
gridCols?: number | null;
|
||
};
|
||
|
||
return {
|
||
stage: "awaiting_confirmation",
|
||
provider: "ruxa-video",
|
||
taskId: params.taskId,
|
||
totalShots: Number(task.totalShots || params.detail.shots.length || 0),
|
||
gridType: task.gridType || undefined,
|
||
rows: Number(task.gridRows || 0) || undefined,
|
||
cols: Number(task.gridCols || 0) || undefined,
|
||
message: params.message,
|
||
shots: params.detail.shots.map((shot: any) => ({
|
||
shotIndex: Number(shot.shotIndex || 0),
|
||
tileImageUrl: normalizeText(shot.tileImageUrl),
|
||
previewImageUrl:
|
||
normalizeText(shot.hdImageUrl) ||
|
||
normalizeText(shot.upscaledImageUrl) ||
|
||
normalizeText(shot.tileImageUrl),
|
||
prompt: normalizeText(shot.prompt),
|
||
})),
|
||
};
|
||
}
|
||
|
||
function inferPreviewAspectRatio(gridType?: StoryboardGridType | null): string {
|
||
switch (gridType) {
|
||
case "grid_2x2":
|
||
case "grid_3x2":
|
||
case "grid_3x3":
|
||
default:
|
||
return "1:1";
|
||
}
|
||
}
|
||
|
||
export function parseStoryboardRevisionCommand(params: {
|
||
text: string;
|
||
totalShots?: number;
|
||
}): StoryboardRevisionCommand {
|
||
return parseStoryboardRevisionCommandWithContext(params);
|
||
}
|
||
|
||
export async function resolveStoryboardRevisionCommand(params: {
|
||
text: string;
|
||
detail: NonNullable<Awaited<ReturnType<typeof getVideoStoryboardTaskDetail>>>;
|
||
}) {
|
||
const interpreted = await interpretStoryboardCommand({
|
||
text: params.text,
|
||
totalShots: Number(params.detail.task.totalShots || params.detail.shots.length || 0),
|
||
items: params.detail.shots.map((shot: any) => ({
|
||
index: Number(shot.shotIndex || 0),
|
||
title: Number(shot.shotIndex || 0) > 0 ? `镜头 ${Number(shot.shotIndex || 0)}` : undefined,
|
||
summary: normalizeText(shot.prompt),
|
||
})),
|
||
});
|
||
|
||
return interpreted.revision;
|
||
}
|
||
|
||
export async function reviseStoryboardTaskByCommand(params: {
|
||
taskId: string;
|
||
command: StoryboardRevisionCommand;
|
||
onProgress?: (event: StoryboardProgressEvent) => void | Promise<void>;
|
||
}) {
|
||
if (!params.command) {
|
||
return {
|
||
ok: false,
|
||
message: "我收到了修改意图,但还没定位到具体镜头。请直接回复例如“修改第3镜头:……”或“重生第2张高清图”。",
|
||
detail: await getVideoStoryboardTaskDetail(params.taskId),
|
||
};
|
||
}
|
||
|
||
const detail = await getVideoStoryboardTaskDetail(params.taskId);
|
||
if (!detail) {
|
||
return {
|
||
ok: false,
|
||
message: "当前分镜任务不存在或已失效。",
|
||
detail: null,
|
||
};
|
||
}
|
||
|
||
const targetShot = detail.shots.find(
|
||
(shot: any) => Number(shot.shotIndex || 0) === params.command?.shotIndex
|
||
);
|
||
if (!targetShot) {
|
||
return {
|
||
ok: false,
|
||
message: `没有找到第 ${params.command.shotIndex} 镜头,请检查镜头编号后再试。`,
|
||
detail,
|
||
};
|
||
}
|
||
|
||
await markVideoStoryboardDraftForRevision({
|
||
taskId: params.taskId,
|
||
});
|
||
|
||
if (params.command.type === "update_prompt") {
|
||
await updateVideoStoryboardShotPrompt({
|
||
taskId: params.taskId,
|
||
shotIndex: params.command.shotIndex,
|
||
prompt: params.command.prompt,
|
||
});
|
||
}
|
||
|
||
const latestDetail = await getVideoStoryboardTaskDetail(params.taskId);
|
||
const latestShot = latestDetail?.shots.find(
|
||
(shot: any) => Number(shot.shotIndex || 0) === params.command?.shotIndex
|
||
);
|
||
const currentPrompt =
|
||
params.command.type === "update_prompt"
|
||
? params.command.prompt
|
||
: normalizeText(latestShot?.prompt) || normalizeText(targetShot.prompt);
|
||
const inputImageUrl =
|
||
normalizeText(latestShot?.upscaledImageUrl) ||
|
||
normalizeText(latestShot?.tileImageUrl) ||
|
||
normalizeText(targetShot.upscaledImageUrl) ||
|
||
normalizeText(targetShot.tileImageUrl);
|
||
|
||
if (!inputImageUrl) {
|
||
return {
|
||
ok: false,
|
||
message: `第 ${params.command.shotIndex} 镜头缺少输入图,无法重生高清预览。`,
|
||
detail: latestDetail || detail,
|
||
};
|
||
}
|
||
|
||
try {
|
||
await markVideoStoryboardHdRunning({
|
||
taskId: params.taskId,
|
||
shotIndex: params.command.shotIndex,
|
||
});
|
||
|
||
const hdResult = await generateCanvasImage({
|
||
prompt: currentPrompt,
|
||
referenceImage: inputImageUrl,
|
||
imageCount: 1,
|
||
imageSize: "2K",
|
||
aspectRatio: inferPreviewAspectRatio(
|
||
(latestDetail?.task as any)?.gridType || (detail.task as any)?.gridType
|
||
),
|
||
});
|
||
|
||
const previewImageUrl =
|
||
hdResult.images[0]?.originalUrl || hdResult.images[0]?.thumbnailUrl || inputImageUrl;
|
||
|
||
await markVideoStoryboardHdCompleted({
|
||
taskId: params.taskId,
|
||
shotIndex: params.command.shotIndex,
|
||
hdImageUrl: previewImageUrl,
|
||
hdImagePrompt: currentPrompt,
|
||
});
|
||
|
||
await syncVideoStoryboardHdStageStatus(params.taskId);
|
||
await markVideoStoryboardAwaitingConfirmation({
|
||
taskId: params.taskId,
|
||
});
|
||
|
||
await params.onProgress?.({
|
||
stage: "hd_shot_completed",
|
||
provider: "ruxa-video",
|
||
taskId: params.taskId,
|
||
shotIndex: params.command.shotIndex,
|
||
imageUrl: previewImageUrl,
|
||
prompt: currentPrompt,
|
||
});
|
||
|
||
const refreshedDetail = await getVideoStoryboardTaskDetail(params.taskId);
|
||
if (refreshedDetail) {
|
||
const message =
|
||
params.command.type === "update_prompt"
|
||
? `已更新第 ${params.command.shotIndex} 镜头,并重新生成了高清预览,请继续确认。`
|
||
: `已重生第 ${params.command.shotIndex} 张高清预览,请继续确认。`;
|
||
|
||
await params.onProgress?.(
|
||
buildAwaitingConfirmationEvent({
|
||
taskId: params.taskId,
|
||
detail: refreshedDetail,
|
||
message,
|
||
})
|
||
);
|
||
|
||
return {
|
||
ok: true,
|
||
message,
|
||
detail: refreshedDetail,
|
||
};
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
message:
|
||
params.command.type === "update_prompt"
|
||
? `已更新第 ${params.command.shotIndex} 镜头,并重新生成高清预览。`
|
||
: `已重生第 ${params.command.shotIndex} 张高清预览。`,
|
||
detail: latestDetail || detail,
|
||
};
|
||
} catch (error) {
|
||
const errorMessage =
|
||
normalizeText(error instanceof Error ? error.message : String(error)) ||
|
||
"高清预览重生失败";
|
||
|
||
await markVideoStoryboardHdFailed({
|
||
taskId: params.taskId,
|
||
shotIndex: params.command.shotIndex,
|
||
errorMessage,
|
||
});
|
||
|
||
await params.onProgress?.({
|
||
stage: "hd_shot_failed",
|
||
provider: "ruxa-video",
|
||
taskId: params.taskId,
|
||
shotIndex: params.command.shotIndex,
|
||
error: errorMessage,
|
||
});
|
||
|
||
return {
|
||
ok: false,
|
||
message: `第 ${params.command.shotIndex} 镜头的高清预览重生失败:${errorMessage}`,
|
||
detail: await getVideoStoryboardTaskDetail(params.taskId),
|
||
};
|
||
}
|
||
}
|
||
|
||
export async function updateStoryboardShotPromptAndRegenerateHd(params: {
|
||
taskId: string;
|
||
shotIndex: number;
|
||
prompt: string;
|
||
onProgress?: (event: StoryboardProgressEvent) => void | Promise<void>;
|
||
}) {
|
||
return reviseStoryboardTaskByCommand({
|
||
taskId: params.taskId,
|
||
command: {
|
||
type: "update_prompt",
|
||
shotIndex: params.shotIndex,
|
||
prompt: params.prompt,
|
||
},
|
||
onProgress: params.onProgress,
|
||
});
|
||
}
|
||
|
||
export async function regenerateStoryboardShotHd(params: {
|
||
taskId: string;
|
||
shotIndex: number;
|
||
onProgress?: (event: StoryboardProgressEvent) => void | Promise<void>;
|
||
}) {
|
||
return reviseStoryboardTaskByCommand({
|
||
taskId: params.taskId,
|
||
command: {
|
||
type: "regenerate_hd",
|
||
shotIndex: params.shotIndex,
|
||
},
|
||
onProgress: params.onProgress,
|
||
});
|
||
}
|