976 lines
42 KiB
TypeScript
976 lines
42 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
AlertCircle,
|
||
ArrowLeft,
|
||
Boxes,
|
||
Clapperboard,
|
||
ChevronRight,
|
||
ImagePlus,
|
||
RefreshCw,
|
||
ScrollText,
|
||
SlidersHorizontal,
|
||
Sparkles,
|
||
Play,
|
||
Upload,
|
||
WandSparkles,
|
||
X,
|
||
Columns2,
|
||
Download,
|
||
ArrowUpRight,
|
||
} from "lucide-react";
|
||
import { api, ApiError, type QuickCreateJob } from "../api";
|
||
import { ConfirmModal, OverlayPortal, useBodyScrollLock } from "../components/overlays";
|
||
import { CustomSelect } from "../components/custom-select";
|
||
import { useFileDrop } from "../components/use-file-drop";
|
||
import { forgetQuickCreateJob, readQuickCreateJobId, rememberQuickCreateJob, stripQuickCreateSuffix } from "../quick-create-lock";
|
||
import type { ModelConfig } from "../types";
|
||
import {
|
||
DEFAULT_BILLING_RATES,
|
||
estimateCost,
|
||
FC_MODELS,
|
||
modelLabel,
|
||
pointsPerCallFromCatalog,
|
||
pointsPerImageFromCatalog,
|
||
type BillingRates,
|
||
} from "../components/free-create/constants";
|
||
import type { NavigateFn } from "./route-config";
|
||
import {
|
||
PC_CAT_MORE,
|
||
PC_CAT_MORE_LABEL,
|
||
PC_CAT_OPTIONS,
|
||
PC_CAT_PRIMARY,
|
||
} from "../product-business";
|
||
|
||
const PROGRESS_STEPS = [
|
||
{ label: "脚本", icon: ScrollText },
|
||
{ label: "资产", icon: Boxes },
|
||
{ label: "视频", icon: Clapperboard },
|
||
];
|
||
const QUICK_RATIOS = [
|
||
{ value: "9:16", label: "9:16 竖屏" },
|
||
{ value: "16:9", label: "16:9 横屏" },
|
||
{ value: "1:1", label: "1:1 方形" },
|
||
{ value: "3:4", label: "3:4 竖版" },
|
||
{ value: "4:3", label: "4:3 横版" },
|
||
{ value: "21:9", label: "21:9 超宽" },
|
||
];
|
||
const QUICK_RESOLUTIONS = [
|
||
{ value: "480p", label: "480p 流畅" },
|
||
{ value: "720p", label: "720p 高清" },
|
||
{ value: "1080p", label: "1080p 超清" },
|
||
{ value: "4k", label: "4K 超清" },
|
||
];
|
||
const QUICK_DURATIONS = [15, 30, 45, 60];
|
||
/** 与后端 QUICK_SCRIPT_MODEL_NAME 一致 */
|
||
const QUICK_SCRIPT_MODEL = "doubao-seed-2-1-pro-260628";
|
||
const QUICK_SCRIPT_POINTS_FALLBACK = 100;
|
||
const QUICK_IMAGE_POINTS_FALLBACK = 20;
|
||
|
||
function modelResolutions(config: ModelConfig | undefined) {
|
||
const capabilities = (config?.metadata?.capabilities || {}) as Record<string, unknown>;
|
||
const nested = Array.isArray(capabilities.resolutions) ? capabilities.resolutions : [];
|
||
const legacy = Array.isArray(config?.metadata?.resolutions) ? config.metadata.resolutions : [];
|
||
return (nested.length ? nested : legacy).map(String);
|
||
}
|
||
|
||
function formatClock(seconds: number) {
|
||
const total = Math.max(0, Math.round(Number(seconds) || 0));
|
||
const minutes = Math.floor(total / 60);
|
||
const rest = total % 60;
|
||
return `${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
|
||
}
|
||
|
||
function historyTitle(item: QuickCreateJob) {
|
||
return stripQuickCreateSuffix(item.title || item.product_name || "一键成片");
|
||
}
|
||
|
||
function jobIsComplete(item: QuickCreateJob) {
|
||
return item.status === "succeeded" || Boolean(item.result?.video_url) || Boolean(item.result?.video_segments?.some((clip) => clip.video_url));
|
||
}
|
||
|
||
function isReviewFailure(text?: string) {
|
||
return /审核|敏感内容|moderation|safety_violation|sensitivecontent/i.test(text || "");
|
||
}
|
||
|
||
function historyBadge(item: QuickCreateJob) {
|
||
if (item.status === "cancelled") return "已取消";
|
||
if (jobIsComplete(item)) return "已完成";
|
||
return "未完成";
|
||
}
|
||
|
||
function historyCover(item: QuickCreateJob) {
|
||
return item.product_images?.[0]?.url || "";
|
||
}
|
||
|
||
function historyVideoUrl(item: QuickCreateJob) {
|
||
return item.result?.video_url || item.result?.video_segments?.[0]?.video_url || "";
|
||
}
|
||
|
||
function savedJobId() {
|
||
return readQuickCreateJobId();
|
||
}
|
||
|
||
export function QuickCreatePage({
|
||
onBack,
|
||
backLabel = "返回视频创作",
|
||
initialProductId,
|
||
navigate,
|
||
onNotify,
|
||
onProjectCreated,
|
||
onQuickCreateStatus,
|
||
modelConfigs,
|
||
}: {
|
||
onBack: () => void;
|
||
backLabel?: string;
|
||
initialProductId?: string;
|
||
navigate: NavigateFn;
|
||
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||
onProjectCreated?: () => void;
|
||
onQuickCreateStatus?: (projectId: string, status: string) => void;
|
||
modelConfigs: ModelConfig[];
|
||
}) {
|
||
const [name, setName] = useState("");
|
||
const [category, setCategory] = useState("");
|
||
const [catMoreOpen, setCatMoreOpen] = useState(false);
|
||
const [images, setImages] = useState<File[]>([]);
|
||
const [savedImages, setSavedImages] = useState<Array<{ asset_id: string; url: string }>>([]);
|
||
const [sourceProductId, setSourceProductId] = useState("");
|
||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||
const [jobId, setJobId] = useState(savedJobId);
|
||
const [job, setJob] = useState<QuickCreateJob | null>(null);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [cancelling, setCancelling] = useState(false);
|
||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||
// 进页面先向后端确认有没有在跑的任务。localStorage 可能被清、也可能换了浏览器,
|
||
// 只认它会让刷新后看到一张空表单,用户以为没任务又提交一次 —— 那会重复建商品、重复扣费。
|
||
const [restoring, setRestoring] = useState(true);
|
||
const [serviceUnavailable, setServiceUnavailable] = useState(false);
|
||
const [unavailableMessage, setUnavailableMessage] = useState("");
|
||
const [pollEpoch, setPollEpoch] = useState(0);
|
||
const [history, setHistory] = useState<QuickCreateJob[]>([]);
|
||
const [playing, setPlaying] = useState<{ url: string; title: string } | null>(null);
|
||
useBodyScrollLock(Boolean(playing));
|
||
const videoConfigs = useMemo(
|
||
() => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"),
|
||
[modelConfigs],
|
||
);
|
||
const preferredModel = useMemo(
|
||
() => videoConfigs.find((config) => config.name === FC_MODELS[1].name) || videoConfigs[0],
|
||
[videoConfigs],
|
||
);
|
||
const [aspectRatio, setAspectRatio] = useState("9:16");
|
||
const [resolution, setResolution] = useState("720p");
|
||
const [totalDuration, setTotalDuration] = useState(15);
|
||
const [videoModelId, setVideoModelId] = useState("");
|
||
const [billingRates, setBillingRates] = useState<BillingRates>(DEFAULT_BILLING_RATES);
|
||
const completedNoticeRef = useRef("");
|
||
const terminalNoticeRef = useRef("");
|
||
const watchedGeneratingRef = useRef(false);
|
||
const cancelRequestedRef = useRef(false);
|
||
const notifyRef = useRef(onNotify);
|
||
const projectCreatedRef = useRef(onProjectCreated);
|
||
const quickCreateStatusRef = useRef(onQuickCreateStatus);
|
||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||
const productPrefillDoneRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
notifyRef.current = onNotify;
|
||
projectCreatedRef.current = onProjectCreated;
|
||
quickCreateStatusRef.current = onQuickCreateStatus;
|
||
}, [onNotify, onProjectCreated, onQuickCreateStatus]);
|
||
|
||
useEffect(() => {
|
||
if (!videoModelId && preferredModel) setVideoModelId(preferredModel.id);
|
||
}, [preferredModel, videoModelId]);
|
||
|
||
useEffect(() => {
|
||
void api.billingConfig()
|
||
.then((config) => setBillingRates({
|
||
margin: Number(config.video_margin_multiplier) || DEFAULT_BILLING_RATES.margin,
|
||
rate: Number(config.points_per_yuan) || DEFAULT_BILLING_RATES.rate,
|
||
multiplier: Number(config.team_price_multiplier) || 1,
|
||
}))
|
||
.catch(() => undefined);
|
||
void api.quickCreateHistory()
|
||
.then((payload) => {
|
||
setHistory((payload.results || []).filter(jobIsComplete));
|
||
const running = payload.inflight || null;
|
||
if (running) {
|
||
setJob(running);
|
||
setJobId(running.id);
|
||
rememberQuickCreateJob(running.id);
|
||
} else if (!savedJobId()) {
|
||
forgetQuickCreateJob();
|
||
}
|
||
})
|
||
.catch(() => undefined)
|
||
.finally(() => setRestoring(false));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!initialProductId || jobId || productPrefillDoneRef.current) return;
|
||
let cancelled = false;
|
||
void api.product(initialProductId)
|
||
.then((product) => {
|
||
if (cancelled || productPrefillDoneRef.current) return;
|
||
productPrefillDoneRef.current = true;
|
||
setName((current) => current || product.title || "");
|
||
setSourceProductId((current) => current || product.id);
|
||
if (product.category && PC_CAT_OPTIONS.includes(product.category)) {
|
||
setCategory((current) => current || product.category);
|
||
setCatMoreOpen(PC_CAT_MORE.includes(product.category));
|
||
}
|
||
setSavedImages((current) => {
|
||
if (current.length) return current;
|
||
return (product.images || [])
|
||
.filter((image) => image.asset)
|
||
.slice(0, 9)
|
||
.map((image) => ({ asset_id: image.asset, url: image.preview_url || "" }));
|
||
});
|
||
})
|
||
.catch(() => undefined);
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [initialProductId, jobId]);
|
||
|
||
const selectedVideoModel = useMemo(
|
||
() => videoConfigs.find((config) => config.id === videoModelId) || preferredModel,
|
||
[preferredModel, videoConfigs, videoModelId],
|
||
);
|
||
const supportedResolutions = useMemo(
|
||
() => modelResolutions(selectedVideoModel),
|
||
[selectedVideoModel],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!supportedResolutions.length || supportedResolutions.includes(resolution)) return;
|
||
setResolution(supportedResolutions.includes("720p") ? "720p" : supportedResolutions[0]);
|
||
}, [resolution, supportedResolutions]);
|
||
|
||
useEffect(() => {
|
||
if (!images.length) {
|
||
setImagePreviews([]);
|
||
return;
|
||
}
|
||
const urls = images.map((image) => URL.createObjectURL(image));
|
||
setImagePreviews(urls);
|
||
return () => urls.forEach((url) => URL.revokeObjectURL(url));
|
||
}, [images]);
|
||
|
||
useEffect(() => {
|
||
if (!jobId) return;
|
||
let cancelled = false;
|
||
let timer = 0;
|
||
const poll = async () => {
|
||
try {
|
||
const next = await api.quickCreateStatus(jobId);
|
||
if (cancelled) return;
|
||
if (next.status === "queued" || next.status === "running") {
|
||
watchedGeneratingRef.current = true;
|
||
}
|
||
if (next.status !== "succeeded") {
|
||
applyJobProduct(next);
|
||
}
|
||
if (next.settings) {
|
||
setAspectRatio(next.settings.aspect_ratio);
|
||
setResolution(next.settings.resolution);
|
||
setTotalDuration(next.settings.total_duration);
|
||
if (next.settings.video_model_config_id) setVideoModelId(next.settings.video_model_config_id);
|
||
}
|
||
setJob(next);
|
||
if (next.status === "succeeded") {
|
||
if (watchedGeneratingRef.current && completedNoticeRef.current !== next.id) {
|
||
completedNoticeRef.current = next.id;
|
||
notifyRef.current?.("success", "一键成片已生成");
|
||
projectCreatedRef.current?.();
|
||
}
|
||
if (next.project_id) quickCreateStatusRef.current?.(next.project_id, next.status);
|
||
clearDraft();
|
||
void api.quickCreateHistory().then((payload) => setHistory((payload.results || []).filter(jobIsComplete))).catch(() => undefined);
|
||
watchedGeneratingRef.current = false;
|
||
return;
|
||
}
|
||
if (next.status === "failed" || next.status === "cancelled") {
|
||
if (terminalNoticeRef.current !== next.id) {
|
||
terminalNoticeRef.current = next.id;
|
||
const message = next.status === "cancelled"
|
||
? "一键成片已取消"
|
||
: next.error_message || "一键成片未完成,已完成的步骤会保留,可重试继续";
|
||
notifyRef.current?.(next.status === "cancelled" ? "info" : "error", message);
|
||
}
|
||
if (next.project_id) quickCreateStatusRef.current?.(next.project_id, next.status);
|
||
void api.quickCreateHistory().then((payload) => setHistory((payload.results || []).filter(jobIsComplete))).catch(() => undefined);
|
||
watchedGeneratingRef.current = false;
|
||
return;
|
||
}
|
||
timer = window.setTimeout(poll, 2500);
|
||
} catch (error) {
|
||
if (cancelled) return;
|
||
// 极速任务按团队隔离。本机切换账号后,localStorage 里可能仍保留上个团队的任务 ID;
|
||
// 404 不是生成失败,清掉这条旧记录即可,不能把“任务不存在”不断弹给新账号。
|
||
if (error instanceof ApiError && error.status === 404) {
|
||
setJob(null);
|
||
setJobId("");
|
||
clearDraft();
|
||
forgetQuickCreateJob();
|
||
notifyRef.current?.("info", "已清除其他账号的一键成片记录");
|
||
return;
|
||
}
|
||
notifyRef.current?.("error", error instanceof Error ? error.message : "读取一键成片进度失败");
|
||
timer = window.setTimeout(poll, 8000);
|
||
}
|
||
};
|
||
void poll();
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearTimeout(timer);
|
||
};
|
||
}, [jobId, pollEpoch]);
|
||
|
||
useEffect(() => {
|
||
if (!playing) return;
|
||
const onKey = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") setPlaying(null);
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, [playing]);
|
||
|
||
function playClip(url?: string, title?: string) {
|
||
if (!url) {
|
||
onNotify?.("info", "视频还不能播放,请稍后再试");
|
||
return;
|
||
}
|
||
setPlaying({ url, title: title || "预览视频" });
|
||
}
|
||
|
||
function selectImages(files: FileList | File[] | null) {
|
||
const selected = Array.from(files || []).filter((file) => file.type.startsWith("image/"));
|
||
setImages((current) => {
|
||
const room = Math.max(0, 9 - savedImages.length - current.length);
|
||
const accepted = selected.slice(0, room);
|
||
if (selected.length > room) onNotify?.("info", "最多上传9张图片,已保留前9张");
|
||
return [...current, ...accepted];
|
||
});
|
||
}
|
||
|
||
function removeImage(index: number) {
|
||
if (index < savedImages.length) {
|
||
setSavedImages((current) => current.filter((_, imageIndex) => imageIndex !== index));
|
||
return;
|
||
}
|
||
setImages((current) => current.filter((_, imageIndex) => imageIndex !== index - savedImages.length));
|
||
}
|
||
|
||
function clearImages() {
|
||
setSavedImages([]);
|
||
setImages([]);
|
||
}
|
||
|
||
async function retryGeneration() {
|
||
if (job?.id && job.status === "failed") {
|
||
setSubmitting(true);
|
||
setServiceUnavailable(false);
|
||
setUnavailableMessage("");
|
||
cancelRequestedRef.current = false;
|
||
terminalNoticeRef.current = "";
|
||
watchedGeneratingRef.current = true;
|
||
setJob((current) => (
|
||
current
|
||
? { ...current, status: "running", error_message: "", message: "正在从上次进度继续生成…" }
|
||
: current
|
||
));
|
||
try {
|
||
const next = await api.retryQuickCreate(job.id);
|
||
setJob(next);
|
||
setJobId(next.id);
|
||
setPollEpoch((value) => value + 1);
|
||
rememberQuickCreateJob(next.id);
|
||
if (next.status === "queued" || next.status === "running") {
|
||
onNotify?.("success", "已从上次进度继续生成");
|
||
} else if (next.status === "succeeded") {
|
||
onNotify?.("success", "一键成片已生成");
|
||
} else {
|
||
onNotify?.("error", next.error_message || "继续生成失败,已完成的步骤会保留");
|
||
}
|
||
onProjectCreated?.();
|
||
} catch (error) {
|
||
onNotify?.("error", error instanceof Error ? error.message : "继续生成失败");
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
return;
|
||
}
|
||
await startGeneration();
|
||
}
|
||
|
||
async function startGeneration() {
|
||
if (restoring) {
|
||
onNotify?.("info", "正在读取任务状态,请稍候");
|
||
return;
|
||
}
|
||
if (!name.trim() || (!images.length && !savedImages.length)) {
|
||
onNotify?.("info", "请先填写商品名称并上传商品图片");
|
||
return;
|
||
}
|
||
if (!category) {
|
||
onNotify?.("info", "请选择商品品类,脚本会按品类来写");
|
||
return;
|
||
}
|
||
// 与后端门禁同口径:可用积分不够预估总额就不提交。
|
||
if (estimatedPoints > 0) {
|
||
try {
|
||
const summary = await api.billingSummary();
|
||
const balance = Number(summary.account?.balance || 0);
|
||
const reserved = Number(summary.account?.reserved_balance || 0);
|
||
const available = balance - reserved;
|
||
if (available < estimatedPoints) {
|
||
onNotify?.(
|
||
"info",
|
||
`积分不足:预计需要 ${estimatedPoints} 积分,当前可用 ${Math.max(0, Math.floor(available))} 积分,请充值后再开始`,
|
||
);
|
||
return;
|
||
}
|
||
} catch {
|
||
/* 余额接口失败时仍交给后端硬拦 */
|
||
}
|
||
}
|
||
setSubmitting(true);
|
||
setJob(null);
|
||
setServiceUnavailable(false);
|
||
setUnavailableMessage("");
|
||
cancelRequestedRef.current = false;
|
||
terminalNoticeRef.current = "";
|
||
try {
|
||
const form = new FormData();
|
||
form.append("name", name.trim());
|
||
form.append("category", category);
|
||
form.append("aspect_ratio", aspectRatio);
|
||
form.append("resolution", resolution);
|
||
form.append("total_duration", String(totalDuration));
|
||
if (videoModelId) form.append("video_model_config_id", videoModelId);
|
||
if (sourceProductId && savedImages.length) {
|
||
form.append("source_product_id", sourceProductId);
|
||
savedImages.forEach((image) => form.append("image_asset_ids", image.asset_id));
|
||
}
|
||
images.forEach((image) => form.append("images", image));
|
||
const created = await api.startQuickCreate(form);
|
||
if (cancelRequestedRef.current) {
|
||
try {
|
||
await api.cancelQuickCreate(created.id);
|
||
} catch {
|
||
/* 启动刚成功但用户已点取消时,尽量停掉后台任务 */
|
||
}
|
||
resetResult();
|
||
notifyRef.current?.("info", "已取消本次生成");
|
||
return;
|
||
}
|
||
setJob(created);
|
||
setJobId(created.id);
|
||
setImages([]);
|
||
setSavedImages(created.product_images || []);
|
||
setSourceProductId(created.product_id || "");
|
||
rememberQuickCreateJob(created.id);
|
||
onNotify?.("success", "一键成片任务已启动");
|
||
onProjectCreated?.();
|
||
} catch (error) {
|
||
if (error instanceof ApiError && error.status === 409) {
|
||
// 后端单飞闸:已有任务在跑。直接接上它,别让用户对着报错干瞪眼。
|
||
const running = (error.payload as { inflight?: QuickCreateJob } | undefined)?.inflight;
|
||
if (running) {
|
||
setJob(running);
|
||
setJobId(running.id);
|
||
rememberQuickCreateJob(running.id);
|
||
}
|
||
onNotify?.("info", error.message || "已有一个一键成片正在进行中");
|
||
} else if (error instanceof ApiError && error.status === 503) {
|
||
setServiceUnavailable(true);
|
||
setUnavailableMessage(error.message || "一键成片服务暂不可用,请稍后再试");
|
||
onNotify?.("info", error.message || "一键成片服务暂不可用,请稍后再试");
|
||
} else if (error instanceof ApiError && error.status === 400 && /余额不足|积分不足/.test(error.message || "")) {
|
||
onNotify?.("info", error.message);
|
||
} else {
|
||
onNotify?.("error", error instanceof Error ? error.message : "一键成片启动失败");
|
||
}
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
}
|
||
|
||
function applyJobProduct(next: QuickCreateJob) {
|
||
if (next.product_name) setName((current) => current || next.product_name);
|
||
if (next.product_images?.length) {
|
||
setSavedImages(next.product_images.filter((image) => image.asset_id));
|
||
if (next.product_id) setSourceProductId(next.product_id);
|
||
setImages([]);
|
||
}
|
||
}
|
||
|
||
function clearDraft() {
|
||
setName("");
|
||
setCategory("");
|
||
setCatMoreOpen(false);
|
||
setImages([]);
|
||
setSavedImages([]);
|
||
setSourceProductId("");
|
||
productPrefillDoneRef.current = true;
|
||
}
|
||
|
||
function resetResult() {
|
||
setJob(null);
|
||
setJobId("");
|
||
setCancelling(false);
|
||
setConfirmCancel(false);
|
||
setServiceUnavailable(false);
|
||
setUnavailableMessage("");
|
||
completedNoticeRef.current = "";
|
||
terminalNoticeRef.current = "";
|
||
watchedGeneratingRef.current = false;
|
||
clearDraft();
|
||
forgetQuickCreateJob();
|
||
}
|
||
|
||
function openProfessional(projectId?: string, status?: string) {
|
||
if (!projectId) return;
|
||
const released = status === "queued" || status === "running" || !status ? "failed" : status;
|
||
quickCreateStatusRef.current?.(projectId, released);
|
||
navigate("pipeline", { projectId, forcePipeline: true, quickCreateStatus: released });
|
||
}
|
||
|
||
async function cancelGeneration() {
|
||
if (cancelling) return;
|
||
setConfirmCancel(false);
|
||
cancelRequestedRef.current = true;
|
||
if (!jobId) {
|
||
setSubmitting(false);
|
||
resetResult();
|
||
notifyRef.current?.("info", "已取消本次生成");
|
||
return;
|
||
}
|
||
setCancelling(true);
|
||
try {
|
||
const next = await api.cancelQuickCreate(jobId);
|
||
setJob(next);
|
||
if (next.project_id) quickCreateStatusRef.current?.(next.project_id, next.status);
|
||
notifyRef.current?.("info", "已取消本次生成");
|
||
} catch (error) {
|
||
if (error instanceof ApiError && error.status === 404) {
|
||
resetResult();
|
||
notifyRef.current?.("info", "已退出本次生成");
|
||
return;
|
||
}
|
||
// 接口失败也不能把人锁在转圈里:清掉本地任务,允许重新开始。
|
||
resetResult();
|
||
notifyRef.current?.("info", error instanceof Error ? error.message : "已停止当前生成");
|
||
} finally {
|
||
setCancelling(false);
|
||
}
|
||
}
|
||
|
||
function requestCancelGeneration() {
|
||
if (!cancelling) setConfirmCancel(true);
|
||
}
|
||
|
||
const isGenerating = submitting || job?.status === "queued" || job?.status === "running";
|
||
const imageDrop = useFileDrop((files) => selectImages(files), { disabled: isGenerating });
|
||
const isComplete = job?.status === "succeeded";
|
||
const isCancelled = job?.status === "cancelled";
|
||
const isFailed = !isGenerating && !isComplete && (job?.status === "failed" || isCancelled || serviceUnavailable);
|
||
const reviewBlocked = isReviewFailure(job?.error_message);
|
||
const displayUrls = [...savedImages.map((image) => image.url), ...imagePreviews];
|
||
const imageCount = savedImages.length + images.length;
|
||
const canStart = Boolean(name.trim() && category && imageCount && selectedVideoModel) && !reviewBlocked;
|
||
const canRetry = Boolean(name.trim() && imageCount && selectedVideoModel) && !reviewBlocked;
|
||
const activePhase = submitting ? 0 : Math.max(0, Math.min(PROGRESS_STEPS.length, job?.phase_index ?? 0));
|
||
const result = job?.result;
|
||
const videoClips = result?.video_segments?.length
|
||
? result.video_segments
|
||
: result?.video_url
|
||
? [{ id: "final", sort_order: 0, duration_seconds: result.duration_seconds, video_url: result.video_url, poster_url: result.poster_url }]
|
||
: [];
|
||
const clipUrls = new Set(videoClips.map((clip) => clip.video_url).filter(Boolean));
|
||
const mergedUrl = result?.final_video_url || (result?.video_url && !clipUrls.has(result.video_url) ? result.video_url : "");
|
||
const sceneCount = Math.max(1, Math.round(totalDuration / 15));
|
||
const videoEstimate = estimateCost(
|
||
selectedVideoModel,
|
||
{ ratio: aspectRatio, resolution, duration: totalDuration, refs: [] },
|
||
billingRates,
|
||
);
|
||
// 与实扣对齐:脚本(Seed 2.1 Pro 挂牌) + 默认出图挂牌×场次粗估 + 视频挂牌秒价×时长
|
||
const applyTeam = (raw: number) => {
|
||
const multiplier = billingRates.multiplier || 1;
|
||
if (!(raw > 0)) return 0;
|
||
return multiplier === 1 ? raw : Math.max(1, Math.round(Number((raw * multiplier).toFixed(6))));
|
||
};
|
||
const scriptUnit = (() => {
|
||
const texts = modelConfigs.filter((m) => m.capability === "text" && m.status === "active");
|
||
const script = texts.find((m) => m.name === QUICK_SCRIPT_MODEL) || texts.find((m) => m.is_default) || texts[0];
|
||
const listed = pointsPerCallFromCatalog(script);
|
||
return listed != null && listed > 0 ? listed : QUICK_SCRIPT_POINTS_FALLBACK;
|
||
})();
|
||
const imageUnit = (() => {
|
||
const images = modelConfigs.filter((m) => m.capability === "image" && m.status === "active");
|
||
// 与后端 get_default_model(IMAGE) 一致:优先 is_default,再回落影擎-Image2 名字
|
||
const img = images.find((m) => m.is_default)
|
||
|| images.find((m) => m.name === "gpt-image-2")
|
||
|| images[0]
|
||
|| modelConfigs.find((m) => m.capability === "image");
|
||
const listed = pointsPerImageFromCatalog(img);
|
||
return listed != null && listed > 0 ? listed : QUICK_IMAGE_POINTS_FALLBACK;
|
||
})();
|
||
const estimatedPoints = videoEstimate.listed
|
||
? videoEstimate.points + applyTeam(scriptUnit) + applyTeam(sceneCount * imageUnit)
|
||
: 0;
|
||
const shellClass = [
|
||
"quick-create-shell",
|
||
restoring ? "is-restoring" : "",
|
||
isGenerating ? "is-generating" : "",
|
||
isComplete ? "is-complete" : "",
|
||
isFailed ? "is-failed" : "",
|
||
].filter(Boolean).join(" ");
|
||
|
||
return (
|
||
<div className="quick-create-page">
|
||
<header className="project-builder-header quick-create-header">
|
||
<div className="project-builder-title">
|
||
<button type="button" className="image-back-button" onClick={onBack} aria-label={backLabel}><ArrowLeft /></button>
|
||
<div><h1>一键成片</h1></div>
|
||
</div>
|
||
</header>
|
||
|
||
<div className={shellClass} id="quickCreateShell">
|
||
<section
|
||
className={`quick-create-panel quick-form-panel${restoring ? " is-locked" : ""}`}
|
||
aria-busy={restoring}
|
||
inert={restoring}
|
||
>
|
||
<div className="quick-form-copy">
|
||
<h2>告诉我们这是什么商品</h2>
|
||
</div>
|
||
|
||
<label className="quick-field">
|
||
<span className="quick-field-label"><span>商品名称</span><small>必填</small></span>
|
||
<input className="quick-name-input" autoComplete="off" value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:净颜精华、轻醒咖啡" disabled={isGenerating} />
|
||
</label>
|
||
|
||
<div className="quick-field">
|
||
<span className="quick-field-label"><span>商品品类</span><small>必选 · 写脚本时会用到</small></span>
|
||
<div className={`quick-cat-grid${catMoreOpen ? " is-more-open" : ""}`} role="listbox" aria-label="商品品类">
|
||
{PC_CAT_PRIMARY.map((option) => (
|
||
<button
|
||
type="button"
|
||
className={`chip quick-cat-chip${category === option ? " active" : ""}`}
|
||
key={option}
|
||
disabled={isGenerating}
|
||
aria-selected={category === option}
|
||
onClick={() => { setCategory(option); setCatMoreOpen(false); }}
|
||
>
|
||
{option}
|
||
</button>
|
||
))}
|
||
<button
|
||
type="button"
|
||
className={`chip quick-cat-chip quick-cat-more${catMoreOpen ? " is-open" : ""}`}
|
||
disabled={isGenerating}
|
||
aria-expanded={catMoreOpen}
|
||
onClick={() => setCatMoreOpen((open) => !open)}
|
||
>
|
||
<span>{PC_CAT_MORE_LABEL}</span>
|
||
<ChevronRight />
|
||
</button>
|
||
{catMoreOpen ? PC_CAT_MORE.map((option) => (
|
||
<button
|
||
type="button"
|
||
className={`chip quick-cat-chip${category === option ? " active" : ""}`}
|
||
key={option}
|
||
disabled={isGenerating}
|
||
aria-selected={category === option}
|
||
onClick={() => setCategory(option)}
|
||
>
|
||
{option}
|
||
</button>
|
||
)) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="quick-field">
|
||
<span className="quick-field-label"><span>商品图片</span><small>必填 · 最多9张</small></span>
|
||
<div
|
||
className={`quick-upload${imageCount ? " has-images" : ""}${imageDrop.dragging ? " is-dragover" : ""}`}
|
||
{...imageDrop.dropProps}
|
||
>
|
||
<input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={(event) => { selectImages(event.target.files); event.currentTarget.value = ""; }} />
|
||
{imageCount ? (
|
||
<div className="quick-upload-filled">
|
||
<div className="quick-image-grid" aria-label={`已上传 ${imageCount} 张商品图片`}>
|
||
{Array.from({ length: 9 }, (_, index) => {
|
||
const image = displayUrls[index];
|
||
return <div key={image || savedImages[index]?.asset_id || `empty-${index}`} className={`quick-image-tile${index < imageCount ? " is-filled" : ""}`}>
|
||
{index < imageCount ? <>{image ? <img src={image} alt={`商品图片 ${index + 1}`} /> : null}<button type="button" disabled={isGenerating} onClick={() => removeImage(index)} aria-label={`删除商品图片 ${index + 1}`}><X /></button></> : null}
|
||
</div>;
|
||
})}
|
||
</div>
|
||
<div className="quick-upload-more">
|
||
<button type="button" className="quick-upload-trigger" onClick={() => imageInputRef.current?.click()} disabled={isGenerating || imageCount >= 9}>
|
||
<span className="quick-upload-more-icon"><ImagePlus /></span>
|
||
<strong>继续上传</strong>
|
||
<small>已上传 {imageCount} / 9</small>
|
||
</button>
|
||
<button type="button" className="quick-clear-all" onClick={clearImages} disabled={isGenerating}>清空全部</button>
|
||
</div>
|
||
</div>
|
||
) : <button type="button" className="quick-upload-copy" onClick={() => imageInputRef.current?.click()} disabled={isGenerating}><span className="quick-upload-icon"><Upload /></span><strong>上传商品图片</strong><small>建议包含清晰主图、包装和使用细节</small></button>}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="quick-parameter-grid" aria-label="视频核心参数">
|
||
<label className="quick-parameter-field">
|
||
<span>视频比例</span>
|
||
<CustomSelect fill value={aspectRatio} onChange={setAspectRatio} disabled={isGenerating} options={QUICK_RATIOS} />
|
||
</label>
|
||
<label className="quick-parameter-field">
|
||
<span>分辨率</span>
|
||
<CustomSelect
|
||
fill
|
||
value={resolution}
|
||
onChange={setResolution}
|
||
disabled={isGenerating}
|
||
options={(supportedResolutions.length
|
||
? QUICK_RESOLUTIONS.filter((option) => supportedResolutions.includes(option.value))
|
||
: QUICK_RESOLUTIONS
|
||
)}
|
||
/>
|
||
</label>
|
||
<label className="quick-parameter-field">
|
||
<span>视频时长</span>
|
||
<CustomSelect
|
||
fill
|
||
value={String(totalDuration)}
|
||
onChange={(next) => setTotalDuration(Number(next))}
|
||
disabled={isGenerating}
|
||
options={QUICK_DURATIONS.map((duration) => ({ value: String(duration), label: `${duration / 15} 场(${duration}s)` }))}
|
||
/>
|
||
</label>
|
||
<label className="quick-parameter-field">
|
||
<span>视频模型</span>
|
||
<CustomSelect
|
||
fill
|
||
value={videoModelId}
|
||
onChange={setVideoModelId}
|
||
disabled={isGenerating || !videoConfigs.length}
|
||
placeholder="暂无可用视频模型"
|
||
options={videoConfigs.map((config) => ({
|
||
value: config.id,
|
||
label: FC_MODELS.find((item) => item.name === config.name)?.label || config.display_name || modelLabel(config.name),
|
||
}))}
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="quick-form-footer">
|
||
{isGenerating ? (
|
||
<button type="button" className="quick-cancel-button" onClick={requestCancelGeneration} disabled={cancelling}>
|
||
<X /><span>{cancelling ? "正在取消…" : "取消生成"}</span>
|
||
</button>
|
||
) : (
|
||
<button type="button" className="quick-generate-button" onClick={() => void startGeneration()} disabled={!canStart}>
|
||
<WandSparkles /><span>{estimatedPoints > 0 ? `立即生成视频 · 消耗 ${estimatedPoints} 积分` : "立即生成视频"}</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="quick-create-panel quick-status-panel" aria-live="polite">
|
||
<div className="quick-state quick-state-restoring" role="status">
|
||
<div className="quick-restoring-spinner" aria-hidden="true" />
|
||
<h2>正在读取任务状态</h2>
|
||
<p>确认有没有正在进行的一键成片,稍候</p>
|
||
</div>
|
||
|
||
<div className="quick-state quick-state-ready">
|
||
<div className="quick-ready-orbit"><span className="quick-ready-icon"><Sparkles /></span></div>
|
||
<h2>核心参数可选,其余自动完成</h2>
|
||
</div>
|
||
|
||
<div className="quick-state quick-state-generating">
|
||
<div className="quick-generating-preview" aria-label="视频生成中"><div className="quick-preview-spinner" /></div>
|
||
<div className="quick-generating-copy"><h2>正在为商品生成视频</h2><p>{job?.message || "正在生成视频并进行质量检查…"}</p></div>
|
||
<div className="quick-progress-track">
|
||
{PROGRESS_STEPS.map((step, index) => {
|
||
const Icon = step.icon;
|
||
const done = index < activePhase;
|
||
const active = isGenerating && index === activePhase && activePhase < PROGRESS_STEPS.length;
|
||
return <div key={step.label} className={`quick-progress-node${active ? " active" : ""}${done ? " done" : ""}`}><span className="quick-progress-dot"><Icon /></span><strong>{step.label}</strong></div>;
|
||
})}
|
||
</div>
|
||
<div className="quick-generating-actions">
|
||
<button type="button" className="secondary-action" onClick={requestCancelGeneration} disabled={cancelling}>
|
||
<X />{cancelling ? "正在取消…" : "取消生成"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="quick-state quick-state-complete">
|
||
<div className={`quick-video-result-grid${videoClips.length === 1 ? " is-single" : ""}`}>
|
||
{videoClips.map((clip, index) => {
|
||
const clipUrl = clip.video_url || result?.video_url || "";
|
||
return (
|
||
<button
|
||
key={clip.id}
|
||
type="button"
|
||
className="quick-video-result-card"
|
||
onClick={() => playClip(clipUrl, `第${index + 1}场视频`)}
|
||
>
|
||
<span className="quick-video-result-thumb">
|
||
{clipUrl ? <video src={clipUrl} muted playsInline preload="metadata" /> : null}
|
||
<span className="quick-video-play" aria-hidden="true"><Play /></span>
|
||
</span>
|
||
<span className="quick-video-result-meta"><strong>第{index + 1}场视频</strong><small>{clip.duration_seconds || 15}秒</small></span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="quick-result-head">
|
||
<div>
|
||
<h2>{videoClips.length || 1} 个视频已生成</h2>
|
||
<p>{videoClips.length || 1}场 · 每场{videoClips[0]?.duration_seconds || 15}秒 · {result?.aspect_ratio || "9:16"} · {(result?.resolution || "720p").toUpperCase()} · {result?.video_model || "智能模型"}</p>
|
||
</div>
|
||
<span className="quick-result-badge">质量检查通过</span>
|
||
</div>
|
||
<div className={`quick-result-actions${videoClips.length > 1 ? "" : " is-single"}`}>
|
||
<button type="button" className="secondary-action" onClick={resetResult}><RefreshCw />重新生成</button>
|
||
{videoClips.length > 1 ? (
|
||
mergedUrl ? (
|
||
<button type="button" className="secondary-action" onClick={() => playClip(mergedUrl, "完整视频")}>
|
||
<Play />播放
|
||
</button>
|
||
) : (
|
||
<button type="button" className="secondary-action" onClick={() => job && openProfessional(job.project_id, job.status)}>
|
||
<Columns2 />合并视频
|
||
</button>
|
||
)
|
||
) : null}
|
||
{result?.video_url ? (
|
||
<a className="primary-action quick-download-action" href={result.video_url} target="_blank" rel="noreferrer" download><Download />下载视频</a>
|
||
) : (
|
||
<button type="button" className="primary-action" disabled><Download />下载视频</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="quick-state quick-state-failed">
|
||
<span className="quick-failed-icon"><AlertCircle /></span>
|
||
<h2>
|
||
{serviceUnavailable
|
||
? "一键成片暂不可用"
|
||
: isCancelled
|
||
? "已取消本次生成"
|
||
: reviewBlocked
|
||
? "图片未通过审核"
|
||
: job?.phase === "script"
|
||
? "本次生成未完成"
|
||
: "成片尚未完成"}
|
||
</h2>
|
||
<p>
|
||
{serviceUnavailable
|
||
? (unavailableMessage || "后台生成服务未就绪,请确认 Celery worker 已启动后再试。")
|
||
: reviewBlocked
|
||
? (job?.error_message || "商品图或生成画面未通过内容审核。请更换商品图,或进入专业模式调整后再生成。")
|
||
: (job?.error_message || "生成还没走完。点重试会从上次进度继续,已生成的脚本和素材会保留。")}
|
||
</p>
|
||
<div className="quick-failed-actions">
|
||
{reviewBlocked && job?.project_id ? (
|
||
<button type="button" className="primary-action" onClick={() => openProfessional(job.project_id, job.status)}>
|
||
<SlidersHorizontal />进入专业模式
|
||
</button>
|
||
) : null}
|
||
{canRetry && !serviceUnavailable && !reviewBlocked ? (
|
||
<button type="button" className="primary-action" onClick={() => void retryGeneration()}><RefreshCw />重试</button>
|
||
) : null}
|
||
<button type="button" className={(reviewBlocked && job?.project_id) || (canRetry && !serviceUnavailable) ? "secondary-action" : "primary-action"} onClick={resetResult}>
|
||
<RefreshCw />重新开始
|
||
</button>
|
||
{!reviewBlocked && job?.project_id ? (
|
||
<button type="button" className="secondary-action" onClick={() => openProfessional(job.project_id, job.status)}>
|
||
<SlidersHorizontal />进入专业模式
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<section className="quick-history" aria-label="过往一键成片项目">
|
||
<div className="quick-history-head">
|
||
<h2>过往一键成片项目</h2>
|
||
<span>{history.length}个项目</span>
|
||
</div>
|
||
{history.length ? (
|
||
<div className="quick-history-list">
|
||
{history.map((item) => {
|
||
const cover = historyCover(item);
|
||
const videoUrl = historyVideoUrl(item);
|
||
const duration = item.result?.duration_seconds || item.settings?.total_duration || 15;
|
||
const scenes = item.result?.video_segments?.length || Math.max(1, Math.round(duration / 15));
|
||
const badge = historyBadge(item);
|
||
const canPlay = Boolean(videoUrl);
|
||
return (
|
||
<article key={item.id} className="quick-history-card">
|
||
<button
|
||
type="button"
|
||
className="quick-history-thumb"
|
||
onClick={() => playClip(videoUrl, historyTitle(item))}
|
||
aria-label={canPlay ? `播放${historyTitle(item)}` : historyTitle(item)}
|
||
disabled={!canPlay}
|
||
>
|
||
{cover ? <img src={cover} alt="" /> : <span className="quick-history-thumb-empty">{canPlay ? null : <Play />}</span>}
|
||
{canPlay ? <span className="quick-history-play" aria-hidden="true"><Play /></span> : null}
|
||
<small>{formatClock(duration)}</small>
|
||
</button>
|
||
<div className="quick-history-copy">
|
||
<span className={`quick-history-badge${badge === "已完成" ? "" : " is-wait"}`}>{badge}</span>
|
||
<h3>{historyTitle(item)}</h3>
|
||
<p>一键成片 · {scenes}场 · {item.result?.aspect_ratio || item.settings?.aspect_ratio || "9:16"} · {(item.result?.resolution || item.settings?.resolution || "720p").toUpperCase()} · {item.result?.video_model || item.settings?.video_model_label || "智能模型"}</p>
|
||
</div>
|
||
<button type="button" className="quick-history-open" onClick={() => openProfessional(item.project_id, item.status)}>
|
||
<ArrowUpRight />查看项目
|
||
</button>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<p className="quick-history-empty">还没有完成的一键成片,生成成功后会出现在这里。</p>
|
||
)}
|
||
</section>
|
||
|
||
<ConfirmModal
|
||
open={confirmCancel}
|
||
title="确认取消生成?"
|
||
subtitle="当前任务将停止"
|
||
detail="取消后,本次一键成片不会继续生成;已完成的商品和素材会保留在专业创作项目中。"
|
||
confirmText="确认取消"
|
||
icon={<AlertCircle size={16} />}
|
||
dismissable={!cancelling}
|
||
onCancel={() => { if (!cancelling) setConfirmCancel(false); }}
|
||
onConfirm={() => void cancelGeneration()}
|
||
/>
|
||
|
||
{playing ? (
|
||
<OverlayPortal>
|
||
<div className="quick-player-bg" onClick={() => setPlaying(null)} role="presentation">
|
||
<div className="quick-player" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label={playing.title}>
|
||
<div className="quick-player-bar">
|
||
<strong>{playing.title}</strong>
|
||
<button type="button" onClick={() => setPlaying(null)} aria-label="关闭播放"><X /></button>
|
||
</div>
|
||
<video src={playing.url} controls autoPlay playsInline controlsList="nodownload" />
|
||
</div>
|
||
</div>
|
||
</OverlayPortal>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|