测试极速成片
This commit is contained in:
@@ -14,10 +14,13 @@ import {
|
||||
Upload,
|
||||
WandSparkles,
|
||||
X,
|
||||
Columns2,
|
||||
Download,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
import { api, ApiError, type QuickCreateJob } from "../api";
|
||||
import { ConfirmModal } from "../components/overlays";
|
||||
import { forgetQuickCreateJob, readQuickCreateJobId, rememberQuickCreateJob } from "../quick-create-lock";
|
||||
import type { ModelConfig } from "../types";
|
||||
import {
|
||||
DEFAULT_BILLING_RATES,
|
||||
@@ -28,7 +31,6 @@ import {
|
||||
} from "../components/free-create/constants";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
|
||||
const QUICK_JOB_KEY = "airshelf:quick-create-job";
|
||||
const PROGRESS_STEPS = [
|
||||
{ label: "脚本", icon: ScrollText },
|
||||
{ label: "资产", icon: Boxes },
|
||||
@@ -73,6 +75,10 @@ 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 "已完成";
|
||||
@@ -84,11 +90,7 @@ function historyVideoUrl(item: QuickCreateJob) {
|
||||
}
|
||||
|
||||
function savedJobId() {
|
||||
try {
|
||||
return localStorage.getItem(QUICK_JOB_KEY) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
return readQuickCreateJobId();
|
||||
}
|
||||
|
||||
export function QuickCreatePage({
|
||||
@@ -98,6 +100,7 @@ export function QuickCreatePage({
|
||||
navigate,
|
||||
onNotify,
|
||||
onProjectCreated,
|
||||
onQuickCreateStatus,
|
||||
modelConfigs,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
@@ -106,6 +109,7 @@ export function QuickCreatePage({
|
||||
navigate: NavigateFn;
|
||||
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||||
onProjectCreated?: () => void;
|
||||
onQuickCreateStatus?: (projectId: string, status: string) => void;
|
||||
modelConfigs: ModelConfig[];
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
@@ -120,6 +124,7 @@ export function QuickCreatePage({
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
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; poster: string; title: string } | null>(null);
|
||||
const videoConfigs = useMemo(
|
||||
@@ -141,13 +146,15 @@ export function QuickCreatePage({
|
||||
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;
|
||||
}, [onNotify, onProjectCreated]);
|
||||
quickCreateStatusRef.current = onQuickCreateStatus;
|
||||
}, [onNotify, onProjectCreated, onQuickCreateStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoModelId && preferredModel) setVideoModelId(preferredModel.id);
|
||||
@@ -162,7 +169,7 @@ export function QuickCreatePage({
|
||||
}))
|
||||
.catch(() => undefined);
|
||||
void api.quickCreateHistory()
|
||||
.then((payload) => setHistory(payload.results || []))
|
||||
.then((payload) => setHistory((payload.results || []).filter(jobIsComplete)))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
@@ -221,36 +228,11 @@ export function QuickCreatePage({
|
||||
try {
|
||||
const next = await api.quickCreateStatus(jobId);
|
||||
if (cancelled) return;
|
||||
if (next.status === "succeeded") {
|
||||
if (watchedGeneratingRef.current && completedNoticeRef.current !== next.id) {
|
||||
completedNoticeRef.current = next.id;
|
||||
notifyRef.current?.("success", "极速成片已生成");
|
||||
projectCreatedRef.current?.();
|
||||
}
|
||||
productPrefillDoneRef.current = true;
|
||||
setHistory((current) => [next, ...current.filter((item) => item.id !== next.id)]);
|
||||
void api.quickCreateHistory().then((payload) => setHistory(payload.results || [])).catch(() => undefined);
|
||||
watchedGeneratingRef.current = false;
|
||||
setName("");
|
||||
setImages([]);
|
||||
setSavedImages([]);
|
||||
setSourceProductId("");
|
||||
setJob(null);
|
||||
setJobId("");
|
||||
setCancelling(false);
|
||||
setConfirmCancel(false);
|
||||
try {
|
||||
localStorage.removeItem(QUICK_JOB_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return;
|
||||
if (next.status === "queued" || next.status === "running") {
|
||||
watchedGeneratingRef.current = true;
|
||||
}
|
||||
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([]);
|
||||
if (next.status !== "succeeded") {
|
||||
applyJobProduct(next);
|
||||
}
|
||||
if (next.settings) {
|
||||
setAspectRatio(next.settings.aspect_ratio);
|
||||
@@ -258,8 +240,19 @@ export function QuickCreatePage({
|
||||
setTotalDuration(next.settings.total_duration);
|
||||
if (next.settings.video_model_config_id) setVideoModelId(next.settings.video_model_config_id);
|
||||
}
|
||||
if (next.status === "queued" || next.status === "running") watchedGeneratingRef.current = true;
|
||||
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;
|
||||
@@ -268,7 +261,8 @@ export function QuickCreatePage({
|
||||
: next.error_message || "极速成片未完成,已完成的步骤会保留,可重试继续";
|
||||
notifyRef.current?.(next.status === "cancelled" ? "info" : "error", message);
|
||||
}
|
||||
void api.quickCreateHistory().then((payload) => setHistory(payload.results || [])).catch(() => undefined);
|
||||
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;
|
||||
}
|
||||
@@ -280,11 +274,8 @@ export function QuickCreatePage({
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
setJob(null);
|
||||
setJobId("");
|
||||
try {
|
||||
localStorage.removeItem(QUICK_JOB_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
clearDraft();
|
||||
forgetQuickCreateJob();
|
||||
notifyRef.current?.("info", "已清除其他账号的极速成片记录");
|
||||
return;
|
||||
}
|
||||
@@ -297,7 +288,7 @@ export function QuickCreatePage({
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [jobId]);
|
||||
}, [jobId, pollEpoch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
@@ -346,16 +337,25 @@ export function QuickCreatePage({
|
||||
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);
|
||||
try {
|
||||
localStorage.setItem(QUICK_JOB_KEY, next.id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
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 || "继续生成失败,已完成的步骤会保留");
|
||||
}
|
||||
onNotify?.("success", "已从上次进度继续生成");
|
||||
onProjectCreated?.();
|
||||
} catch (error) {
|
||||
onNotify?.("error", error instanceof Error ? error.message : "继续生成失败");
|
||||
@@ -406,11 +406,7 @@ export function QuickCreatePage({
|
||||
setImages([]);
|
||||
setSavedImages(created.product_images || []);
|
||||
setSourceProductId(created.product_id || "");
|
||||
try {
|
||||
localStorage.setItem(QUICK_JOB_KEY, created.id);
|
||||
} catch {
|
||||
/* 本地存储不可用时只影响刷新恢复,不影响本次生成 */
|
||||
}
|
||||
rememberQuickCreateJob(created.id);
|
||||
onNotify?.("success", "极速成片任务已启动");
|
||||
onProjectCreated?.();
|
||||
} catch (error) {
|
||||
@@ -426,6 +422,23 @@ export function QuickCreatePage({
|
||||
}
|
||||
}
|
||||
|
||||
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("");
|
||||
setImages([]);
|
||||
setSavedImages([]);
|
||||
setSourceProductId("");
|
||||
productPrefillDoneRef.current = true;
|
||||
}
|
||||
|
||||
function resetResult() {
|
||||
setJob(null);
|
||||
setJobId("");
|
||||
@@ -436,11 +449,15 @@ export function QuickCreatePage({
|
||||
completedNoticeRef.current = "";
|
||||
terminalNoticeRef.current = "";
|
||||
watchedGeneratingRef.current = false;
|
||||
try {
|
||||
localStorage.removeItem(QUICK_JOB_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
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() {
|
||||
@@ -457,6 +474,7 @@ export function QuickCreatePage({
|
||||
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) {
|
||||
@@ -477,12 +495,23 @@ export function QuickCreatePage({
|
||||
}
|
||||
|
||||
const isGenerating = submitting || job?.status === "queued" || job?.status === "running";
|
||||
const isComplete = job?.status === "succeeded";
|
||||
const isCancelled = job?.status === "cancelled";
|
||||
const isFailed = !isGenerating && (job?.status === "failed" || isCancelled || serviceUnavailable);
|
||||
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 canRetry = Boolean(name.trim() && imageCount && selectedVideoModel);
|
||||
const canRetry = Boolean(name.trim() && imageCount && selectedVideoModel) && !reviewBlocked;
|
||||
const activePhase = submitting ? 0 : Math.max(0, Math.min(4, 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 posterFallback = displayUrls[0] || "";
|
||||
const sceneCount = Math.max(1, Math.round(totalDuration / 15));
|
||||
const videoEstimate = estimateCost(
|
||||
selectedVideoModel,
|
||||
@@ -494,6 +523,7 @@ export function QuickCreatePage({
|
||||
const shellClass = [
|
||||
"quick-create-shell",
|
||||
isGenerating ? "is-generating" : "",
|
||||
isComplete ? "is-complete" : "",
|
||||
isFailed ? "is-failed" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
@@ -614,19 +644,89 @@ export function QuickCreatePage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="quick-state quick-state-complete">
|
||||
<div className={`quick-video-result-grid${videoClips.length === 1 ? " is-single" : ""}`}>
|
||||
{videoClips.map((clip, index) => (
|
||||
<button
|
||||
key={clip.id}
|
||||
type="button"
|
||||
className="quick-video-result-card"
|
||||
onClick={() => playClip(clip.video_url || result?.video_url, clip.poster_url || result?.poster_url || posterFallback, `第${index + 1}场视频`)}
|
||||
>
|
||||
<span className="quick-video-result-thumb">
|
||||
{clip.poster_url || result?.poster_url || posterFallback ? (
|
||||
<img src={clip.poster_url || result?.poster_url || posterFallback} alt={`第${index + 1}场视频首帧`} />
|
||||
) : clip.video_url ? (
|
||||
<video src={clip.video_url} 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, result?.poster_url || posterFallback, "完整视频")}>
|
||||
<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 ? "已取消本次生成" : job?.phase === "script" ? "本次生成未完成" : "成片尚未完成"}</h2>
|
||||
<p>{serviceUnavailable ? (unavailableMessage || "后台生成服务未就绪,请确认 Celery worker 已启动后再试。") : job?.error_message || "生成还没走完。点重试会从上次进度继续,已生成的脚本和素材会保留。"}</p>
|
||||
<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">
|
||||
{canRetry && !serviceUnavailable ? (
|
||||
{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={canRetry && !serviceUnavailable ? "secondary-action" : "primary-action"} onClick={resetResult}>
|
||||
<button type="button" className={(reviewBlocked && job?.project_id) || (canRetry && !serviceUnavailable) ? "secondary-action" : "primary-action"} onClick={resetResult}>
|
||||
<RefreshCw />重新开始
|
||||
</button>
|
||||
{job?.project_id ? (
|
||||
<button type="button" className="secondary-action" onClick={() => navigate("pipeline", { projectId: job.project_id })}>
|
||||
{!reviewBlocked && job?.project_id ? (
|
||||
<button type="button" className="secondary-action" onClick={() => openProfessional(job.project_id, job.status)}>
|
||||
<SlidersHorizontal />进入专业模式
|
||||
</button>
|
||||
) : null}
|
||||
@@ -667,7 +767,7 @@ export function QuickCreatePage({
|
||||
<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={() => navigate("pipeline", { projectId: item.project_id })}>
|
||||
<button type="button" className="quick-history-open" onClick={() => openProfessional(item.project_id, item.status)}>
|
||||
<ArrowUpRight />查看项目
|
||||
</button>
|
||||
</article>
|
||||
|
||||
Reference in New Issue
Block a user