测试极速成片
This commit is contained in:
@@ -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} 场)` : ""}。
|
||||
确认后进入故事板并开始生成。
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user