测试极速成片
This commit is contained in:
@@ -21,6 +21,7 @@ import type {
|
||||
} from "./types";
|
||||
import { publicModelDisplayName } from "./model-display";
|
||||
import { generationErrorText } from "./generation-error";
|
||||
import { isQuickCreateBusy, lockedQuickCreateProject, rememberQuickCreateJob, withQuickCreateStatus } from "./quick-create-lock";
|
||||
import { AccountMenu, CornerMarks, Decorations, ModeTabs, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell";
|
||||
import { SystemLoading } from "./components/loading";
|
||||
import {
|
||||
@@ -415,6 +416,17 @@ export function App() {
|
||||
};
|
||||
}, [authed, page, activeProjectId, detailRetry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authed || page !== "pipeline" || !activeProjectId) return;
|
||||
const listed = projects.find((item) => item.id === activeProjectId);
|
||||
const detailed = projectDetail && projectDetail.id === activeProjectId ? projectDetail : null;
|
||||
const locked = lockedQuickCreateProject(listed, detailed);
|
||||
if (!locked) return;
|
||||
rememberQuickCreateJob(locked.quick_create_job_id);
|
||||
setNotice({ type: "info", text: "该项目正在极速成片中,请在极速成片页查看进度" });
|
||||
navigate("quickCreate", { productId: locked.product, replace: true });
|
||||
}, [authed, page, activeProjectId, projects, projectDetail]);
|
||||
|
||||
// 静默轮询运行中的视频段(本机无 Celery worker,由前端驱动 poll-video-segment),实时刷新管线进度,不弹 toast。
|
||||
// 资源账:旧实现每轮「GET 项目 → 逐段串行 POST → 再 GET 项目」,4 段在途时一轮 = 2 个 26KB GET + 4 个串行
|
||||
// ARK 轮询(总耗时随段数线性涨)。现用内存态定位在途段(省前置 GET),段间 Promise.all 并行,一轮只回读一次。
|
||||
@@ -516,12 +528,36 @@ export function App() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeProjectId, refreshExport]);
|
||||
|
||||
function applyQuickCreateStatus(projectId: string, status: string) {
|
||||
setProjects((items) => withQuickCreateStatus(items, projectId, status));
|
||||
setProjectDetail((current) => (
|
||||
current && current.id === projectId ? { ...current, quick_create_status: status } : current
|
||||
));
|
||||
}
|
||||
|
||||
function navigate(next: Page, options: NavigateOptions = {}) {
|
||||
// 已知为子账号时在发起导航前拦截;与直接访问 URL 的 layout guard 共用同一规则。
|
||||
if (role && !isOwner && isOwnerOnlyPage(next)) {
|
||||
setNotice({ type: "info", text: "当前账号暂无访问权限" });
|
||||
return;
|
||||
}
|
||||
if (next === "pipeline") {
|
||||
const targetId = options.projectId ?? activeProjectId;
|
||||
const listed = projects.find((item) => item.id === targetId);
|
||||
const detailed = projectDetail && projectDetail.id === targetId ? projectDetail : null;
|
||||
const released = options.quickCreateStatus || "failed";
|
||||
const canForce = Boolean(options.forcePipeline && targetId && !isQuickCreateBusy({ quick_create_status: released }));
|
||||
if (canForce && targetId) {
|
||||
applyQuickCreateStatus(targetId, released);
|
||||
}
|
||||
const locked = canForce ? null : lockedQuickCreateProject(listed, detailed);
|
||||
if (locked) {
|
||||
rememberQuickCreateJob(locked.quick_create_job_id);
|
||||
setNotice({ type: "info", text: "该项目正在极速成片中,请在极速成片页查看进度" });
|
||||
next = "quickCreate";
|
||||
options = { ...options, productId: locked.product, replace: options.replace };
|
||||
}
|
||||
}
|
||||
// 图片创作 / 极速成片只有显式入口才携带商品;从工作台或视频创作进入时不能继承全局当前商品。
|
||||
const productId = next === "imageOptimize" || next === "quickCreate" ? options.productId : (options.productId ?? activeProductId);
|
||||
const projectId = options.projectId ?? activeProjectId;
|
||||
@@ -1064,6 +1100,7 @@ export function App() {
|
||||
modelConfigs={modelConfigs}
|
||||
onNotify={(type, text) => setNotice({ type, text })}
|
||||
onProjectCreated={() => { void loadData(); }}
|
||||
onQuickCreateStatus={applyQuickCreateStatus}
|
||||
/>
|
||||
);
|
||||
case "videoRemix":
|
||||
@@ -1151,19 +1188,14 @@ export function App() {
|
||||
onAdoptVideoVersion={(segmentId, versionId) => action(() => api.adoptVideoVersion(pipelineProject.id, { video_segment_id: segmentId, version_id: versionId }), "已采用该版本")}
|
||||
onGenerateVoiceover={(payload) => action(() => api.generateVoiceover(pipelineProject.id, payload), "配音已生成")}
|
||||
onGenerateBaseAsset={async (kind, prompt, label, referenceAssetId) => {
|
||||
// 异步:提交→轮询出图→刷新。角色立绘成功后立刻据该立绘出三视图,用户不必再进详情点一次。
|
||||
// 异步:提交→轮询出图→刷新。角色三视图由后端在立绘落库后自动接力,
|
||||
// 避免页面刷新/离开时漏掉,也避免这里重复创建第二个三视图任务。
|
||||
// referenceAssetId:角色重跑时传当前立绘 → 后端走 image_edit 参考它,保持人物一致
|
||||
const assetId = await submitAndPollAsset(
|
||||
() => api.generateBaseAsset(pipelineProject.id, { kind, prompt, label, reference_asset_id: referenceAssetId }),
|
||||
kind === "person" ? "" : "基础资产已生成",
|
||||
);
|
||||
if (!assetId) return null;
|
||||
if (kind === "person") {
|
||||
await submitAndPollAsset(
|
||||
() => api.generateTriview(pipelineProject.id, { portrait_asset_id: assetId }),
|
||||
"角色立绘与三视图已生成",
|
||||
);
|
||||
}
|
||||
return { adopted_asset: assetId };
|
||||
}}
|
||||
onGenerateStoryboard={(prompt) =>
|
||||
|
||||
@@ -83,6 +83,7 @@ export type QuickCreateJob = {
|
||||
};
|
||||
result: null | {
|
||||
video_url: string;
|
||||
final_video_url?: string;
|
||||
poster_url: string;
|
||||
duration_seconds: number;
|
||||
aspect_ratio: string;
|
||||
|
||||
@@ -1034,7 +1034,28 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
.as-action-bar > span svg { width: 14px; height: 14px; flex-shrink: 0; }
|
||||
.as-action-bar > div { display: flex; gap: 9px; flex-shrink: 0; }
|
||||
.as-action-bar > div { display: flex; align-items: center; gap: 9px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.as-spec-fields {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.as-spec-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.as-spec-field:nth-child(1),
|
||||
.as-spec-field:nth-child(2) { width: 118px; }
|
||||
.as-spec-field:nth-child(3) { width: 156px; }
|
||||
.as-spec-field > span {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: var(--pl-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stage-foot .as-spec-fields { margin-right: 2px; }
|
||||
.as-action-bar .pl-ghost,
|
||||
.as-action-bar .pl-next { height: 42px; min-height: 42px; }
|
||||
.as-ai-btn,
|
||||
|
||||
@@ -433,7 +433,9 @@
|
||||
}
|
||||
.projects-page .vc-grid.list .vc-thumb {
|
||||
width: 184px;
|
||||
height: 132px;
|
||||
height: 100%;
|
||||
min-height: 132px;
|
||||
align-self: stretch;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
.projects-page .vc-grid.list .vc-play {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const QUICK_JOB_KEY = "airshelf:quick-create-job";
|
||||
|
||||
export function isQuickCreateBusy(project?: { quick_create_status?: string } | null) {
|
||||
return project?.quick_create_status === "queued" || project?.quick_create_status === "running";
|
||||
}
|
||||
|
||||
export function lockedQuickCreateProject<T extends { quick_create_status?: string }>(
|
||||
listed?: T | null,
|
||||
detailed?: T | null,
|
||||
): T | null {
|
||||
const source = detailed || listed || null;
|
||||
return source && isQuickCreateBusy(source) ? source : null;
|
||||
}
|
||||
|
||||
export function withQuickCreateStatus<T extends { id: string; quick_create_status?: string }>(
|
||||
items: T[],
|
||||
projectId: string,
|
||||
status: string,
|
||||
): T[] {
|
||||
return items.map((item) => (item.id === projectId ? { ...item, quick_create_status: status } : item));
|
||||
}
|
||||
|
||||
export function readQuickCreateJobId() {
|
||||
try {
|
||||
return localStorage.getItem(QUICK_JOB_KEY) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberQuickCreateJob(jobId?: string) {
|
||||
if (!jobId) return;
|
||||
try {
|
||||
localStorage.setItem(QUICK_JOB_KEY, jobId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function forgetQuickCreateJob() {
|
||||
try {
|
||||
localStorage.removeItem(QUICK_JOB_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import type { BillingSummary, Product, Project } from "../types";
|
||||
import type { NavigateFn, Page } from "./route-config";
|
||||
import { ConfirmModal } from "../components/overlays";
|
||||
import { isQuickCreateBusy } from "../quick-create-lock";
|
||||
|
||||
type DashTab = "all" | "wip" | "done";
|
||||
type EntryTone = "primary" | "subtle";
|
||||
@@ -82,7 +83,7 @@ function isQuickCreateProject(project: Project) {
|
||||
|
||||
function dashCardMeta(project: Project, productTitle: string): string {
|
||||
const shots = project.video_segment_count ?? project.video_segments?.length ?? 0;
|
||||
const mode = isQuickCreateProject(project) ? "极速成片" : "专业创作";
|
||||
const mode = isQuickCreateBusy(project) ? "极速成片生成中" : isQuickCreateProject(project) ? "极速成片" : "专业创作";
|
||||
return [mode, productTitle, shots ? `${shots} 镜` : null].filter(Boolean).join(" / ");
|
||||
}
|
||||
|
||||
@@ -273,7 +274,7 @@ export function Dashboard({
|
||||
type="button"
|
||||
onClick={(event) => { event.stopPropagation(); openProject(); }}
|
||||
>
|
||||
{project.status === "completed" ? "查看" : "继续"}
|
||||
{isQuickCreateBusy(project) ? "查看进度" : project.status === "completed" ? "查看" : "继续"}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { isPublicGenerationError, presentGenerationError } from "../generation-e
|
||||
import type { Notice, Page } from "./route-config";
|
||||
import { stageOrder, statusPill } from "./stage-config";
|
||||
import { ConfirmModal, MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays";
|
||||
import { DEFAULT_BILLING_RATES, estimateCost } from "../components/free-create/constants";
|
||||
import { DEFAULT_BILLING_RATES, estimateCost, FC_MODELS, modelLabel } from "../components/free-create/constants";
|
||||
import { ModelLibrary } from "../components/model-library";
|
||||
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
||||
import {
|
||||
@@ -286,6 +286,29 @@ const PIPELINE_RAIL = [
|
||||
{ n: "04", title: "故事板", desc: "生成并确认视频分镜画面" },
|
||||
{ n: "05", title: "视频生成", desc: "按故事板生成视频片段" },
|
||||
];
|
||||
const OUTPUT_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 OUTPUT_RESOLUTIONS = [
|
||||
{ value: "480p", label: "480p 流畅" },
|
||||
{ value: "720p", label: "720p 高清" },
|
||||
{ value: "1080p", label: "1080p 超清" },
|
||||
{ value: "4k", label: "4K 超清" },
|
||||
];
|
||||
const DEFAULT_VIDEO_MODEL_NAME = "doubao-seedance-2-0-260128";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const PIPELINE_HEAD: Record<number, { title: string; desc: string; status: string }> = {
|
||||
1: { title: "脚本创建", desc: "围绕商品卖点组织镜头脚本,确认后进入下一步内容生产", status: "镜头脚本" },
|
||||
2: { title: "资产选择", desc: "准备故事板所需的商品、角色与场景资产,确保后续画面保持一致", status: "资产选择" },
|
||||
@@ -1155,13 +1178,111 @@ export function PipelinePage(props: {
|
||||
const [chargeConfirm, setChargeConfirm] = useState<"storyboard" | "video" | null>(null);
|
||||
const sbChargeShots = shots.length || sbExpectedShots;
|
||||
const sbChargePoints = sbChargeShots * pts(20);
|
||||
const defaultVideoModel = (videoModels ?? []).find((m) => m.status === "active") || (videoModels ?? [])[0];
|
||||
const videoConfigs = videoModels ?? [];
|
||||
const defaultVideoModel = videoConfigs.find((m) => m.status === "active" && m.name === DEFAULT_VIDEO_MODEL_NAME)
|
||||
|| videoConfigs.find((m) => m.status === "active")
|
||||
|| videoConfigs[0];
|
||||
const [outputAspect, setOutputAspect] = useState(() => project.metadata?.wizard?.aspect_ratio || "9:16");
|
||||
const [outputResolution, setOutputResolution] = useState(() => String(project.metadata?.wizard?.resolution || "720p").toLowerCase());
|
||||
const [outputModelId, setOutputModelId] = useState(() => project.metadata?.wizard?.video_model_config_id || defaultVideoModel?.id || "");
|
||||
const specRef = useRef({ aspect: outputAspect, resolution: outputResolution, modelId: outputModelId });
|
||||
useEffect(() => {
|
||||
const wiz = project.metadata?.wizard;
|
||||
const next = {
|
||||
aspect: wiz?.aspect_ratio || "9:16",
|
||||
resolution: String(wiz?.resolution || "720p").toLowerCase(),
|
||||
modelId: wiz?.video_model_config_id || defaultVideoModel?.id || "",
|
||||
};
|
||||
specRef.current = next;
|
||||
setOutputAspect(next.aspect);
|
||||
setOutputResolution(next.resolution);
|
||||
setOutputModelId(next.modelId);
|
||||
}, [project.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
if (outputModelId || !defaultVideoModel?.id) return;
|
||||
specRef.current = { ...specRef.current, modelId: defaultVideoModel.id };
|
||||
setOutputModelId(defaultVideoModel.id);
|
||||
}, [defaultVideoModel?.id, outputModelId]);
|
||||
const outputModel = videoConfigs.find((model) => model.id === outputModelId) || defaultVideoModel;
|
||||
const supportedResolutions = modelResolutions(outputModel);
|
||||
useEffect(() => {
|
||||
if (!supportedResolutions.length || supportedResolutions.includes(outputResolution)) return;
|
||||
const nextResolution = supportedResolutions.includes("720p") ? "720p" : supportedResolutions[0];
|
||||
specRef.current = { ...specRef.current, resolution: nextResolution };
|
||||
setOutputResolution(nextResolution);
|
||||
}, [outputResolution, supportedResolutions]);
|
||||
function specWizardPatch(aspect: string, resolution: string, modelId: string) {
|
||||
const model = videoConfigs.find((item) => item.id === modelId) || defaultVideoModel;
|
||||
return {
|
||||
aspect_ratio: aspect,
|
||||
resolution,
|
||||
video_model_config_id: modelId,
|
||||
video_model_name: model?.name || "",
|
||||
video_model_label: FC_MODELS.find((item) => item.name === model?.name)?.label || model?.display_name || modelLabel(model?.name || ""),
|
||||
};
|
||||
}
|
||||
async function persistOutputSpec() {
|
||||
const { aspect, resolution, modelId } = specRef.current;
|
||||
const wizard = { ...(project.metadata?.wizard ?? {}), ...specWizardPatch(aspect, resolution, modelId) };
|
||||
await api.updateProject(project.id, { metadata: { ...(project.metadata ?? {}), wizard } });
|
||||
}
|
||||
function changeOutputSpec(patch: { aspect_ratio?: string; resolution?: string; video_model_config_id?: string }) {
|
||||
let nextAspect = patch.aspect_ratio ?? specRef.current.aspect;
|
||||
let nextResolution = patch.resolution ?? specRef.current.resolution;
|
||||
const nextModelId = patch.video_model_config_id ?? specRef.current.modelId;
|
||||
const nextModel = videoConfigs.find((model) => model.id === nextModelId) || outputModel;
|
||||
if (patch.video_model_config_id) {
|
||||
const supported = modelResolutions(nextModel);
|
||||
if (supported.length && !supported.includes(nextResolution)) {
|
||||
nextResolution = supported.includes("720p") ? "720p" : supported[0];
|
||||
}
|
||||
}
|
||||
specRef.current = { aspect: nextAspect, resolution: nextResolution, modelId: nextModelId };
|
||||
setOutputAspect(nextAspect);
|
||||
setOutputResolution(nextResolution);
|
||||
setOutputModelId(nextModelId);
|
||||
const wizard = { ...(project.metadata?.wizard ?? {}), ...specWizardPatch(nextAspect, nextResolution, nextModelId) };
|
||||
void api.updateProject(project.id, { metadata: { ...(project.metadata ?? {}), wizard } });
|
||||
}
|
||||
const outputSpecSummary = `${FC_MODELS.find((item) => item.name === outputModel?.name)?.label || outputModel?.display_name || modelLabel(outputModel?.name || "") || "视频模型"} · ${outputAspect} · ${outputResolution}`;
|
||||
const videoPrompt = outputAspect === "1:1"
|
||||
? "方形电商短视频,镜头稳定,商品露出清晰,节奏有转化感"
|
||||
: (outputAspect === "9:16" || outputAspect === "3:4")
|
||||
? "竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感"
|
||||
: "横屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感";
|
||||
function renderOutputSpecFields() {
|
||||
return (
|
||||
<div className="as-spec-fields" aria-label="成片规格">
|
||||
<label className="as-spec-field">
|
||||
<select className="select" value={outputAspect} onChange={(event) => changeOutputSpec({ aspect_ratio: event.target.value })}>
|
||||
{OUTPUT_RATIOS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="as-spec-field">
|
||||
<select className="select" value={outputResolution} onChange={(event) => changeOutputSpec({ resolution: event.target.value })}>
|
||||
{OUTPUT_RESOLUTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value} disabled={Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value))}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="as-spec-field">
|
||||
<select className="select" value={outputModelId} onChange={(event) => changeOutputSpec({ video_model_config_id: event.target.value })} disabled={!videoConfigs.length}>
|
||||
{videoConfigs.length ? videoConfigs.map((config) => (
|
||||
<option key={config.id} value={config.id}>{FC_MODELS.find((item) => item.name === config.name)?.label || config.display_name || modelLabel(config.name)}</option>
|
||||
)) : <option value="">暂无模型</option>}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const videoChargeDurations = segments.length
|
||||
? segments.map((s) => s.target_duration_seconds || 15)
|
||||
: shots.map((s) => shotSeconds(s));
|
||||
const videoChargeShots = videoChargeDurations.length;
|
||||
const videoChargePoints = videoChargeDurations.reduce(
|
||||
(sum, duration) => sum + estimateCost(defaultVideoModel, { ratio: "9:16", resolution: "720p", duration, refs: [] }, billingRates).points,
|
||||
(sum, duration) => sum + estimateCost(outputModel, { ratio: outputAspect, resolution: outputResolution, duration, refs: [] }, billingRates).points,
|
||||
0,
|
||||
);
|
||||
const sbNextLabel = sbAnyImage || sbAnyGenerating
|
||||
@@ -1192,18 +1313,22 @@ export function PipelinePage(props: {
|
||||
setRerunPending((s) => { if (!s.has(segId)) return s; const n = new Set(s); n.delete(segId); return n; });
|
||||
function submitVideoOptimistic(segId: string, prompt: string) {
|
||||
setRerunPending((s) => new Set(s).add(segId));
|
||||
Promise.resolve(onSubmitVideo(segId, prompt))
|
||||
.then((res) => { if (res == null) clearRerunPending(segId); }) // 失败(action 返回 null)→ 立即解禁可重试;成功交给下方 effect
|
||||
void persistOutputSpec()
|
||||
.catch(() => undefined)
|
||||
.then(() => Promise.resolve(onSubmitVideo(segId, prompt)))
|
||||
.then((res) => { if (res == null) clearRerunPending(segId); })
|
||||
.catch(() => clearRerunPending(segId));
|
||||
window.setTimeout(() => clearRerunPending(segId), 20000); // 兜底:异常下也别永久禁用
|
||||
window.setTimeout(() => clearRerunPending(segId), 20000);
|
||||
}
|
||||
// 「全部重跑」乐观态:把将被提交的段(非在途)全部立刻标 pending → 每张卡马上转圈,不必等状态回传
|
||||
function submitAllVideosOptimistic() {
|
||||
const ids = segments.filter((s) => !["running", "queued"].includes(s.status)).map((s) => s.id);
|
||||
if (ids.length === 0) return;
|
||||
setRerunPending((s) => { const n = new Set(s); ids.forEach((id) => n.add(id)); return n; });
|
||||
Promise.resolve(onSubmitAllVideos(videoPrompt))
|
||||
.then((res) => { if (res == null) ids.forEach(clearRerunPending); }) // 失败 → 解禁;成功交给上面的 effect 逐个摘除
|
||||
void persistOutputSpec()
|
||||
.catch(() => undefined)
|
||||
.then(() => Promise.resolve(onSubmitAllVideos(videoPrompt)))
|
||||
.then((res) => { if (res == null) ids.forEach(clearRerunPending); })
|
||||
.catch(() => ids.forEach(clearRerunPending));
|
||||
ids.forEach((id) => window.setTimeout(() => clearRerunPending(id), 20000));
|
||||
}
|
||||
@@ -1950,9 +2075,11 @@ export function PipelinePage(props: {
|
||||
const [storyboardPrompt, setStoryboardPrompt] = useState(sbSavedPrompt || SB_PROMPT_DEFAULT);
|
||||
const startStoryboardGeneration = () => {
|
||||
setSbGenerating(true);
|
||||
void Promise.resolve(onGenerateStoryboard(storyboardPrompt || SB_PROMPT_DEFAULT)).finally(() => setSbGenerating(false));
|
||||
void persistOutputSpec()
|
||||
.catch(() => undefined)
|
||||
.then(() => onGenerateStoryboard(storyboardPrompt || SB_PROMPT_DEFAULT))
|
||||
.finally(() => setSbGenerating(false));
|
||||
};
|
||||
const videoPrompt = "竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感";
|
||||
const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version));
|
||||
|
||||
// ── Stage 5 · 真实视频播放器:时间轴 clips 当作播放列表,逐段播真实视频文件 ──
|
||||
@@ -3708,6 +3835,11 @@ export function PipelinePage(props: {
|
||||
<footer className="as-action-bar">
|
||||
<span><Info />确认后将使用以上资产创建故事板,后续仍可替换。提取 {pts(10)} / 人物 {pts(20)} / 场景 {pts(20)} · 失败不扣</span>
|
||||
<div>
|
||||
{sbAnyImage || sbAnyGenerating ? (
|
||||
<span className="pill pill-l2 neutral" title="后续生成视频将使用此设置">
|
||||
<span className="dot"></span>当前成片 · {outputSpecSummary}
|
||||
</span>
|
||||
) : renderOutputSpecFields()}
|
||||
<button className="pl-ghost" type="button" onClick={() => goStage(1)}><ArrowLeft /><span>返回脚本</span></button>
|
||||
<button
|
||||
className="pl-next"
|
||||
@@ -3878,6 +4010,9 @@ export function PipelinePage(props: {
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ image-2 逐场输出 · {cardCount ? `${cardCount} 场` : "0 场"} · 单场可重跑,失败不扣 ]</span></div>
|
||||
<div className="hstack">
|
||||
<span className="pill pill-l2 neutral" title="后续生成视频将使用此设置">
|
||||
<span className="dot"></span>当前成片 · {outputSpecSummary}
|
||||
</span>
|
||||
<button className="btn" type="button" onClick={() => goStage(2)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回资产</button>
|
||||
<div className="sb-confirm-wrap">
|
||||
{sbConfirmHint && (
|
||||
@@ -4881,14 +5016,14 @@ export function PipelinePage(props: {
|
||||
detail={chargeConfirm === "video"
|
||||
? (
|
||||
<>
|
||||
将按故事板生成 <b>{videoChargeShots || "多"} 段</b>视频,预估扣除 <b>{videoChargePoints > 0 ? `${videoChargePoints} 积分` : "按实际用量结算"}</b>
|
||||
将按故事板生成 <b>{videoChargeShots || "多"} 段</b>视频({outputSpecSummary}),预估扣除 <b>{videoChargePoints > 0 ? `${videoChargePoints} 积分` : "按实际用量结算"}</b>
|
||||
{videoChargeShots > 0 && videoChargePoints > 0 ? `(约 ${Math.round(videoChargePoints / videoChargeShots)} 积分/段)` : ""}。
|
||||
确认后进入视频页并开始生成。
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
将按脚本生成 <b>{sbChargeShots || "多"} 场</b>分镜图,预估扣除 <b>{sbChargePoints > 0 ? `${sbChargePoints} 积分` : "按实际用量结算"}</b>
|
||||
将按脚本生成 <b>{sbChargeShots || "多"} 场</b>分镜图({outputAspect}),预估扣除 <b>{sbChargePoints > 0 ? `${sbChargePoints} 积分` : "按实际用量结算"}</b>
|
||||
{sbChargeShots > 0 ? `(${pts(20)} 积分/镜 × ${sbChargeShots} 场)` : ""}。
|
||||
确认后进入故事板并开始生成。
|
||||
</>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Pager } from "../components/pager";
|
||||
import { SkeletonGrid } from "../components/loading";
|
||||
import { useViewMode } from "../components/use-view-mode";
|
||||
import { api } from "../api";
|
||||
import { isQuickCreateBusy } from "../quick-create-lock";
|
||||
|
||||
const PROD_PAGE_SIZE = 10;
|
||||
// 商品详情「AI 素材」grid 每页条数(4 列 → 3 整行)
|
||||
@@ -663,6 +664,7 @@ function pdAssetTypeLabel(asset: Asset): string {
|
||||
// 项目状态 → 分桶 / 友好标签 / pill 类(对齐 projects.tsx 语义,组件内自洽)
|
||||
function pdProjBucket(project: Project) { return project.status === "completed" ? "done" : project.status === "failed" ? "fail" : "wip"; }
|
||||
function pdProjStatusLabel(project: Project) {
|
||||
if (isQuickCreateBusy(project)) return "极速成片生成中";
|
||||
return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record<string, string>)[project.status] || "进行中";
|
||||
}
|
||||
function pdProjPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "err" : "info"; }
|
||||
|
||||
@@ -10,6 +10,7 @@ import { isLocalLife } from "../product-business";
|
||||
import { Pager } from "../components/pager";
|
||||
import { SkeletonRows } from "../components/loading";
|
||||
import { useViewMode } from "../components/use-view-mode";
|
||||
import { isQuickCreateBusy } from "../quick-create-lock";
|
||||
import "../project-wizard-page.css";
|
||||
|
||||
const PROJ_PAGE_SIZE = 8; // 4 列网格两行
|
||||
@@ -451,6 +452,7 @@ function isQuickCreateProject(project: Project) {
|
||||
}
|
||||
|
||||
function projectModeLabel(project: Project) {
|
||||
if (isQuickCreateBusy(project)) return "极速成片生成中";
|
||||
return isQuickCreateProject(project) ? "极速成片" : "专业创作";
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -58,6 +58,10 @@ export type NavigateOptions = {
|
||||
hash?: string;
|
||||
// 视频项目页初始 tab(all/wip/done/fail):仅经 route state 透传给 ProjectsPage,不进 URL path。
|
||||
tab?: string;
|
||||
// 极速成片已结束时仍要进专业模式,跳过「生成中」锁。
|
||||
forcePipeline?: boolean;
|
||||
// 与 forcePipeline 一起用:立刻把列表里的极速成片状态改成非生成中,避免被旧 running 弹回。
|
||||
quickCreateStatus?: string;
|
||||
};
|
||||
export type NavigateFn = (page: Page, options?: NavigateOptions) => void;
|
||||
export type Notice = { type: "success" | "error" | "info"; text: string } | null;
|
||||
|
||||
@@ -514,6 +514,8 @@ export type Project = {
|
||||
final_video_url?: string;
|
||||
// 是否由极速成片入口创建;列表页据此显示「极速成片」而不是「专业创作」
|
||||
quick_create?: boolean;
|
||||
quick_create_status?: string;
|
||||
quick_create_job_id?: string;
|
||||
stages: ProjectStage[];
|
||||
script_versions: ScriptVersion[];
|
||||
video_segments: VideoSegment[];
|
||||
@@ -550,6 +552,11 @@ export type Project = {
|
||||
template_id?: string;
|
||||
template_name?: string;
|
||||
template_outline?: string;
|
||||
aspect_ratio?: string;
|
||||
resolution?: string;
|
||||
video_model_config_id?: string;
|
||||
video_model_name?: string;
|
||||
video_model_label?: string;
|
||||
};
|
||||
cast?: string[];
|
||||
scenes?: string[];
|
||||
|
||||
Reference in New Issue
Block a user