完善二期清单
This commit is contained in:
@@ -8,13 +8,15 @@ import { isPublicGenerationError, presentGenerationError } from "../generation-e
|
||||
import type { Notice, Page } from "./route-config";
|
||||
import { money, stageOrder, statusPill } from "./stage-config";
|
||||
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
|
||||
import { MediaLightbox, useBodyScrollLock } from "../components/overlays";
|
||||
import { MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays";
|
||||
import { ModelLibrary } from "../components/model-library";
|
||||
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
||||
import { IconKitSvg } from "../components/IconKitSvg";
|
||||
import {
|
||||
allowedStructures,
|
||||
clampDuration,
|
||||
coercePresentationFormat,
|
||||
coerceVideoStructure,
|
||||
DURATION_OPTIONS,
|
||||
durationWarning,
|
||||
isForbidden,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
type PresentationFormat,
|
||||
type VideoStructure,
|
||||
} from "../script-setup";
|
||||
import { isLocalLife } from "../product-business";
|
||||
|
||||
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
|
||||
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
|
||||
@@ -50,6 +53,15 @@ const VO_VOICES = [
|
||||
// 新建向导落进 metadata.wizard 的是选项 key,这里映射回中文(对齐 projects.tsx 的 WIZ_PERSONAS)
|
||||
// 一期的「风格」(真实测评/痛点种草/…)已被二期的「视频结构」取代,见 script-setup.ts
|
||||
const WIZ_PERSONA_LABEL: Record<string, string> = { urban: "都市白领女性", bestie: "闺蜜种草", ceo: "总裁亲选", reviewer: "专业测评师", mom: "实用宝妈", genz: "学生党" };
|
||||
const PERSONA_KEY_BY_LABEL: Record<string, string> = {
|
||||
...Object.fromEntries(Object.entries(WIZ_PERSONA_LABEL).map(([key, label]) => [label, key])),
|
||||
专业测评: "reviewer",
|
||||
};
|
||||
function coercePersona(value: unknown, fallback = "urban"): string {
|
||||
if (typeof value !== "string" || !value) return fallback;
|
||||
if (WIZ_PERSONA_LABEL[value]) return value;
|
||||
return PERSONA_KEY_BY_LABEL[value] || fallback;
|
||||
}
|
||||
// 视频片段状态 pill 文案(色调走 statusPill:ok/info/err/neutral)
|
||||
function statusLabel(status: string): string {
|
||||
if (["succeeded", "completed", "done", "ok"].includes(status)) return "完成";
|
||||
@@ -1276,12 +1288,12 @@ export function PipelinePage(props: {
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
const [setupSource, setSetupSource] = useState<"ai" | "manual" | "video">("ai");
|
||||
const [setupFormat, setSetupFormat] = useState<PresentationFormat>(
|
||||
(wizard?.presentation_format as PresentationFormat) || recommended.format
|
||||
coercePresentationFormat(wizard?.presentation_format, recommended.format)
|
||||
);
|
||||
const [setupStructure, setSetupStructure] = useState<VideoStructure>(
|
||||
(wizard?.video_structure as VideoStructure) || recommended.structure
|
||||
coerceVideoStructure(wizard?.video_structure, recommended.structure)
|
||||
);
|
||||
const [setupPersona, setSetupPersona] = useState<string>(wizard?.persona || SETUP_PERSONA_KEYS[0] || "urban");
|
||||
const [setupPersona, setSetupPersona] = useState<string>(coercePersona(wizard?.persona, SETUP_PERSONA_KEYS[0] || "urban"));
|
||||
const [setupDuration, setSetupDuration] = useState<number>(() => {
|
||||
if (typeof wizard?.total_duration === "number") return clampDuration(wizard.total_duration);
|
||||
// 兼容一期向导存的字符串档位("0-30" → 30)
|
||||
@@ -1299,6 +1311,36 @@ export function PipelinePage(props: {
|
||||
setSetupStructure(recommended.structure);
|
||||
if (!wizardHasDuration) setSetupDuration(recommended.duration);
|
||||
}, [recommended.format, recommended.structure, recommended.duration, wizardHasCombo, wizardHasDuration]);
|
||||
|
||||
// ── 5.2 换商品重跑 ── 新建向导选的套路模板由后端回填进 metadata.wizard,这里只读不写
|
||||
const templateName = typeof wizard?.template_name === "string" ? wizard.template_name : "";
|
||||
const templateOutline = typeof wizard?.template_outline === "string" ? wizard.template_outline : "";
|
||||
|
||||
// ── 5.1 存为模板 ── 把这一版脚本的套路存进团队模板库,换商品重开项目时套用
|
||||
const [tplOpen, setTplOpen] = useState(false);
|
||||
const [tplName, setTplName] = useState("");
|
||||
const [tplSaving, setTplSaving] = useState(false);
|
||||
function openSaveTemplate() {
|
||||
const formatLabel = PRESENTATION_FORMATS[setupFormat];
|
||||
const structureLabel = VIDEO_STRUCTURES[setupStructure];
|
||||
setTplName(`${formatLabel} · ${structureLabel} · ${shots.length} 镜`);
|
||||
setTplOpen(true);
|
||||
}
|
||||
async function saveTemplate() {
|
||||
const name = tplName.trim();
|
||||
if (name.length < 2 || tplSaving || !currentScript) return;
|
||||
setTplSaving(true);
|
||||
try {
|
||||
const tpl = await api.saveProjectAsTemplate(project.id, { name, script_version_id: currentScript.id });
|
||||
setTplOpen(false);
|
||||
onNotify?.("success", `已存为套路模板「${tpl.name}」,新建项目时可选它换商品重跑`);
|
||||
} catch (error) {
|
||||
onNotify?.("error", error instanceof Error ? error.message : "存模板失败,请重试");
|
||||
} finally {
|
||||
setTplSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 1.8 组合联动:表现形式为主,视频结构按它筛;当前选中的若被筛掉就自动落到第一个合法项
|
||||
const structureOptions = useMemo(() => allowedStructures(setupFormat), [setupFormat]);
|
||||
function pickFormat(next: PresentationFormat) {
|
||||
@@ -1595,18 +1637,21 @@ export function PipelinePage(props: {
|
||||
// 1.5 · 确认设定后真正发起生成。表现形式/结构/时长走结构化参数(后端挑套路),
|
||||
// 人物没有对应的后端字段,仍并进 prompt 让模型读。
|
||||
async function runScriptWithSetup() {
|
||||
const formatLabel = PRESENTATION_FORMATS[setupFormat];
|
||||
const structureLabel = VIDEO_STRUCTURES[setupStructure];
|
||||
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
|
||||
const format = coercePresentationFormat(setupFormat);
|
||||
const structure = coerceVideoStructure(setupStructure);
|
||||
const persona = coercePersona(setupPersona);
|
||||
const formatLabel = PRESENTATION_FORMATS[format];
|
||||
const structureLabel = VIDEO_STRUCTURES[structure];
|
||||
const personaLabel = WIZ_PERSONA_LABEL[persona] || persona;
|
||||
// 持久化到 metadata.wizard。先 await 落库(设定卡仍开着、确定 disabled),
|
||||
// 存完再「关卡」一帧切换,不出现空窗 → 不闪回入口菜单。
|
||||
await onSaveProjectMeta?.({
|
||||
wizard: {
|
||||
...(project.metadata?.wizard ?? {}),
|
||||
presentation_format: setupFormat,
|
||||
video_structure: setupStructure,
|
||||
presentation_format: format,
|
||||
video_structure: structure,
|
||||
total_duration: setupDuration,
|
||||
persona: setupPersona,
|
||||
persona,
|
||||
},
|
||||
});
|
||||
setSetupOpen(false);
|
||||
@@ -1645,9 +1690,16 @@ export function PipelinePage(props: {
|
||||
return;
|
||||
}
|
||||
const sourceLabel = SOURCE_LABEL[setupSource] || "脚本辅助生成";
|
||||
// 5.2 换商品重跑:新建项目时选了套路模板 → 把模板的镜序/节奏/转化写法并进 prompt,
|
||||
// 但明确要求文案按当前商品重写(模板里不含旧文案,转化写法可能带旧品牌词,这里再挡一道)。
|
||||
const tplLine = templateOutline
|
||||
? `\n请照这个套路重出一版:\n${templateOutline}\n`
|
||||
+ `镜数、每镜作用与时长按上面来,文案全部按我当前的商品重新写,`
|
||||
+ `参考写法里出现的旧商品、品牌、价格一律不要保留。`
|
||||
: "";
|
||||
await runScriptGeneration(
|
||||
`目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放`,
|
||||
`${sourceLabel}:${combo}`,
|
||||
`目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放${tplLine}`,
|
||||
templateName ? `套用模板「${templateName}」:${combo}` : `${sourceLabel}:${combo}`,
|
||||
"ai",
|
||||
);
|
||||
}
|
||||
@@ -2473,13 +2525,22 @@ export function PipelinePage(props: {
|
||||
return () => window.clearInterval(timer);
|
||||
}, [activeVideoCount, onPollVideosQuiet]);
|
||||
|
||||
// Stage 5:进入拼接页时回填已有导出成片(若此前导过)
|
||||
// 进入视频 / 拼接阶段时回填已有成片(此前合成过就直接给出播放/下载入口)
|
||||
useEffect(() => {
|
||||
if (viewStage !== 5) return;
|
||||
if (viewStage !== 4 && viewStage !== 5) return;
|
||||
onRefreshExport();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewStage]);
|
||||
|
||||
// 合成中:静默轮进度。合成本身由发起方(App.submitExport)在轮,这里是为「合成中刷新了页面」
|
||||
// 兜底 —— 否则发起方的轮询循环随刷新丢掉,界面会卡在旧百分比、按钮一直禁用。
|
||||
const exportRunning = exportResult?.status === "queued" || exportResult?.status === "running";
|
||||
useEffect(() => {
|
||||
if (!exportRunning || (viewStage !== 4 && viewStage !== 5)) return;
|
||||
const timer = setInterval(() => onRefreshExport(), 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [exportRunning, viewStage, onRefreshExport]);
|
||||
|
||||
// 确认脚本:采用当前脚本(后端推进 SCRIPT→BASE_ASSETS),再进入资产阶段。无脚本时仅切视图。
|
||||
async function confirmScript() {
|
||||
if (currentScript && !scriptAdopted) {
|
||||
@@ -2645,6 +2706,7 @@ export function PipelinePage(props: {
|
||||
// 真实商品名 + 封面资产 id(商品组无 adopted_asset 时,商品缩图回退到商品库封面)
|
||||
const productRecord = products.find((item) => item.id === project.product);
|
||||
const productName = productRecord?.title || "透真补水面膜";
|
||||
const isLocalProduct = isLocalLife(productRecord);
|
||||
const productCover = productRecord?.cover_asset || productRecord?.images?.find((img) => img.is_primary)?.asset || productRecord?.images?.[0]?.asset || null;
|
||||
|
||||
// 三视图的「上传」通道:独立上传框,不再借「替换」弹窗绕。AI 生成通道原样保留,两条路并存。
|
||||
@@ -2795,21 +2857,14 @@ export function PipelinePage(props: {
|
||||
<div className="script-brief-summary" aria-label="当前创作方向">
|
||||
{/* 真实创作方向:来源=已有脚本的 source(无脚本时跟随所选模式),其余=设定卡确认时存进 metadata.wizard 的 */}
|
||||
<span className="pill neutral script-brief-pill"><span className="k">来源</span><span className="v" id="brief-source">{currentScript ? (SOURCE_LABEL[currentScript.source || "ai"] || "脚本辅助生成") : setupOpen ? SOURCE_LABEL[setupSource] : "未选择"}</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">形式</span><span className="v" id="brief-format">{(() => {
|
||||
const k = wizard?.presentation_format as PresentationFormat | undefined;
|
||||
// 旧项目只有一期的「风格」,没有表现形式 —— 显示待确认,别硬套
|
||||
return k && PRESENTATION_FORMATS[k] ? PRESENTATION_FORMATS[k] : "待确认";
|
||||
})()}</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">结构</span><span className="v" id="brief-structure">{(() => {
|
||||
const k = wizard?.video_structure as VideoStructure | undefined;
|
||||
return k && VIDEO_STRUCTURES[k] ? VIDEO_STRUCTURES[k] : "待确认";
|
||||
})()}</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">形式</span><span className="v" id="brief-format">{wizard?.presentation_format ? PRESENTATION_FORMATS[coercePresentationFormat(wizard.presentation_format)] : "待确认"}</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">结构</span><span className="v" id="brief-structure">{wizard?.video_structure ? VIDEO_STRUCTURES[coerceVideoStructure(wizard.video_structure)] : "待确认"}</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">时长</span><span className="v" id="brief-duration">{(() => {
|
||||
// 有脚本时按各镜真实秒数加总(镜可以不等长了);没脚本就显示设定卡里选的
|
||||
const scripted = (currentScript?.segments || []).reduce((sum, s) => sum + (s.duration_seconds || 0), 0);
|
||||
return `${scripted || setupDuration} 秒`;
|
||||
})()}</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">人物</span><span className="v" id="brief-persona">{(() => { const k = wizard?.persona; return k ? (WIZ_PERSONA_LABEL[k] || k) : "待确认"; })()}</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">人物</span><span className="v" id="brief-persona">{wizard?.persona ? (WIZ_PERSONA_LABEL[coercePersona(wizard.persona)] || wizard.persona) : "待确认"}</span></span>
|
||||
</div>
|
||||
{/* 行34/流程步骤3 · 人物 / 场景标签:AI 出稿自动提取;增删不立即生效,排进待确认队列,
|
||||
点发送才整体重写。待删的灰删除线、待加的虚线高亮,均可在输入框上方胶囊撤销。 */}
|
||||
@@ -2990,7 +3045,14 @@ export function PipelinePage(props: {
|
||||
{setupOpen && (
|
||||
<div className="msg ai">
|
||||
<div className="bubble setup-card">
|
||||
<div className="setup-lead">{setupSource === "manual" ? "已选「上传脚本」。" : setupSource === "video" ? "已选「上传视频提炼」。" : "我会根据商品信息直接生成第一版。"}先确认创作方向。</div>
|
||||
<div className="setup-lead">{setupSource === "manual" ? "已选「上传脚本」。" : setupSource === "video" ? "已选「上传视频提炼」。" : templateName ? `已套用模板「${templateName}」,套路参数已填好。` : "我会根据商品信息直接生成第一版。"}先确认创作方向。</div>
|
||||
{/* 5.3 预期对齐:先讲清「同套路重出 ≠ 旧片换个商品名」,免得用户以为是复制粘贴 */}
|
||||
{templateOutline && setupSource === "ai" && (
|
||||
<div className="tpl-expect">
|
||||
<span className="mono">// 同套路重出</span>
|
||||
沿用模板的镜数、每镜作用和节奏,文案、画面、模特与故事板都会按{setupProduct?.title || "当前商品"}重新生成 —— 不是把上一条片子换个商品名。
|
||||
</div>
|
||||
)}
|
||||
<label className="setup-field">
|
||||
<span className="sf-k">表现形式</span>
|
||||
<select className="setup-select" value={setupFormat} onChange={(e) => pickFormat(e.target.value as PresentationFormat)}>
|
||||
@@ -3134,6 +3196,7 @@ export function PipelinePage(props: {
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ 脚本生成 {pts(10)} 积分/次 · 失败不扣 ]</span></div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" disabled={loading || !currentScript} onClick={openSaveTemplate}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 4h12a1 1 0 0 1 1 1v15l-7-4-7 4V5a1 1 0 0 1 1-1z" /></svg> 存为模板</button>
|
||||
<button className="btn" type="button" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑", "重新生成全部", undefined, "auto")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> 重新生成全部</button>
|
||||
<button className="btn btn-primary btn-lg" type="button" disabled={loading || !currentScript} onClick={confirmScript}>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
||||
</div>
|
||||
@@ -3150,7 +3213,7 @@ export function PipelinePage(props: {
|
||||
const adoptedTriAsset = productGroup?.adopted_asset || "";
|
||||
const previewTriAsset = (triPreviewId && productVersions.includes(triPreviewId)) ? triPreviewId : adoptedTriAsset;
|
||||
const hasTriView = productVersions.length > 0;
|
||||
const triPanelShow = triPanelOpen || hasTriView || triGenerating;
|
||||
const triPanelShow = isLocalProduct ? hasTriView : (triPanelOpen || hasTriView || triGenerating);
|
||||
// 商品主图:优先用序列化器内嵌的 preview_url(全局 assets 已不再全量取,assetUrl 反查会落空),
|
||||
// 依次 封面 → 主图 → 首图,最后才回退旧的全局反查。
|
||||
const productAssetUrl =
|
||||
@@ -3227,7 +3290,7 @@ export function PipelinePage(props: {
|
||||
<div className="prod-row">
|
||||
<div className="asset-card-2 prod-lib-card" data-asset-kind="product" data-asset-id={adoptedTriAsset || "prod-main"} id="asset-prod-card">
|
||||
<div className={`placeholder prod-thumb${productAssetUrl ? " has-mock-media" : ""}`} style={productAssetUrl ? mediaStyle(productAssetUrl) : undefined}>
|
||||
{!hasTriView && (
|
||||
{!isLocalProduct && !hasTriView && (
|
||||
<span className="tri-missing-badge" id="asset-prod-tri-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明">
|
||||
<span className="ico" aria-hidden="true"></span>
|
||||
<span className="lbl-mono">缺三视图</span>
|
||||
@@ -3245,16 +3308,19 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
<div className="prod-body">
|
||||
<div className="prod-name" id="asset-prod-card-name">{productName}</div>
|
||||
<div className="prod-cat">{products.find((item) => item.id === project.product)?.category || "未分类"}</div>
|
||||
<div className="prod-cat">{isLocalProduct ? "本地生活" : ""}{isLocalProduct && productRecord?.category ? " · " : ""}{productRecord?.category || (isLocalProduct ? "" : "未分类")}</div>
|
||||
<div className="prod-date">{(project.created_at || "").slice(0, 10)} 创建</div>
|
||||
</div>
|
||||
<div className="prod-action" id="asset-prod-action">
|
||||
{/* 双通道:左「上传三视图」(ghost,用户自带图) + 右「AI 生成三视图」(primary,平台生成) */}
|
||||
{isLocalProduct ? (
|
||||
<div className="mono prod-skip-note">// 本地生活无需商品三视图,可直接进入下一步</div>
|
||||
) : (
|
||||
<div className="hstack">
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={triGenerating || triUploading} onClick={() => triFileRef.current?.click()}>{triUploading ? "上传中…" : "上传三视图"}</button>
|
||||
<span className="spacer"></span>
|
||||
<button className="btn btn-primary btn-sm" type="button" data-stop id="asset-prod-aigen-btn" disabled={triGenerating || triUploading} onClick={runProductTri}>{triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<input ref={triFileRef} type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) void uploadTriViewFromFile(f); e.target.value = ""; }} />
|
||||
@@ -3691,6 +3757,12 @@ export function PipelinePage(props: {
|
||||
: activeVideoCount > 0
|
||||
? `生成中 · ${activeVideoCount} 段进行中(自动刷新)`
|
||||
: "待生成";
|
||||
// 合成成片(ffmpeg 按场次顺序拼成一条):全部场次出片才能合成。
|
||||
// 命名避开本作用域已有的 exporting/exportErr(那是「片段打包 zip 下载」)。
|
||||
const allSegDone = segments.length > 0 && segDone === segments.length;
|
||||
const mergedUrl = exportResult?.status === "succeeded" ? (exportResult.output_url || "") : "";
|
||||
const merging = exportResult?.status === "queued" || exportResult?.status === "running";
|
||||
const mergeProgress = exportResult?.progress ?? 0;
|
||||
return (
|
||||
<section className="stage active" data-stage-pane="4">
|
||||
<div className="queue-bar">
|
||||
@@ -3756,13 +3828,36 @@ export function PipelinePage(props: {
|
||||
)}
|
||||
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ 已完成 {segDone} 场 · 总时长 {segTotalSec}s · 失败不扣 · 通过后扣 ]</span></div>
|
||||
<div className="info">
|
||||
<span className="mono">[ 已完成 {segDone} 场 · 总时长 {segTotalSec}s · 失败不扣 · 通过后扣 ]</span>
|
||||
{merging && <span className="mono" style={{ marginLeft: "10px", color: "var(--heat)" }}>// 合成中 {mergeProgress}%</span>}
|
||||
{exportResult?.status === "failed" && (
|
||||
<span className="mono" style={{ marginLeft: "10px", color: "var(--accent-crimson)" }}>// 合成失败:{exportResult.error_message || "请重试"}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" onClick={() => goStage(3)}><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>
|
||||
{/* V1 雪藏拼接导出:隐藏「进入拼接」入口(代码保留,V2 恢复 false→true) */}
|
||||
{false && (
|
||||
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(5)}>确认视频,进入拼接 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
||||
{/* 合成成片:各场视频按顺序拼成一条完整视频(合成完才有下面的播放/下载) */}
|
||||
{mergedUrl && (
|
||||
<>
|
||||
<button className="btn" type="button" onClick={() => setPreview({ src: mergedUrl, kind: "video", name: `${project.name} · 成片` })}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M6 4l13 8-13 8z" /></svg> 播放成片
|
||||
</button>
|
||||
<a className="btn" href={mergedUrl} target="_blank" rel="noreferrer" download>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" /></svg> 下载成片
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-primary btn-lg"
|
||||
type="button"
|
||||
disabled={!allSegDone || merging}
|
||||
title={allSegDone ? "把各场视频按顺序拼成一条完整视频(不扣积分)" : "所有场次都出片后才能合成"}
|
||||
onClick={() => onSubmitExport()}
|
||||
>
|
||||
{merging ? <><span className="spinner btn-spin" aria-hidden="true" />合成中… {mergeProgress}%</> : mergedUrl ? "重新合成" : "合成完整视频"}
|
||||
{!merging && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -4552,6 +4647,35 @@ export function PipelinePage(props: {
|
||||
onRefresh={() => void onRefreshProject()}
|
||||
/>
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
|
||||
|
||||
{/* 5.1 存为模板 · 先把「会被存进去的」摊开给用户看,再让他起名,避免以为连文案一起存了 */}
|
||||
<TeamModal
|
||||
open={tplOpen}
|
||||
title="存为套路模板"
|
||||
subtitle="// SAVE TEMPLATE"
|
||||
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 4h12a1 1 0 0 1 1 1v15l-7-4-7 4V5a1 1 0 0 1 1-1z" /></svg>}
|
||||
close={() => setTplOpen(false)}
|
||||
footer={<button className="btn btn-primary" type="button" disabled={tplName.trim().length < 2 || tplSaving} onClick={() => void saveTemplate()}>{tplSaving ? "保存中…" : "保存模板"}</button>}
|
||||
>
|
||||
<div className="field">
|
||||
<label className="field-label">模板名称<span className="req">*</span></label>
|
||||
<input className="input" value={tplName} maxLength={40} onChange={(event) => setTplName(event.target.value)} placeholder="例如:口播 · 痛点前置 · 5 镜" />
|
||||
</div>
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label className="field-label">会存进模板的内容</label>
|
||||
<div className="tpl-capture">
|
||||
<div className="row"><span className="k">带货套路</span><span className="v">{PRESENTATION_FORMATS[setupFormat]} · {VIDEO_STRUCTURES[setupStructure]}</span></div>
|
||||
<div className="row"><span className="k">脚本结构</span><span className="v">{shots.map((s) => s.role || "叙述").join(" → ") || "—"}</span></div>
|
||||
<div className="row"><span className="k">镜头节奏</span><span className="v">{shots.length} 镜 · {shots.map((s) => `${s.duration_seconds}s`).join(" / ") || "—"}</span></div>
|
||||
<div className="row"><span className="k">人物设定</span><span className="v">{WIZ_PERSONA_LABEL[setupPersona] || setupPersona}</span></div>
|
||||
<div className="row"><span className="k">转化方式</span><span className="v">{(shots.find((s) => s.role === "CTA") || shots[shots.length - 1])?.narration || "—"}</span></div>
|
||||
</div>
|
||||
<p className="tpl-note">
|
||||
<span className="mono">// NOTE</span> 模板只存上面这套「怎么讲」。这一版脚本里针对
|
||||
{" "}{setupProduct?.title || "当前商品"}{" "}写的口播文案不会存进去 —— 换商品套用时由 AI 按新商品重写。
|
||||
</p>
|
||||
</div>
|
||||
</TeamModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user