测试极速成片

This commit is contained in:
Azmat@qq.com
2026-08-26 15:18:18 +08:00
parent 245525ec53
commit 0ee498d807
30 changed files with 1963 additions and 367 deletions
+147 -12
View File
@@ -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} 场)` : ""}。
确认后进入故事板并开始生成。
</>