feat: 交互式对话框生成器 + 电商取消生成与上传优化
新增: - 交互式对话框生成器模块(路由、页面、样式、MorePage入口) - 电商模块取消生成功能(任务追踪/取消按钮/中止逻辑) - 视频服务图片上传支持 Blob/dataURL/远程URL 多种来源 优化: - 电商图片上传修复本地 blob 预览图缺少原始文件的问题 - 视频规划管线错误信息改进 - 生成流程中多处增加中止检查点
This commit is contained in:
@@ -30,13 +30,61 @@ export interface PlanCallbacks {
|
||||
resumeFrom?: EcommerceVideoPlanProgress;
|
||||
}
|
||||
|
||||
const LOCAL_PREVIEW_MISSING_FILE_MESSAGE = "Please re-upload the product image before generating the short video.";
|
||||
|
||||
function readBlobAsDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(reader.error || new Error("File read failed"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRemoteImageUrl(source: string): string | null {
|
||||
try {
|
||||
const url = new URL(source, typeof window !== "undefined" ? window.location.href : undefined);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadProductImageSource(source: string | Blob): Promise<string> {
|
||||
if (typeof source === "string") {
|
||||
if (source.startsWith("blob:")) {
|
||||
throw new Error(LOCAL_PREVIEW_MISSING_FILE_MESSAGE);
|
||||
}
|
||||
|
||||
if (source.startsWith("data:")) {
|
||||
const mimeType = normalizeEcommerceImageMime(source.match(/^data:([^;,]+)/)?.[1] || "image/png");
|
||||
const result = await aiGenerationClient.uploadAsset({ dataUrl: source, mimeType, scope: "ecommerce-product" });
|
||||
return result.url;
|
||||
}
|
||||
|
||||
const remoteUrl = normalizeRemoteImageUrl(source);
|
||||
if (remoteUrl) {
|
||||
const result = await aiGenerationClient.uploadAssetByUrl({ sourceUrl: remoteUrl, scope: "ecommerce-product" });
|
||||
return result.url;
|
||||
}
|
||||
|
||||
throw new Error("Unsupported product image URL. Please re-upload the product image.");
|
||||
}
|
||||
|
||||
const mimeType = normalizeEcommerceImageMime(source.type || "image/png");
|
||||
const blob = source.type === mimeType ? source : new Blob([source], { type: mimeType });
|
||||
const dataUrl = await readBlobAsDataUrl(blob);
|
||||
const result = await aiGenerationClient.uploadAsset({ dataUrl, mimeType, scope: "ecommerce-product" });
|
||||
return result.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full ad video planning pipeline.
|
||||
* Supports resumption: if `resumeFrom` contains data for a step, that step is skipped.
|
||||
* After each step, `onPartialProgress` fires so callers can persist intermediate state.
|
||||
*/
|
||||
export async function runVideoPlan(
|
||||
imageDataUrls: string[],
|
||||
imageSources: Array<string | Blob>,
|
||||
manualText: string,
|
||||
config: AdVideoUserConfig,
|
||||
callbacks: PlanCallbacks,
|
||||
@@ -45,41 +93,30 @@ export async function runVideoPlan(
|
||||
const progress: EcommerceVideoPlanProgress = { ...resumeFrom };
|
||||
const emit = () => callbacks.onPartialProgress?.({ ...progress });
|
||||
|
||||
// ── Step: upload ──────────────────────────────────────
|
||||
// Step: upload
|
||||
if (!progress.imageUrls?.length) {
|
||||
onStepStart("upload");
|
||||
const imageUrls: string[] = [];
|
||||
const rejected: string[] = [];
|
||||
for (const srcUrl of imageDataUrls) {
|
||||
for (const source of imageSources) {
|
||||
try {
|
||||
const resp = await fetch(srcUrl);
|
||||
const rawBlob = await resp.blob();
|
||||
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
|
||||
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
const result = await aiGenerationClient.uploadAsset({ dataUrl, mimeType, scope: "ecommerce-product" });
|
||||
imageUrls.push(result.url);
|
||||
imageUrls.push(await uploadProductImageSource(source));
|
||||
} catch (err) {
|
||||
rejected.push(err instanceof Error ? err.message : "图片上传失败");
|
||||
rejected.push(err instanceof Error ? err.message : "Image upload failed");
|
||||
}
|
||||
}
|
||||
if (rejected.length) {
|
||||
progress.uploadWarnings = rejected;
|
||||
callbacks.onUploadRejected?.(rejected);
|
||||
}
|
||||
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
|
||||
if (!imageUrls.length) throw new Error("Image upload failed. Please check the image format or network and try again.");
|
||||
progress.imageUrls = imageUrls;
|
||||
onStepDone("upload");
|
||||
callbacks.onImagesUploaded?.(imageUrls);
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: analyze ─────────────────────────────────────
|
||||
// Step: analyze
|
||||
if (progress.imageDescription === undefined) {
|
||||
onStepStart("analyze");
|
||||
progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal);
|
||||
@@ -87,7 +124,7 @@ export async function runVideoPlan(
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: summary ─────────────────────────────────────
|
||||
// Step: summary
|
||||
if (!progress.summary) {
|
||||
onStepStart("summary");
|
||||
progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal);
|
||||
@@ -95,7 +132,7 @@ export async function runVideoPlan(
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: selling ─────────────────────────────────────
|
||||
// Step: selling
|
||||
if (!progress.selling) {
|
||||
onStepStart("selling");
|
||||
progress.selling = await extractSellingPoints(progress.summary, signal);
|
||||
@@ -103,16 +140,16 @@ export async function runVideoPlan(
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: creative ────────────────────────────────────
|
||||
// Step: creative
|
||||
if (!progress.creatives?.length) {
|
||||
onStepStart("creative");
|
||||
progress.creatives = await generateCreativeOptions(progress.selling, config, signal);
|
||||
if (!progress.creatives.length) throw new Error("未能生成有效的广告创意");
|
||||
if (!progress.creatives.length) throw new Error("Failed to generate valid ad creatives.");
|
||||
onStepDone("creative");
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: storyboard ──────────────────────────────────
|
||||
// Step: storyboard
|
||||
if (!progress.storyboard) {
|
||||
onStepStart("storyboard");
|
||||
progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal);
|
||||
@@ -120,7 +157,7 @@ export async function runVideoPlan(
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: prompts ─────────────────────────────────────
|
||||
// Step: prompts
|
||||
if (!progress.videoPrompts) {
|
||||
onStepStart("prompts");
|
||||
progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal);
|
||||
@@ -128,7 +165,7 @@ export async function runVideoPlan(
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: compliance ──────────────────────────────────
|
||||
// Step: compliance
|
||||
if (!progress.compliance) {
|
||||
onStepStart("compliance");
|
||||
progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal);
|
||||
@@ -185,7 +222,7 @@ export async function renderSceneImage(
|
||||
if (resultUrl) {
|
||||
callbacks.onSceneImageCompleted(input.sceneId, resultUrl);
|
||||
} else {
|
||||
callbacks.onSceneImageFailed(input.sceneId, "图片生成未返回结果");
|
||||
callbacks.onSceneImageFailed(input.sceneId, "Image generation returned no result.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +277,7 @@ export async function renderScene(
|
||||
if (resultUrl) {
|
||||
callbacks.onSceneCompleted(input.sceneId, resultUrl);
|
||||
} else {
|
||||
callbacks.onSceneFailed(input.sceneId, "任务未返回结果");
|
||||
callbacks.onSceneFailed(input.sceneId, "Task returned no result.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +296,7 @@ export function buildSceneTasks(
|
||||
});
|
||||
}
|
||||
|
||||
// ── Video History API ──────────────────────────────────
|
||||
// Video History API
|
||||
|
||||
export interface VideoHistoryScene {
|
||||
sceneId: number;
|
||||
@@ -305,7 +342,7 @@ export async function saveVideoHistory(payload: {
|
||||
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error("保存历史记录失败");
|
||||
if (!res.ok) throw new Error("Failed to save video history");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -317,7 +354,7 @@ export async function fetchVideoHistory(
|
||||
`${API_BASE}?limit=${limit}&offset=${offset}`,
|
||||
{ headers: getAuthHeaders() },
|
||||
);
|
||||
if (!res.ok) throw new Error("获取历史记录失败");
|
||||
if (!res.ok) throw new Error("Failed to fetch video history");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -326,5 +363,5 @@ export async function deleteVideoHistory(id: number): Promise<void> {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error("删除失败");
|
||||
if (!res.ok) throw new Error("Failed to delete video history");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user