Files
omniai-web/src/features/ecommerce/ecommerceVideoService.ts
T
stringadmin 9504f8ee87 fix(ecommerce): replace base64 upload with binary blob in video service
runVideoPlan was passing blob URLs as "dataUrl" to uploadAssetWithProgress,
which sent them to /api/oss/upload (base64 path). Blob URLs don't match
DATA_URL_PATTERN regex, causing corrupt 44-byte files on OSS.

Now uses uploadAssetBinary (FormData multipart) via /api/oss/upload-binary,
fetching blob → uploading binary directly, same as EcommercePage path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 16:16:09 +08:00

150 lines
4.5 KiB
TypeScript

import {
analyzeProductImages,
buildProductSummary,
extractSellingPoints,
generateCreativeOptions,
generateStoryboard,
generateVideoPrompts,
checkCompliance,
type AdVideoUserConfig,
} from "../../api/adVideoPlanClient";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import type {
EcommerceVideoPlanResult,
EcommerceVideoSceneTask,
PlanStep,
} from "./ecommerceVideoTypes";
export interface PlanCallbacks {
onStepStart: (step: PlanStep) => void;
onStepDone: (step: PlanStep) => void;
signal?: AbortSignal;
}
export async function runVideoPlan(
imageDataUrls: string[],
manualText: string,
config: AdVideoUserConfig,
callbacks: PlanCallbacks,
): Promise<EcommerceVideoPlanResult> {
const { onStepStart, onStepDone, signal } = callbacks;
onStepStart("upload");
const imageUrls: string[] = [];
const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
for (const srcUrl of imageDataUrls) {
try {
const resp = await fetch(srcUrl);
const rawBlob = await resp.blob();
const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png";
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const result = await aiGenerationClient.uploadAssetBinary(blob, { mimeType, scope: "ecommerce-product" });
imageUrls.push(result.url);
} catch {
// skip images that fail to upload
}
}
onStepDone("upload");
onStepStart("analyze");
const imageDesc = await analyzeProductImages(imageUrls, signal);
onStepDone("analyze");
onStepStart("summary");
const summary = await buildProductSummary(imageDesc, manualText, signal);
onStepDone("summary");
onStepStart("selling");
const selling = await extractSellingPoints(summary, signal);
onStepDone("selling");
onStepStart("creative");
const creatives = await generateCreativeOptions(selling, config, signal);
if (!creatives.length) throw new Error("未能生成有效的广告创意");
onStepDone("creative");
onStepStart("storyboard");
const storyboard = await generateStoryboard(creatives[0], summary, config, signal);
onStepDone("storyboard");
onStepStart("prompts");
const videoPrompts = await generateVideoPrompts(storyboard, summary, signal);
onStepDone("prompts");
onStepStart("compliance");
const compliance = await checkCompliance(summary, selling, storyboard, signal);
onStepDone("compliance");
return { summary, selling, creatives, storyboard, videoPrompts, compliance };
}
export interface RenderSceneInput {
sceneId: number;
prompt: string;
durationSeconds: number;
imageUrl: string;
aspectRatio: string;
resolution: string;
model?: string;
}
export interface RenderCallbacks {
onSceneSubmitted: (sceneId: number, taskId: string) => void;
onSceneProgress: (sceneId: number, progress: number) => void;
onSceneCompleted: (sceneId: number, resultUrl: string) => void;
onSceneFailed: (sceneId: number, error: string) => void;
}
export async function renderScene(
input: RenderSceneInput,
callbacks: RenderCallbacks,
abortRef: { current: boolean },
): Promise<void> {
const model = resolveVideoRequestModel({
model: input.model || "happyhorse-1.0",
referenceUrls: [input.imageUrl],
});
const { taskId } = await aiGenerationClient.createVideoTask({
model,
prompt: input.prompt,
ratio: input.aspectRatio,
duration: input.durationSeconds,
quality: input.resolution,
resolution: input.resolution,
imageUrl: input.imageUrl,
referenceUrls: [input.imageUrl],
hasReferenceVideo: false,
});
callbacks.onSceneSubmitted(input.sceneId, taskId);
const resultUrl = await waitForTask(taskId, {
abortRef,
onProgress: (e) => callbacks.onSceneProgress(input.sceneId, e.progress),
});
if (resultUrl) {
callbacks.onSceneCompleted(input.sceneId, resultUrl);
} else {
callbacks.onSceneFailed(input.sceneId, "任务未返回结果");
}
}
export function buildSceneTasks(
plan: EcommerceVideoPlanResult,
): EcommerceVideoSceneTask[] {
return plan.storyboard.scenes.map((scene) => {
const prompt = plan.videoPrompts.find((p) => p.scene_id === scene.scene_id);
return {
sceneId: scene.scene_id,
prompt: prompt?.positive_prompt || scene.visual_description,
durationSeconds: Number.parseInt(scene.duration, 10) || 5,
status: "idle",
progress: 0,
};
});
}