2026-06-02 12:38:01 +08:00
|
|
|
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";
|
2026-06-03 20:19:07 +08:00
|
|
|
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
|
2026-06-02 12:38:01 +08:00
|
|
|
import type {
|
2026-06-03 12:16:33 +08:00
|
|
|
EcommerceVideoPlanProgress,
|
2026-06-02 12:38:01 +08:00
|
|
|
EcommerceVideoPlanResult,
|
|
|
|
|
EcommerceVideoSceneTask,
|
|
|
|
|
PlanStep,
|
|
|
|
|
} from "./ecommerceVideoTypes";
|
|
|
|
|
|
2026-06-04 18:27:12 +08:00
|
|
|
type UploadAssetByUrl = typeof aiGenerationClient.uploadAssetByUrl;
|
|
|
|
|
|
|
|
|
|
interface DurableMediaUrl {
|
|
|
|
|
url: string | null;
|
|
|
|
|
originalUrl?: string | null;
|
|
|
|
|
ossKey?: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const TEMP_MEDIA_HOST_RE = /^file\d*\.aitohumanize\.com$/i;
|
|
|
|
|
const OSS_MEDIA_HOST_RE = /\.oss-[^.]+\.aliyuncs\.com$/i;
|
|
|
|
|
|
|
|
|
|
function isTemporaryProviderUrl(url: string): boolean {
|
|
|
|
|
try {
|
|
|
|
|
return TEMP_MEDIA_HOST_RE.test(new URL(url).hostname);
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isDurableOssUrl(url: string): boolean {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = new URL(url);
|
|
|
|
|
return parsed.protocol === "https:" && OSS_MEDIA_HOST_RE.test(parsed.hostname);
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getMediaExtension(url: string, mimeType: string): string {
|
|
|
|
|
const normalizedMime = mimeType.split(";")[0]?.trim().toLowerCase();
|
|
|
|
|
if (normalizedMime === "image/jpeg") return "jpg";
|
|
|
|
|
if (normalizedMime === "image/png") return "png";
|
|
|
|
|
if (normalizedMime === "image/webp") return "webp";
|
|
|
|
|
if (normalizedMime === "image/gif") return "gif";
|
|
|
|
|
if (normalizedMime === "video/mp4") return "mp4";
|
|
|
|
|
if (normalizedMime === "video/webm") return "webm";
|
|
|
|
|
if (normalizedMime === "video/quicktime") return "mov";
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const matched = new URL(url).pathname.match(/\.([a-z0-9]{2,5})$/i);
|
|
|
|
|
if (matched?.[1]) return matched[1].toLowerCase();
|
|
|
|
|
} catch {
|
|
|
|
|
// Keep mime fallback below.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return mimeType.startsWith("video/") ? "mp4" : "png";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildDurableMediaName(prefix: string, url: string, mimeType: string): string {
|
|
|
|
|
const normalized = prefix
|
|
|
|
|
.trim()
|
|
|
|
|
.replace(/[\\/:*?"<>|]+/g, "-")
|
|
|
|
|
.replace(/\s+/g, " ")
|
|
|
|
|
.slice(0, 80)
|
|
|
|
|
.trim();
|
|
|
|
|
return `${normalized || "ecommerce-video-media"}.${getMediaExtension(url, mimeType)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function resolveDurableMediaUrl(
|
|
|
|
|
url: string | null | undefined,
|
|
|
|
|
options: {
|
|
|
|
|
mediaType: "image" | "video";
|
|
|
|
|
namePrefix: string;
|
|
|
|
|
scope?: string;
|
|
|
|
|
uploadAssetByUrl?: UploadAssetByUrl;
|
|
|
|
|
},
|
|
|
|
|
): Promise<DurableMediaUrl> {
|
|
|
|
|
const sourceUrl = String(url || "").trim();
|
|
|
|
|
if (!sourceUrl) return { url: null };
|
|
|
|
|
if (isDurableOssUrl(sourceUrl)) return { url: sourceUrl };
|
|
|
|
|
|
|
|
|
|
const mimeType = options.mediaType === "video" ? "video/mp4" : "image/png";
|
|
|
|
|
const uploadAssetByUrl = options.uploadAssetByUrl || aiGenerationClient.uploadAssetByUrl.bind(aiGenerationClient);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const uploaded = await uploadAssetByUrl({
|
|
|
|
|
sourceUrl,
|
|
|
|
|
name: buildDurableMediaName(options.namePrefix, sourceUrl, mimeType),
|
|
|
|
|
mimeType,
|
|
|
|
|
scope: options.scope || "ecommerce-video-history",
|
|
|
|
|
});
|
|
|
|
|
return {
|
|
|
|
|
url: uploaded.url || null,
|
|
|
|
|
originalUrl: sourceUrl,
|
|
|
|
|
ossKey: uploaded.ossKey || null,
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message = error instanceof Error ? error.message : String(error || "");
|
|
|
|
|
console.warn("[ecommerce-video] history media persistence failed:", message);
|
|
|
|
|
if (isTemporaryProviderUrl(sourceUrl)) {
|
|
|
|
|
return { url: null, originalUrl: sourceUrl };
|
|
|
|
|
}
|
|
|
|
|
return { url: sourceUrl };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 12:38:01 +08:00
|
|
|
export interface PlanCallbacks {
|
|
|
|
|
onStepStart: (step: PlanStep) => void;
|
|
|
|
|
onStepDone: (step: PlanStep) => void;
|
2026-06-03 01:39:06 +08:00
|
|
|
onImagesUploaded?: (urls: string[]) => void;
|
2026-06-03 20:19:07 +08:00
|
|
|
onUploadRejected?: (messages: string[]) => void;
|
2026-06-03 12:16:33 +08:00
|
|
|
onPartialProgress?: (progress: EcommerceVideoPlanProgress) => void;
|
2026-06-02 12:38:01 +08:00
|
|
|
signal?: AbortSignal;
|
2026-06-03 12:16:33 +08:00
|
|
|
/** Partial state from a previous run; steps with existing data are skipped. */
|
|
|
|
|
resumeFrom?: EcommerceVideoPlanProgress;
|
2026-06-02 12:38:01 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-03 12:16:33 +08:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-06-02 12:38:01 +08:00
|
|
|
export async function runVideoPlan(
|
|
|
|
|
imageDataUrls: string[],
|
|
|
|
|
manualText: string,
|
|
|
|
|
config: AdVideoUserConfig,
|
|
|
|
|
callbacks: PlanCallbacks,
|
|
|
|
|
): Promise<EcommerceVideoPlanResult> {
|
2026-06-03 12:16:33 +08:00
|
|
|
const { onStepStart, onStepDone, signal, resumeFrom = {} } = callbacks;
|
|
|
|
|
const progress: EcommerceVideoPlanProgress = { ...resumeFrom };
|
|
|
|
|
const emit = () => callbacks.onPartialProgress?.({ ...progress });
|
2026-06-02 12:38:01 +08:00
|
|
|
|
2026-06-03 12:16:33 +08:00
|
|
|
// ── Step: upload ──────────────────────────────────────
|
|
|
|
|
if (!progress.imageUrls?.length) {
|
|
|
|
|
onStepStart("upload");
|
|
|
|
|
const imageUrls: string[] = [];
|
2026-06-03 20:19:07 +08:00
|
|
|
const rejected: string[] = [];
|
2026-06-03 12:16:33 +08:00
|
|
|
for (const srcUrl of imageDataUrls) {
|
|
|
|
|
try {
|
|
|
|
|
const resp = await fetch(srcUrl);
|
|
|
|
|
const rawBlob = await resp.blob();
|
2026-06-03 20:19:07 +08:00
|
|
|
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
|
2026-06-03 12:16:33 +08:00
|
|
|
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
|
2026-06-03 20:19:07 +08:00
|
|
|
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" });
|
2026-06-03 12:16:33 +08:00
|
|
|
imageUrls.push(result.url);
|
2026-06-03 20:19:07 +08:00
|
|
|
} catch (err) {
|
|
|
|
|
rejected.push(err instanceof Error ? err.message : "图片上传失败");
|
2026-06-03 12:16:33 +08:00
|
|
|
}
|
2026-06-02 16:16:09 +08:00
|
|
|
}
|
2026-06-03 20:19:07 +08:00
|
|
|
if (rejected.length) {
|
|
|
|
|
progress.uploadWarnings = rejected;
|
|
|
|
|
callbacks.onUploadRejected?.(rejected);
|
|
|
|
|
}
|
2026-06-03 12:16:33 +08:00
|
|
|
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
|
|
|
|
|
progress.imageUrls = imageUrls;
|
|
|
|
|
onStepDone("upload");
|
|
|
|
|
callbacks.onImagesUploaded?.(imageUrls);
|
|
|
|
|
emit();
|
2026-06-02 12:38:01 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-03 12:16:33 +08:00
|
|
|
// ── Step: analyze ─────────────────────────────────────
|
|
|
|
|
if (progress.imageDescription === undefined) {
|
|
|
|
|
onStepStart("analyze");
|
|
|
|
|
progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal);
|
|
|
|
|
onStepDone("analyze");
|
|
|
|
|
emit();
|
|
|
|
|
}
|
2026-06-02 12:38:01 +08:00
|
|
|
|
2026-06-03 12:16:33 +08:00
|
|
|
// ── Step: summary ─────────────────────────────────────
|
|
|
|
|
if (!progress.summary) {
|
|
|
|
|
onStepStart("summary");
|
|
|
|
|
progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal);
|
|
|
|
|
onStepDone("summary");
|
|
|
|
|
emit();
|
|
|
|
|
}
|
2026-06-02 12:38:01 +08:00
|
|
|
|
2026-06-03 12:16:33 +08:00
|
|
|
// ── Step: selling ─────────────────────────────────────
|
|
|
|
|
if (!progress.selling) {
|
|
|
|
|
onStepStart("selling");
|
|
|
|
|
progress.selling = await extractSellingPoints(progress.summary, signal);
|
|
|
|
|
onStepDone("selling");
|
|
|
|
|
emit();
|
|
|
|
|
}
|
2026-06-02 12:38:01 +08:00
|
|
|
|
2026-06-03 12:16:33 +08:00
|
|
|
// ── Step: creative ────────────────────────────────────
|
|
|
|
|
if (!progress.creatives?.length) {
|
|
|
|
|
onStepStart("creative");
|
|
|
|
|
progress.creatives = await generateCreativeOptions(progress.selling, config, signal);
|
|
|
|
|
if (!progress.creatives.length) throw new Error("未能生成有效的广告创意");
|
|
|
|
|
onStepDone("creative");
|
|
|
|
|
emit();
|
|
|
|
|
}
|
2026-06-02 12:38:01 +08:00
|
|
|
|
2026-06-03 12:16:33 +08:00
|
|
|
// ── Step: storyboard ──────────────────────────────────
|
|
|
|
|
if (!progress.storyboard) {
|
|
|
|
|
onStepStart("storyboard");
|
|
|
|
|
progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal);
|
|
|
|
|
onStepDone("storyboard");
|
|
|
|
|
emit();
|
|
|
|
|
}
|
2026-06-02 12:38:01 +08:00
|
|
|
|
2026-06-03 12:16:33 +08:00
|
|
|
// ── Step: prompts ─────────────────────────────────────
|
|
|
|
|
if (!progress.videoPrompts) {
|
|
|
|
|
onStepStart("prompts");
|
|
|
|
|
progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal);
|
|
|
|
|
onStepDone("prompts");
|
|
|
|
|
emit();
|
|
|
|
|
}
|
2026-06-02 12:38:01 +08:00
|
|
|
|
2026-06-03 12:16:33 +08:00
|
|
|
// ── Step: compliance ──────────────────────────────────
|
|
|
|
|
if (!progress.compliance) {
|
|
|
|
|
onStepStart("compliance");
|
|
|
|
|
progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal);
|
|
|
|
|
onStepDone("compliance");
|
|
|
|
|
emit();
|
|
|
|
|
}
|
2026-06-02 12:38:01 +08:00
|
|
|
|
2026-06-03 12:16:33 +08:00
|
|
|
return {
|
|
|
|
|
imageUrls: progress.imageUrls!,
|
|
|
|
|
imageDescription: progress.imageDescription,
|
|
|
|
|
summary: progress.summary!,
|
|
|
|
|
selling: progress.selling!,
|
|
|
|
|
creatives: progress.creatives!,
|
|
|
|
|
storyboard: progress.storyboard!,
|
|
|
|
|
videoPrompts: progress.videoPrompts!,
|
|
|
|
|
compliance: progress.compliance!,
|
|
|
|
|
};
|
2026-06-02 12:38:01 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-03 01:39:06 +08:00
|
|
|
export interface RenderSceneImageInput {
|
|
|
|
|
sceneId: number;
|
|
|
|
|
prompt: string;
|
|
|
|
|
aspectRatio: string;
|
2026-06-03 23:52:25 +08:00
|
|
|
productImageUrls: string[];
|
2026-06-03 01:39:06 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface RenderImageCallbacks {
|
|
|
|
|
onSceneImageSubmitted: (sceneId: number, taskId: string) => void;
|
|
|
|
|
onSceneImageProgress: (sceneId: number, progress: number) => void;
|
|
|
|
|
onSceneImageCompleted: (sceneId: number, resultUrl: string) => void;
|
|
|
|
|
onSceneImageFailed: (sceneId: number, error: string) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function renderSceneImage(
|
|
|
|
|
input: RenderSceneImageInput,
|
|
|
|
|
callbacks: RenderImageCallbacks,
|
|
|
|
|
abortRef: { current: boolean },
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const { taskId } = await aiGenerationClient.createImageTask({
|
|
|
|
|
model: "gpt-image-2",
|
|
|
|
|
prompt: input.prompt,
|
|
|
|
|
ratio: input.aspectRatio,
|
|
|
|
|
quality: "2K",
|
2026-06-03 23:52:25 +08:00
|
|
|
referenceUrls: input.productImageUrls,
|
2026-06-03 01:39:06 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
callbacks.onSceneImageSubmitted(input.sceneId, taskId);
|
|
|
|
|
|
|
|
|
|
const resultUrl = await waitForTask(taskId, {
|
|
|
|
|
abortRef,
|
|
|
|
|
onProgress: (e) => callbacks.onSceneImageProgress(input.sceneId, e.progress),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (resultUrl) {
|
|
|
|
|
callbacks.onSceneImageCompleted(input.sceneId, resultUrl);
|
|
|
|
|
} else {
|
|
|
|
|
callbacks.onSceneImageFailed(input.sceneId, "图片生成未返回结果");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 12:38:01 +08:00
|
|
|
export interface RenderSceneInput {
|
|
|
|
|
sceneId: number;
|
|
|
|
|
prompt: string;
|
|
|
|
|
durationSeconds: number;
|
|
|
|
|
imageUrl: string;
|
2026-06-03 23:52:25 +08:00
|
|
|
productImageUrls: string[];
|
2026-06-02 12:38:01 +08:00
|
|
|
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> {
|
2026-06-03 23:52:25 +08:00
|
|
|
const allReferenceUrls = [...input.productImageUrls, input.imageUrl];
|
2026-06-02 12:38:01 +08:00
|
|
|
const model = resolveVideoRequestModel({
|
|
|
|
|
model: input.model || "happyhorse-1.0",
|
2026-06-03 23:52:25 +08:00
|
|
|
referenceUrls: allReferenceUrls,
|
2026-06-02 12:38:01 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { taskId } = await aiGenerationClient.createVideoTask({
|
|
|
|
|
model,
|
|
|
|
|
prompt: input.prompt,
|
|
|
|
|
ratio: input.aspectRatio,
|
|
|
|
|
duration: input.durationSeconds,
|
|
|
|
|
quality: input.resolution,
|
|
|
|
|
resolution: input.resolution,
|
2026-06-03 01:39:06 +08:00
|
|
|
frameMode: "start-end",
|
2026-06-03 23:52:25 +08:00
|
|
|
referenceUrls: allReferenceUrls,
|
2026-06-02 12:38:01 +08:00
|
|
|
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) => {
|
2026-06-03 01:39:06 +08:00
|
|
|
const matchedPrompt = plan.videoPrompts.find((p) => p.scene_id === scene.scene_id);
|
2026-06-02 12:38:01 +08:00
|
|
|
return {
|
|
|
|
|
sceneId: scene.scene_id,
|
2026-06-03 01:39:06 +08:00
|
|
|
prompt: matchedPrompt?.positive_prompt || scene.visual_description,
|
2026-06-02 12:38:01 +08:00
|
|
|
durationSeconds: Number.parseInt(scene.duration, 10) || 5,
|
2026-06-03 01:39:06 +08:00
|
|
|
status: "idle" as const,
|
2026-06-02 12:38:01 +08:00
|
|
|
progress: 0,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-04 01:12:51 +08:00
|
|
|
|
|
|
|
|
// ── Video History API ──────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface VideoHistoryScene {
|
|
|
|
|
sceneId: number;
|
|
|
|
|
prompt: string;
|
|
|
|
|
imageUrl?: string | null;
|
|
|
|
|
videoUrl?: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 18:27:12 +08:00
|
|
|
interface SaveVideoHistoryPayload {
|
|
|
|
|
title: string;
|
|
|
|
|
config: Record<string, unknown>;
|
|
|
|
|
plan: Record<string, unknown>;
|
|
|
|
|
scenes: VideoHistoryScene[];
|
|
|
|
|
sourceImageUrls: string[];
|
|
|
|
|
uploadAssetByUrl?: UploadAssetByUrl;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 01:12:51 +08:00
|
|
|
export interface VideoHistoryItem {
|
|
|
|
|
id: number;
|
|
|
|
|
title: string;
|
|
|
|
|
config: Record<string, unknown>;
|
|
|
|
|
scenes: VideoHistoryScene[];
|
|
|
|
|
sourceImageUrls: string[];
|
|
|
|
|
createdAt: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface VideoHistoryListResponse {
|
|
|
|
|
items: VideoHistoryItem[];
|
|
|
|
|
total: number;
|
|
|
|
|
limit: number;
|
|
|
|
|
offset: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
import { getStoredToken } from "../../api/serverConnection";
|
|
|
|
|
|
|
|
|
|
const API_BASE = "/api/ai/ecommerce/video-history";
|
|
|
|
|
|
|
|
|
|
function getAuthHeaders(): Record<string, string> {
|
|
|
|
|
const token = getStoredToken();
|
|
|
|
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 18:27:12 +08:00
|
|
|
export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
|
|
|
|
|
const uploadAssetByUrl = payload.uploadAssetByUrl;
|
|
|
|
|
const scenes = await Promise.all(
|
|
|
|
|
payload.scenes.map(async (scene) => {
|
|
|
|
|
const [image, video] = await Promise.all([
|
|
|
|
|
resolveDurableMediaUrl(scene.imageUrl, {
|
|
|
|
|
mediaType: "image",
|
|
|
|
|
namePrefix: `ecommerce-scene-${scene.sceneId}-image`,
|
|
|
|
|
uploadAssetByUrl,
|
|
|
|
|
}),
|
|
|
|
|
resolveDurableMediaUrl(scene.videoUrl, {
|
|
|
|
|
mediaType: "video",
|
|
|
|
|
namePrefix: `ecommerce-scene-${scene.sceneId}-video`,
|
|
|
|
|
uploadAssetByUrl,
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
return {
|
|
|
|
|
...scene,
|
|
|
|
|
imageUrl: image.url,
|
|
|
|
|
videoUrl: video.url,
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const sourceImageUrls = (
|
|
|
|
|
await Promise.all(
|
|
|
|
|
payload.sourceImageUrls.map((url, index) =>
|
|
|
|
|
resolveDurableMediaUrl(url, {
|
|
|
|
|
mediaType: "image",
|
|
|
|
|
namePrefix: `ecommerce-source-${index + 1}`,
|
|
|
|
|
uploadAssetByUrl,
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.map((item) => item.url)
|
|
|
|
|
.filter((url): url is string => Boolean(url));
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...payload,
|
|
|
|
|
scenes,
|
|
|
|
|
sourceImageUrls,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> {
|
|
|
|
|
const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload);
|
2026-06-04 01:12:51 +08:00
|
|
|
const res = await fetch(API_BASE, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
2026-06-04 18:27:12 +08:00
|
|
|
body: JSON.stringify(historyPayload),
|
2026-06-04 01:12:51 +08:00
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error("保存历史记录失败");
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 18:27:12 +08:00
|
|
|
function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem {
|
|
|
|
|
return {
|
|
|
|
|
...item,
|
|
|
|
|
scenes: item.scenes.map((scene) => ({
|
|
|
|
|
...scene,
|
|
|
|
|
imageUrl: scene.imageUrl && !isTemporaryProviderUrl(scene.imageUrl) ? scene.imageUrl : null,
|
|
|
|
|
videoUrl: scene.videoUrl && !isTemporaryProviderUrl(scene.videoUrl) ? scene.videoUrl : null,
|
|
|
|
|
})),
|
|
|
|
|
sourceImageUrls: item.sourceImageUrls.filter((url) => !isTemporaryProviderUrl(url)),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 01:12:51 +08:00
|
|
|
export async function fetchVideoHistory(
|
|
|
|
|
limit = 20,
|
|
|
|
|
offset = 0,
|
|
|
|
|
): Promise<VideoHistoryListResponse> {
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
`${API_BASE}?limit=${limit}&offset=${offset}`,
|
|
|
|
|
{ headers: getAuthHeaders() },
|
|
|
|
|
);
|
|
|
|
|
if (!res.ok) throw new Error("获取历史记录失败");
|
2026-06-04 18:27:12 +08:00
|
|
|
const history = (await res.json()) as VideoHistoryListResponse;
|
|
|
|
|
return {
|
|
|
|
|
...history,
|
|
|
|
|
items: history.items.map(removeTemporaryHistoryUrls),
|
|
|
|
|
};
|
2026-06-04 01:12:51 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function deleteVideoHistory(id: number): Promise<void> {
|
|
|
|
|
const res = await fetch(`${API_BASE}/${id}`, {
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
headers: getAuthHeaders(),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error("删除失败");
|
|
|
|
|
}
|