feat(core/frontend): wire settings/avatar/image-gen + real data render (library/product-detail/pipeline)
App.tsx: thread saveProfile/changePassword/uploadAvatar/generateImages handlers + assets prop to pages. - settings.tsx: profile save / password modal / avatar upload wired; notification/theme prefs -> localStorage - library.tsx + product-detail: asset thumbnails + grids render real TOS preview_url - ai-tools ImageWorkbenchPage: 生成图片 wired to /api/ai/generate-image, renders returned assets - pipeline.tsx stage2-5: base_assets/storyboard/video_segments(adopted_asset)/timeline(clips/subtitles/bgm) rendered from real project data; graceful empty states - types.ts: +VideoSegment.adopted_asset, +Timeline.subtitle_tracks/bgm_tracks verified: tsc --noEmit clean; screenshots confirm pipeline stages 2-5 + product-detail render real data+images (demo asset object_keys re-pointed to image objects so thumbnails resolve) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
603584b46b
commit
099bf0e6aa
@@ -1,51 +1,48 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { Play } from "lucide-react";
|
||||
import type { BillingSummary, Product, Project, Team, User } from "../types";
|
||||
import type { Asset, BillingSummary, Product, Project, Team, User } from "../types";
|
||||
import type { Notice, Page } from "./route-config";
|
||||
import { money, stageOrder, statusPill } from "./stage-config";
|
||||
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
|
||||
import { IconKitSvg } from "../components/IconKitSvg";
|
||||
|
||||
// 镜像 shell.js→mock-media.js 给 .placeholder 注入 mock 图;React 手动复刻对应映射
|
||||
const mock = (file: string): CSSProperties => ({ ["--mock-media-url"]: `url(/exact/assets/mock/${file})` } as CSSProperties);
|
||||
|
||||
// Stage 3 故事板 · 镜像 JS 注入的 3 场(全 mock)
|
||||
const SB_SCENES = [
|
||||
{ sid: "sc1", nm: "场 1", sub: "0-15s", frame: "深夜办公桌", img: "scene-office.png" },
|
||||
{ sid: "sc2", nm: "场 2", sub: "15-30s", frame: "面膜包装/特写", img: "product-mask.png" },
|
||||
{ sid: "sc3", nm: "场 3", sub: "30-45s", frame: "化妆台/产品定格", img: "cover-mask-final.png" }
|
||||
];
|
||||
const SB_PROMPT = "中景 / 固定机位\n光线:台灯暖光 + 屏幕冷光\n演员:林夕(疲倦状态)\n关键道具:面膜盒(从抽屉露半角)\n氛围:午夜、安静、些许焦虑";
|
||||
|
||||
// Stage 4 视频 · 镜像 3 场(全 mock,video-thumb 经 mock-media 映射封面图)
|
||||
const VIDEO_CARDS = [
|
||||
{ vid: "v1", frame: "场 1 · 0-15s", title: "场 1 · 深夜办公桌", meta: "15s · 1080×1920 · ¥0.45", img: "cover-mask-v3.png" },
|
||||
{ vid: "v2", frame: "场 2 · 15-27s", title: "场 2 · 面膜包装/特写", meta: "12s · 1080×1920 · ¥0.45", img: "product-mask.png" },
|
||||
{ vid: "v3", frame: "场 3 · 27-40s", title: "场 3 · 化妆台/产品定格", meta: "13s · 1080×1920 · ¥0.45", img: "cover-mask-final.png" }
|
||||
];
|
||||
|
||||
// Stage 5 拼接编辑器 · 全 mock(镜像时间轴引擎按 data-dur 累计定位,React 直接算 left/width)
|
||||
const ED_VIDEO_CLIPS = [
|
||||
{ n: 1, lbl: "深夜办公桌", dur: 2 }, { n: 2, lbl: "面膜包装", dur: 3 }, { n: 3, lbl: "精华液微距", dur: 3 },
|
||||
{ n: 4, lbl: "敷面膜平躺", dur: 3 }, { n: 5, lbl: "化妆台", dur: 2 }, { n: 6, lbl: "产品定格", dur: 2 }
|
||||
];
|
||||
const ED_SUB_CLIPS = [
|
||||
{ lbl: "加班三天 脸已经不能看了…", dur: 2 }, { lbl: "还好我有这个 透真玻尿酸面膜", dur: 3 }, { lbl: "30g 精华 一片顶三片", dur: 3 },
|
||||
{ lbl: "敷完起来脸是软的", dur: 3 }, { lbl: "化妆都能看出来", dur: 2 }, { lbl: "5 片 ¥39.9 囤起来", dur: 2 }
|
||||
];
|
||||
const ED_RULER: Array<{ left: string; major: boolean; t?: string }> = [
|
||||
{ left: "0%", major: true, t: "0s" }, { left: "6.67%", major: false }, { left: "13.33%", major: true, t: "2s" }, { left: "20%", major: false },
|
||||
{ left: "26.67%", major: true, t: "4s" }, { left: "33.33%", major: false }, { left: "40%", major: true, t: "6s" }, { left: "46.67%", major: false },
|
||||
{ left: "53.33%", major: true, t: "8s" }, { left: "60%", major: false }, { left: "66.67%", major: true, t: "10s" }, { left: "73.33%", major: false },
|
||||
{ left: "80%", major: true, t: "12s" }, { left: "86.67%", major: false }, { left: "93.33%", major: true, t: "14s" }, { left: "100%", major: true, t: "15s" }
|
||||
];
|
||||
const ED_WAVE: Array<[number, number]> = [[8,4],[6,8],[3,14],[7,6],[4,12],[2,16],[6,8],[8,4],[5,10],[3,14],[7,6],[4,12],[6,8],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8]];
|
||||
function edLayout<T extends { dur: number }>(clips: T[]): Array<T & { leftPct: number; widthPct: number }> {
|
||||
let acc = 0;
|
||||
return clips.map((c) => { const left = acc; acc += c.dur; return { ...c, leftPct: (left / 15) * 100, widthPct: (c.dur / 15) * 100 }; });
|
||||
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
|
||||
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
|
||||
// 基础资产组 kind → 中文区块名(对齐 design.md 商品/人物/场景三类)
|
||||
const KIND_LABEL: Record<string, string> = { product: "商品", person: "人物", scene: "场景" };
|
||||
// 视频片段状态 pill 文案(色调走 statusPill:ok/info/err/neutral)
|
||||
function statusLabel(status: string): string {
|
||||
if (["succeeded", "completed", "done", "ok"].includes(status)) return "完成";
|
||||
if (["failed", "error"].includes(status)) return "失败";
|
||||
if (["running", "queued", "polling"].includes(status)) return "生成中";
|
||||
if (status === "needs_review") return "待确认";
|
||||
return "待生成";
|
||||
}
|
||||
// 毫秒 → mm:ss(时间轴 / 字幕展示)
|
||||
function fmtMs(ms: number): string {
|
||||
const total = Math.max(0, Math.round(ms / 1000));
|
||||
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// 时间轴 clip 真实定位:start_ms / duration_ms 相对轨道总长(timeline.duration_seconds)算百分比
|
||||
function clipLayout(startMs: number, durationMs: number, totalMs: number): { leftPct: number; widthPct: number } {
|
||||
const total = totalMs > 0 ? totalMs : 1;
|
||||
return { leftPct: Math.max(0, (startMs / total) * 100), widthPct: Math.max(0, Math.min(100, (durationMs / total) * 100)) };
|
||||
}
|
||||
// 标尺刻度:按轨道总秒数生成每秒一刻,偶数秒为主刻度带秒标
|
||||
function buildRuler(totalSec: number): Array<{ leftPct: number; major: boolean; t?: string }> {
|
||||
const secs = Math.max(1, Math.round(totalSec));
|
||||
const ticks: Array<{ leftPct: number; major: boolean; t?: string }> = [];
|
||||
for (let s = 0; s <= secs; s += 1) {
|
||||
const major = s % 2 === 0;
|
||||
ticks.push({ leftPct: (s / secs) * 100, major, t: major ? `${s}s` : undefined });
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
// 装饰用伪波形(BGM 轨铺底,纯视觉,无真实波形数据时使用)
|
||||
const ED_WAVE: Array<[number, number]> = [[8,4],[6,8],[3,14],[7,6],[4,12],[2,16],[6,8],[8,4],[5,10],[3,14],[7,6],[4,12],[6,8],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8]];
|
||||
const STAGE_STEPS: Array<{ n: number; label: string }> = [
|
||||
{ n: 1, label: "脚本" },
|
||||
{ n: 2, label: "基础资产" },
|
||||
@@ -62,6 +59,7 @@ export function PipelinePage(props: {
|
||||
team: Team;
|
||||
products: Product[];
|
||||
projects: Project[];
|
||||
assets: Asset[];
|
||||
billing: BillingSummary | null;
|
||||
notice: Notice | null;
|
||||
avatarChar: string;
|
||||
@@ -79,11 +77,47 @@ export function PipelinePage(props: {
|
||||
onSubmitExport: () => void;
|
||||
}) {
|
||||
const {
|
||||
project, loading, navigate, user, team, products, projects, billing, notice, avatarChar, logout,
|
||||
project, loading, navigate, user, team, products, projects, assets, billing, notice, avatarChar, logout,
|
||||
onGenerateScript, onGenerateBaseAsset, onGenerateStoryboard, onSkipStoryboard,
|
||||
onSubmitVideo, onPollVideo, onSubmitAllVideos, onPollAllVideos, onSubmitExport
|
||||
onSubmitVideo, onSubmitAllVideos, onSubmitExport
|
||||
} = props;
|
||||
|
||||
// ── 资产解析:把各阶段引用的 asset id → 真实缩略图 preview_url(主图优先,其次首张)──
|
||||
const byId = new Map(assets.map((a) => [a.id, a] as const));
|
||||
const assetUrl = (id: string | null | undefined): string => {
|
||||
if (!id) return "";
|
||||
const a = byId.get(id);
|
||||
return a?.files?.find((f) => f.is_primary)?.preview_url || a?.files?.[0]?.preview_url || "";
|
||||
};
|
||||
const assetName = (id: string | null | undefined): string => (id ? byId.get(id)?.name || "" : "");
|
||||
|
||||
// ── Stage 2:基础资产按 kind 分组(product/person/scene),保持设计稿三区顺序 ──
|
||||
const groups = project.base_asset_groups ?? [];
|
||||
const groupsByKind = (kind: string) => groups.filter((g) => g.kind === kind);
|
||||
const KIND_ORDER: Array<"product" | "person" | "scene"> = ["product", "person", "scene"];
|
||||
|
||||
// ── Stage 3:取已采用(is_adopted)的故事板版本,无则取第一版 ──
|
||||
const storyboards = project.storyboard_versions ?? [];
|
||||
const adoptedStoryboard = storyboards.find((s) => s.is_adopted) || storyboards[0] || null;
|
||||
const sbFrames = [...(adoptedStoryboard?.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const [sbSelected, setSbSelected] = useState(0);
|
||||
const sbActiveFrame = sbFrames[Math.min(sbSelected, Math.max(0, sbFrames.length - 1))] || null;
|
||||
|
||||
// ── Stage 4:视频片段(adopted_asset 缩略图 + 状态 pill + 时长)──
|
||||
const segments = [...(project.video_segments ?? [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const segDone = segments.filter((s) => ["succeeded", "completed", "done"].includes(s.status)).length;
|
||||
const segTotalSec = segments.reduce((sum, s) => sum + (s.target_duration_seconds || 0), 0);
|
||||
|
||||
// ── Stage 5:时间轴 / 字幕 / BGM(真实定位,轨道总长用 timeline.duration_seconds)──
|
||||
const timeline = project.timeline;
|
||||
const tlTotalMs = (timeline?.duration_seconds || 0) * 1000;
|
||||
const tlClips = [...(timeline?.clips ?? [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const tlRulerMs = tlTotalMs > 0 ? tlTotalMs : tlClips.reduce((m, c) => Math.max(m, c.start_ms + c.duration_ms), 0) || 15000;
|
||||
const ruler = buildRuler(tlRulerMs / 1000);
|
||||
const subtitleTrack = (timeline?.subtitle_tracks ?? []).find((t) => t.enabled) || (timeline?.subtitle_tracks ?? [])[0] || null;
|
||||
const subtitleCues = [...(subtitleTrack?.content ?? [])].sort((a, b) => a.start_ms - b.start_ms);
|
||||
const bgmTracks = timeline?.bgm_tracks ?? [];
|
||||
|
||||
// 步进器:对齐镜像 activateStage 逻辑。默认(无 hash)pane=脚本(1) 但步进器 active=项目真实阶段;
|
||||
// 一旦导航(hash 或点击),active 跟随所看阶段,completed=max(项目阶段-1, 所看阶段-1)。
|
||||
const projectStage = project.status === "completed" ? 5 : Math.max(1, (stageOrder as readonly string[]).indexOf(project.current_stage) + 1);
|
||||
@@ -96,8 +130,10 @@ export function PipelinePage(props: {
|
||||
const [storyboardPrompt, setStoryboardPrompt] = useState("统一商品、人物、场景风格,生成可直接指导视频的分镜图");
|
||||
const [videoPrompt, setVideoPrompt] = useState("竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感");
|
||||
const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version));
|
||||
// 真实商品名(api-bridge 仅 hydrate 商品名,其余人物/场景沿用设计稿 mock)
|
||||
const productName = products.find((item) => item.id === project.product)?.title || "透真补水面膜";
|
||||
// 真实商品名 + 封面资产 id(商品组无 adopted_asset 时,商品缩图回退到商品库封面)
|
||||
const productRecord = products.find((item) => item.id === project.product);
|
||||
const productName = productRecord?.title || "透真补水面膜";
|
||||
const productCover = productRecord?.cover_asset || productRecord?.images?.find((img) => img.is_primary)?.asset || productRecord?.images?.[0]?.asset || null;
|
||||
|
||||
function goStage(n: number) {
|
||||
setViewStage(n);
|
||||
@@ -238,14 +274,24 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stage 2-5 · 暂沿用既有功能性结构(默认隐藏),后续逐阶段做像素还原 */}
|
||||
{viewStage === 2 && (
|
||||
{/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */}
|
||||
{viewStage === 2 && (() => {
|
||||
const productGroup = groupsByKind("product")[0] || null;
|
||||
const productAssetUrl = assetUrl(productGroup?.adopted_asset) || assetUrl(productGroup?.candidate_assets?.[0]) || assetUrl(productCover);
|
||||
const productCandidates = (productGroup?.candidate_assets ?? []).filter((id) => id !== productGroup?.adopted_asset);
|
||||
return (
|
||||
<section className="stage active" data-stage-pane="2">
|
||||
<div className="stage-assets">
|
||||
<div className="asset-side">
|
||||
<div className="ttab active" data-jump="asset-sec-products"><span>商品</span><span className="num">3 张</span></div>
|
||||
<div className="ttab" data-jump="asset-sec-characters"><span>人物</span><span className="num">2/2</span></div>
|
||||
<div className="ttab" data-jump="asset-sec-scenes"><span>场景</span><span className="num">3/3</span></div>
|
||||
{KIND_ORDER.map((kind) => {
|
||||
const list = groupsByKind(kind);
|
||||
const adopted = list.filter((g) => g.adopted_asset).length;
|
||||
return (
|
||||
<div className={`ttab${kind === "product" ? " active" : ""}`} key={kind} data-jump={`asset-sec-${kind}`}>
|
||||
<span>{KIND_LABEL[kind]}</span><span className="num">{list.length ? `${adopted}/${list.length}` : "0"}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="info">
|
||||
基础资产是后续故事板的素材。所有卡片同时展示,点左侧分类直接定位。
|
||||
<br /><br />
|
||||
@@ -256,29 +302,31 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
|
||||
<div className="asset-main">
|
||||
<section className="asset-sec" id="asset-sec-products">
|
||||
<section className="asset-sec" id="asset-sec-product">
|
||||
<div className="sec-h"><h3>商品 · <span id="asset-prod-name">{productName}</span></h3><span className="spacer"></span></div>
|
||||
<div className="prod-row">
|
||||
<div className="asset-card-2 prod-lib-card" data-asset-kind="product" data-asset-id="prod-main" id="asset-prod-card">
|
||||
<div className="placeholder prod-thumb has-mock-media" style={mock("product-mask.png")}>
|
||||
<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>
|
||||
<span className="tri-missing-pop" role="tooltip">
|
||||
<span className="pop-h">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
|
||||
MISSING TRI-VIEW
|
||||
<div className="asset-card-2 prod-lib-card" data-asset-kind="product" data-asset-id={productGroup?.adopted_asset || "prod-main"} id="asset-prod-card">
|
||||
<div className={`placeholder prod-thumb${productAssetUrl ? " has-mock-media" : ""}`} style={productAssetUrl ? mediaStyle(productAssetUrl) : undefined}>
|
||||
{!productGroup?.adopted_asset && (
|
||||
<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>
|
||||
<span className="tri-missing-pop" role="tooltip">
|
||||
<span className="pop-h">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
|
||||
MISSING TRI-VIEW
|
||||
</span>
|
||||
<span className="pop-body">该商品还未生成 <b>正 / 侧 / 背</b> 三视图。直接生成图片或视频,模型缺少多角度参考,角色一致性、姿态稳定性可能下降。</span>
|
||||
<span className="pop-tip">建议:点右下 <b>AI 生成三视图</b> 先补齐三视图,再发起后续生成。</span>
|
||||
</span>
|
||||
<span className="pop-body">该商品还未生成 <b>正 / 侧 / 背</b> 三视图。直接生成图片或视频,模型缺少多角度参考,角色一致性、姿态稳定性可能下降。</span>
|
||||
<span className="pop-tip">建议:点右下 <b>AI 生成三视图</b> 先补齐三视图,再发起后续生成。</span>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="ph-frame" id="asset-prod-thumb-label">{productName} · 主图</span>
|
||||
</div>
|
||||
<div className="prod-body">
|
||||
<div className="prod-name" id="asset-prod-card-name">{productName}</div>
|
||||
<div className="prod-cat">美妆个护</div>
|
||||
<div className="prod-date">2026-05-15 创建</div>
|
||||
<div className="prod-cat">{products.find((item) => item.id === project.product)?.category || "未分类"}</div>
|
||||
<div className="prod-date">{(project.created_at || "").slice(0, 10)} 创建</div>
|
||||
</div>
|
||||
<div className="prod-action" id="asset-prod-action">
|
||||
<button className="btn-aigen" type="button" data-stop id="asset-prod-aigen-btn" disabled={loading} onClick={() => onGenerateBaseAsset("product", `${productName} 三视图`)}>
|
||||
@@ -287,109 +335,108 @@ export function PipelinePage(props: {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="prod-preview" id="asset-prod-preview">
|
||||
<div className="prod-preview-h">// 三视图预览 · <span id="prod-preview-status">生成中</span></div>
|
||||
<div className="placeholder prod-preview-img" id="prod-preview-img"></div>
|
||||
<div className="prod-preview-foot" id="prod-preview-foot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="asset-sec" id="asset-sec-characters">
|
||||
<div className="sec-h"><h3>人物 · 2 个</h3><span className="spacer"></span></div>
|
||||
<div className="asset-grid-2">
|
||||
<div className="asset-card-2" data-asset-kind="character" data-asset-id="ch-linxi">
|
||||
<div className="placeholder thumb-2 has-mock-media" style={mock("person-linxi.png")}><span className="ph-frame">林夕 · 都市白领</span></div>
|
||||
<div className="body-2">
|
||||
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>主角 · 林夕</strong><span className="spacer"></span></div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>25-30 岁都市白领,长发,穿宽松米色家居服,温柔但带点疲倦感。</div>
|
||||
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop>重跑</button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop>替换</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="asset-card-2" data-asset-kind="character" data-asset-id="ch-anan">
|
||||
<div className="placeholder thumb-2">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", alignItems: "center" }}>
|
||||
<div className="spinner"></div>
|
||||
<span className="ph-frame">生成中 · 约 8s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="body-2">
|
||||
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>朋友/同事 · 阿楠</strong><span className="spacer"></span></div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>25-30 岁同龄女性,短发,穿白色衬衫,妆容精致皮肤好,作为对比。</div>
|
||||
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop disabled>重跑</button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop disabled>替换</button></div>
|
||||
<div className={`prod-preview${productCandidates.length ? " show" : ""}`} id="asset-prod-preview">
|
||||
<div className="prod-preview-h">// 候选三视图 · <span id="prod-preview-status">{productCandidates.length} 张</span></div>
|
||||
<div className={`placeholder prod-preview-img${assetUrl(productCandidates[0]) ? " has-mock-media" : ""}`} id="prod-preview-img" style={assetUrl(productCandidates[0]) ? mediaStyle(assetUrl(productCandidates[0])) : undefined}><span className="ph-frame">候选 #1</span></div>
|
||||
<div className="prod-preview-foot" id="prod-preview-foot">
|
||||
{productCandidates.slice(0, 4).map((id) => (
|
||||
<div className={`placeholder${assetUrl(id) ? " has-mock-media" : ""}`} key={id} style={{ ...(assetUrl(id) ? mediaStyle(assetUrl(id)) : {}), width: "44px", height: "44px", flex: "0 0 44px" }}><span className="ph-frame"></span></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="asset-sec" id="asset-sec-scenes">
|
||||
<div className="sec-h"><h3>场景 · 3 个</h3><span className="spacer"></span></div>
|
||||
<div className="asset-grid-2">
|
||||
<div className="asset-card-2" data-asset-kind="scene" data-asset-id="sc-desk">
|
||||
<div className="placeholder thumb-2 has-mock-media" style={mock("scene-office.png")}><span className="ph-frame">深夜办公桌</span></div>
|
||||
<div className="body-2">
|
||||
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>深夜办公桌</strong><span className="spacer"></span></div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>深夜居家办公环境,木质书桌,台灯暖光,电脑屏幕亮着。</div>
|
||||
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop>重跑</button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop>替换</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="asset-card-2" data-asset-kind="scene" data-asset-id="sc-bed">
|
||||
<div className="placeholder thumb-2 has-mock-media" style={mock("scene-bedroom.png")}><span className="ph-frame">床头特写</span></div>
|
||||
<div className="body-2">
|
||||
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>卧室床头</strong><span className="spacer"></span></div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>米白色床品,木质床头柜,闹钟显示晚间时间。</div>
|
||||
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop>重跑</button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop>替换</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="asset-card-2" data-asset-kind="scene" data-asset-id="sc-subway">
|
||||
<div className="placeholder thumb-2">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px", alignItems: "center" }}>
|
||||
<div className="fail-icon">!</div>
|
||||
<span className="ph-frame">生成失败</span>
|
||||
{(["person", "scene"] as const).map((kind) => {
|
||||
const list = groupsByKind(kind);
|
||||
return (
|
||||
<section className="asset-sec" id={`asset-sec-${kind}`} key={kind}>
|
||||
<div className="sec-h"><h3>{KIND_LABEL[kind]} · {list.length} 个</h3><span className="spacer"></span></div>
|
||||
{list.length ? (
|
||||
<div className="asset-grid-2">
|
||||
{list.map((group, gi) => {
|
||||
const mainUrl = assetUrl(group.adopted_asset) || assetUrl(group.candidate_assets?.[0]);
|
||||
const cands = (group.candidate_assets ?? []).filter((id) => id !== group.adopted_asset).slice(0, 4);
|
||||
return (
|
||||
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={group.id} key={group.id}>
|
||||
<div className={`placeholder thumb-2${mainUrl ? " has-mock-media" : ""}`} style={mainUrl ? mediaStyle(mainUrl) : undefined}>
|
||||
<span className="ph-frame">{assetName(group.adopted_asset) || `${KIND_LABEL[kind]} ${gi + 1}`}</span>
|
||||
</div>
|
||||
<div className="body-2">
|
||||
<div className="hstack">
|
||||
<strong style={{ fontSize: "13.5px" }}>{assetName(group.adopted_asset) || `${KIND_LABEL[kind]} ${gi + 1}`}</strong>
|
||||
<span className="spacer"></span>
|
||||
{group.adopted_asset
|
||||
? <span className="pill ok"><span className="dot"></span>已采用</span>
|
||||
: <span className="pill neutral"><span className="dot"></span>待采用</span>}
|
||||
</div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>{group.prompt || "(暂无提示词)"}</div>
|
||||
{cands.length > 0 && (
|
||||
<div className="hstack" style={{ marginTop: "10px", gap: "6px", flexWrap: "wrap" }}>
|
||||
{cands.map((id) => (
|
||||
<div className={`placeholder${assetUrl(id) ? " has-mock-media" : ""}`} key={id} style={{ ...(assetUrl(id) ? mediaStyle(assetUrl(id)) : {}), width: "40px", height: "40px", flex: "0 0 40px" }}><span className="ph-frame"></span></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="body-2">
|
||||
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>通勤地铁</strong><span className="spacer"></span><span className="pill err"><span className="dot"></span>失败</span></div>
|
||||
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>早高峰地铁车厢,光线偏冷,年轻通勤族,氛围紧张。</div>
|
||||
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop>重跑</button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop>替换</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<div className="placeholder" style={{ minHeight: "120px" }}><span className="ph-frame">// 暂无{KIND_LABEL[kind]}资产 · 待生成</span></div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ 已确认 ¥0.85 · 待生成 ¥0.20 · 失败 ¥0(不扣) ]</span></div>
|
||||
<div className="info"><span className="mono">[ 基础资产同时展示 · 商品图复用商品库 · 失败不扣 ]</span></div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" onClick={() => goStage(1)}><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>
|
||||
<button className="btn btn-primary btn-lg" 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="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
{/* ============= STAGE 3 · 故事板(采用版的 frames,真图 + 镜头提示词)============= */}
|
||||
{viewStage === 3 && (
|
||||
<section className="stage active" data-stage-pane="3">
|
||||
<div className="stage-storyboard">
|
||||
<div className="sb-canvas">
|
||||
<div className="sb-scenes-col" id="sb-scenes-row">
|
||||
{SB_SCENES.map((scene) => (
|
||||
<div className={`sb-scene-thumb${scene.sid === "sc1" ? " selected" : ""}`} key={scene.sid} data-sid={scene.sid}>
|
||||
<div className="placeholder has-mock-media" style={mock(scene.img)}><span className="ph-frame">{scene.frame}</span></div>
|
||||
<div className="nm">{scene.nm}</div>
|
||||
<div className="sub">{scene.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
{sbFrames.length ? sbFrames.map((frame, idx) => {
|
||||
const url = assetUrl(frame.asset);
|
||||
return (
|
||||
<div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={frame.id} data-sid={frame.id} onClick={() => setSbSelected(idx)}>
|
||||
<div className={`placeholder${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}><span className="ph-frame">场 {idx + 1}</span></div>
|
||||
<div className="nm">场 {idx + 1}</div>
|
||||
<div className="sub">#{frame.sort_order + 1}</div>
|
||||
</div>
|
||||
);
|
||||
}) : <div className="placeholder" style={{ aspectRatio: "1" }}><span className="ph-frame">// 暂无</span></div>}
|
||||
</div>
|
||||
<div className="placeholder sb-main-img has-mock-media" id="sb-main-img" style={mock("cover-mask-v3.png")}><span className="ph-frame">场 1 · 深夜办公桌 · v1</span></div>
|
||||
{(() => {
|
||||
const url = assetUrl(sbActiveFrame?.asset);
|
||||
return (
|
||||
<div className={`placeholder sb-main-img${url ? " has-mock-media" : ""}`} id="sb-main-img" style={url ? mediaStyle(url) : undefined}>
|
||||
<span className="ph-frame">{sbActiveFrame ? `场 ${sbSelected + 1}` : "// 故事板未生成"}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="sb-side">
|
||||
<div className="pane" style={{ padding: "18px" }}>
|
||||
<div className="hstack" style={{ marginBottom: "10px" }}>
|
||||
<strong style={{ fontSize: "14px" }}>故事板 · <span id="sb-side-scene">场 1</span></strong>
|
||||
<strong style={{ fontSize: "14px" }}>故事板 · <span id="sb-side-scene">{sbActiveFrame ? `场 ${sbSelected + 1}` : "—"}</span></strong>
|
||||
<span className="spacer"></span>
|
||||
<span className="pill ok"><span className="dot"></span>已生成</span>
|
||||
{adoptedStoryboard
|
||||
? <span className="pill ok"><span className="dot"></span>已生成</span>
|
||||
: <span className="pill neutral"><span className="dot"></span>未生成</span>}
|
||||
</div>
|
||||
<div className="muted-2" style={{ fontSize: "12px", lineHeight: 1.55, marginBottom: "10px" }}>整张故事板由 image-2 一次性输出,包含画面 + 镜头说明。</div>
|
||||
<div className="sb-rerun-note">
|
||||
@@ -399,7 +446,7 @@ export function PipelinePage(props: {
|
||||
<div className="note-copy"><strong>仅支持整张重跑</strong> · 不能局部改某一镜。如需调单镜,先在 <a href="#stage-1" onClick={(event) => { event.preventDefault(); goStage(1); }}>Stage 1 脚本</a> 改镜头描述,再回此处整张重跑。</div>
|
||||
</div>
|
||||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "6px", letterSpacing: ".04em" }}>// 本场提示词</div>
|
||||
<div className="prompt-edit" contentEditable suppressContentEditableWarning id="sb-prompt-edit">{SB_PROMPT}</div>
|
||||
<div className="prompt-edit" contentEditable suppressContentEditableWarning id="sb-prompt-edit">{sbActiveFrame?.prompt || adoptedStoryboard?.prompt || "(暂无提示词)"}</div>
|
||||
<div className="sb-stage-actions">
|
||||
<button className="pill-cta heat" type="button" id="sb-rerun-btn" disabled={loading} onClick={() => onGenerateStoryboard(storyboardPrompt)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" 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>
|
||||
@@ -409,26 +456,32 @@ export function PipelinePage(props: {
|
||||
<span className="muted-2 mono" style={{ fontSize: "11px", alignSelf: "center" }}>~¥0.45/场</span>
|
||||
</div>
|
||||
<div className="sb-history">
|
||||
<div className="sb-history-h">// 历史版本(<span id="sb-history-ct">1</span>)</div>
|
||||
<div className="sb-history-h">// 历史版本(<span id="sb-history-ct">{storyboards.length}</span>)</div>
|
||||
<div className="sb-history-row" id="sb-history-row">
|
||||
<div className="sb-history-thumb current" data-vi="0">
|
||||
<div className="placeholder has-mock-media" style={mock("cover-mask-final.png")}><span className="ph-frame">v1</span></div>
|
||||
<div className="ts">14:02</div>
|
||||
</div>
|
||||
{storyboards.length ? storyboards.map((ver) => {
|
||||
const cover = assetUrl([...(ver.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order)[0]?.asset);
|
||||
return (
|
||||
<div className={`sb-history-thumb${ver.is_adopted ? " current" : ""}`} key={ver.id} data-vi={ver.id}>
|
||||
<div className={`placeholder${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}><span className="ph-frame">{ver.is_adopted ? "采用" : "历史"}</span></div>
|
||||
<div className="ts">{(ver.created_at || "").slice(11, 16) || "--:--"}</div>
|
||||
</div>
|
||||
);
|
||||
}) : <span className="muted-2 mono" style={{ fontSize: "11px" }}>// 暂无历史</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider" style={{ marginTop: "16px" }}></div>
|
||||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 绑定的资产</div>
|
||||
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }} id="sb-bound-assets">
|
||||
<span className="asset-tag"><span className="dotc"></span>林夕(人物)</span>
|
||||
<span className="asset-tag"><span className="dotc"></span>深夜办公桌(场景)</span>
|
||||
{groups.filter((g) => g.adopted_asset).length ? groups.filter((g) => g.adopted_asset).map((g) => (
|
||||
<span className="asset-tag" key={g.id}><span className="dotc"></span>{assetName(g.adopted_asset) || KIND_LABEL[g.kind] || g.kind}({KIND_LABEL[g.kind] || g.kind})</span>
|
||||
)) : <span className="muted-2 mono" style={{ fontSize: "11px" }}>// 暂无绑定资产</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ image-2 单场 ¥0.45 · 累计 ¥1.35 · 整张重跑,失败不扣 ]</span></div>
|
||||
<div className="info"><span className="mono">[ image-2 整张输出 · {sbFrames.length} 场 · 整张重跑,失败不扣 ]</span></div>
|
||||
<div className="hstack">
|
||||
<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>
|
||||
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(4)}>确认故事板,开始生成视频 <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>
|
||||
@@ -436,61 +489,83 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{viewStage === 4 && (
|
||||
{/* ============= STAGE 4 · 视频(video_segments,adopted_asset 缩略 + 状态 + 时长)============= */}
|
||||
{viewStage === 4 && (() => {
|
||||
const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0;
|
||||
return (
|
||||
<section className="stage active" data-stage-pane="4">
|
||||
<div className="queue-bar">
|
||||
<div>
|
||||
<div style={{ fontSize: "14px", fontWeight: 600 }}>视频生成 · 3 / 3 完成</div>
|
||||
<div className="muted-2 mono" style={{ fontSize: "11px", marginTop: "3px", letterSpacing: ".02em" }}>// 每场 Seedance 约 <span id="seedance-avg">15</span> 秒 · 已完成所有场次</div>
|
||||
<div style={{ fontSize: "14px", fontWeight: 600 }}>视频生成 · {segDone} / {segments.length} 完成</div>
|
||||
<div className="muted-2 mono" style={{ fontSize: "11px", marginTop: "3px", letterSpacing: ".02em" }}>// 每场 Seedance 生成 · {segments.length ? (segDone === segments.length ? "已完成所有场次" : "生成中") : "暂无片段"}</div>
|
||||
</div>
|
||||
<div className="bar-wrap"><span style={{ width: "100%" }}></span></div>
|
||||
<span className="muted mono" style={{ fontSize: "12px" }}>100%</span>
|
||||
<button className="btn btn-sm" type="button" disabled={loading} onClick={() => onSubmitAllVideos(videoPrompt)}>↻ 全部重跑</button>
|
||||
<div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
|
||||
<span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
|
||||
<button className="btn btn-sm" type="button" disabled={loading || !segments.length} onClick={() => onSubmitAllVideos(videoPrompt)}>↻ 全部重跑</button>
|
||||
<button className="btn btn-sm" type="button">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
|
||||
上传视频
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="video-grid" id="video-grid">
|
||||
{VIDEO_CARDS.map((card) => (
|
||||
<div className="video-card" key={card.vid} data-video-id={card.vid}>
|
||||
<div className="placeholder video-thumb has-mock-media" style={mock(card.img)}>
|
||||
<span className="ph-frame">{card.frame}</span>
|
||||
<div className="play"><div className="btn-play"><Play size={14} fill="currentColor" /></div></div>
|
||||
</div>
|
||||
<div className="body">
|
||||
<div className="video-card-head"><strong className="video-card-title">{card.title}</strong><span className="pill ok"><span className="dot"></span>完成</span></div>
|
||||
<div className="video-meta">{card.meta}</div>
|
||||
<div className="video-actions">
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-vstop>重跑</button>
|
||||
<span className="spacer"></span>
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-vstop>下载</button>
|
||||
{segments.length ? (
|
||||
<div className="video-grid" id="video-grid">
|
||||
{segments.map((seg) => {
|
||||
const url = assetUrl(seg.adopted_asset);
|
||||
const tone = statusPill(seg.status);
|
||||
return (
|
||||
<div className="video-card" key={seg.id} data-video-id={seg.id}>
|
||||
<div className={`placeholder video-thumb${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}>
|
||||
<span className="ph-frame">场 {seg.sort_order + 1}</span>
|
||||
{url && <div className="play"><div className="btn-play"><Play size={14} fill="currentColor" /></div></div>}
|
||||
</div>
|
||||
<div className="body">
|
||||
<div className="video-card-head">
|
||||
<strong className="video-card-title">场 {seg.sort_order + 1}</strong>
|
||||
<span className={`pill ${tone}`}><span className="dot"></span>{statusLabel(seg.status)}</span>
|
||||
</div>
|
||||
<div className="video-meta">{seg.target_duration_seconds}s{seg.error_message ? ` · ${seg.error_message}` : ""}</div>
|
||||
<div className="video-actions">
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading} onClick={() => onSubmitVideo(seg.id, `${videoPrompt} 第 ${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds} 秒`)}>重跑</button>
|
||||
<span className="spacer"></span>
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={!url}>下载</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="placeholder" style={{ minHeight: "200px", margin: "18px 28px" }}><span className="ph-frame">// 暂无视频片段 · 先在故事板确认后生成</span></div>
|
||||
)}
|
||||
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ 已完成 3 场 · 累计 ¥1.35 · 总时长 <span id="seedance-total">40</span>s · 失败不扣 · 通过后扣 ]</span></div>
|
||||
<div className="info"><span className="mono">[ 已完成 {segDone} 场 · 总时长 {segTotalSec}s · 失败不扣 · 通过后扣 ]</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{viewStage === 5 && (
|
||||
);
|
||||
})()}
|
||||
{/* ============= STAGE 5 · 拼接导出(timeline.clips / subtitle_tracks / bgm_tracks 真实定位)============= */}
|
||||
{viewStage === 5 && (() => {
|
||||
const previewUrl = assetUrl(tlClips[0]?.asset) || assetUrl(segments.find((s) => s.adopted_asset)?.adopted_asset);
|
||||
const aspect = timeline?.aspect_ratio || "9:16";
|
||||
const resolution = timeline?.resolution || "1080×1920";
|
||||
const bgm = bgmTracks[0] || null;
|
||||
const bgmName = assetName(bgm?.asset) || (bgm ? "背景音乐" : "");
|
||||
return (
|
||||
<section className="stage active" data-stage-pane="5">
|
||||
<div className="editor">
|
||||
<div className="editor-preview">
|
||||
<div className="canvas has-mock-media" id="ed-canvas" style={{ backgroundImage: "url(/exact/assets/mock/cover-mask-final.png)" }}><span id="ed-canvas-label">9:16 预览 · 1080×1920</span></div>
|
||||
<div className={`canvas${previewUrl ? " has-mock-media" : ""}`} id="ed-canvas" style={previewUrl ? mediaStyle(previewUrl) : undefined}><span id="ed-canvas-label">{aspect} 预览 · {resolution}</span></div>
|
||||
<div className="controls">
|
||||
<button className="ctl-btn" type="button" id="ed-prev-btn" title="上一帧 (←)"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 3v10l4-5zM9 3v10l4-5z" fill="currentColor" /></svg></button>
|
||||
<button className="ctl-btn" type="button" id="ed-play-btn" title="播放 / 暂停 (空格)"><svg id="ed-play-icon" width="16" height="16" viewBox="0 0 16 16"><path d="M5 4l7 4-7 4z" fill="currentColor" /></svg></button>
|
||||
<button className="ctl-btn" type="button" id="ed-next-btn" title="下一帧 (→)"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M13 3v10l-4-5zM7 3v10l-4-5z" fill="currentColor" /></svg></button>
|
||||
<span className="muted mono" style={{ fontSize: "12px", marginLeft: "8px" }}><span id="ed-cur-time">00:00.00</span> / <span id="ed-total-time">00:15.00</span></span>
|
||||
<span className="muted mono" style={{ fontSize: "12px", marginLeft: "8px" }}><span id="ed-cur-time">0:00</span> / <span id="ed-total-time">{fmtMs(tlRulerMs)}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -504,15 +579,18 @@ export function PipelinePage(props: {
|
||||
<div className="swatch-card"><div className="demo d">真实分享</div><div className="nm">综艺暖黄</div></div>
|
||||
</div>
|
||||
<div className="divider"></div>
|
||||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 当前选中(<span id="ed-inspect-name">未选</span>)</div>
|
||||
<div className="props-row"><span className="k">起始</span><input className="input-mini" id="ed-inspect-start" defaultValue="—" /></div>
|
||||
<div className="props-row"><span className="k">时长</span><input className="input-mini" id="ed-inspect-dur" defaultValue="—" /></div>
|
||||
<div className="props-row"><span className="k">音量</span><input className="input-mini" defaultValue="100" /></div>
|
||||
<div className="props-row"><span className="k">速度</span><input className="input-mini" defaultValue="1.0x" /></div>
|
||||
<div className="props-row"><span className="k">入场</span><span className="mono" style={{ fontSize: "11.5px" }}>交叉淡化</span></div>
|
||||
<div className="divider"></div>
|
||||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// BGM</div>
|
||||
<div className="props-row" style={{ borderBottom: 0 }}><span style={{ fontSize: "12px", flex: 1 }}>温柔治愈钢琴 · 0:42</span><button className="btn btn-ghost btn-sm" type="button">替换</button></div>
|
||||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 时间轴(<span id="ed-inspect-name">{timeline?.name || "未命名"}</span>)</div>
|
||||
<div className="props-row"><span className="k">总时长</span><input className="input-mini" id="ed-inspect-start" defaultValue={fmtMs(tlRulerMs)} readOnly /></div>
|
||||
<div className="props-row"><span className="k">片段</span><input className="input-mini" id="ed-inspect-dur" defaultValue={`${tlClips.length} 段`} readOnly /></div>
|
||||
<div className="props-row"><span className="k">字幕</span><input className="input-mini" defaultValue={`${subtitleCues.length} 条`} readOnly /></div>
|
||||
<div className="props-row"><span className="k">分辨率</span><span className="mono" style={{ fontSize: "11.5px" }}>{resolution}</span></div>
|
||||
{bgm && (
|
||||
<>
|
||||
<div className="divider"></div>
|
||||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// BGM</div>
|
||||
<div className="props-row" style={{ borderBottom: 0 }}><span style={{ fontSize: "12px", flex: 1 }}>{bgmName} · 音量 {bgm.volume}</span><button className="btn btn-ghost btn-sm" type="button">替换</button></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="timeline" id="ed-timeline">
|
||||
@@ -530,8 +608,8 @@ export function PipelinePage(props: {
|
||||
<div className="tl-ruler">
|
||||
<div className="l">// time</div>
|
||||
<div className="rule-track" id="ed-ruler">
|
||||
{ED_RULER.map((tick, i) => (
|
||||
<span className={`tick ${tick.major ? "major" : "minor"}`} key={i} style={{ left: tick.left }}>{tick.t && <span className="t">{tick.t}</span>}</span>
|
||||
{ruler.map((tick, i) => (
|
||||
<span className={`tick ${tick.major ? "major" : "minor"}`} key={i} style={{ left: `${tick.leftPct}%` }}>{tick.t && <span className="t">{tick.t}</span>}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -539,47 +617,66 @@ export function PipelinePage(props: {
|
||||
<div className="tl-track video-track">
|
||||
<div className="label video"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" /><path d="M7 2v20M17 2v20M2 12h20M2 7h5M2 17h5M17 17h5M17 7h5" /></svg></span>视频</div>
|
||||
<div className="lane" id="ed-lane-video" data-track="video">
|
||||
{edLayout(ED_VIDEO_CLIPS).map((clip) => (
|
||||
<div className="clip video" key={clip.n} data-track="video" data-label={clip.lbl} style={{ left: `${clip.leftPct}%`, width: `${clip.widthPct}%` }}>
|
||||
<span className="frames">{Array.from({ length: clip.dur + 1 }).map((_, i) => <span className="fr" key={i}></span>)}</span>
|
||||
<span className="num">{clip.n}</span><span className="lbl">{clip.lbl}</span>
|
||||
</div>
|
||||
))}
|
||||
{tlClips.length ? tlClips.map((clip, idx) => {
|
||||
const { leftPct, widthPct } = clipLayout(clip.start_ms, clip.duration_ms, tlRulerMs);
|
||||
const lbl = assetName(clip.asset) || `片段 ${idx + 1}`;
|
||||
const frameCount = Math.max(1, Math.round(clip.duration_ms / 1000));
|
||||
return (
|
||||
<div className="clip video" key={clip.id} data-track="video" data-label={lbl} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}>
|
||||
<span className="frames">{Array.from({ length: frameCount + 1 }).map((_, i) => <span className="fr" key={i}></span>)}</span>
|
||||
<span className="num">{idx + 1}</span><span className="lbl">{lbl}</span>
|
||||
</div>
|
||||
);
|
||||
}) : <span className="muted-2 mono" style={{ position: "absolute", left: "8px", top: "50%", transform: "translateY(-50%)", fontSize: "11px" }}>// 暂无片段</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tl-track subtitle-track">
|
||||
<div className="label subtitle"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 7V4h16v3" /><path d="M9 20h6" /><path d="M12 4v16" /></svg></span>字幕</div>
|
||||
<div className="lane" id="ed-lane-subtitle" data-track="subtitle">
|
||||
{edLayout(ED_SUB_CLIPS).map((clip, i) => (
|
||||
<div className="clip subtitle" key={i} data-track="subtitle" data-label={clip.lbl} style={{ left: `${clip.leftPct}%`, width: `${clip.widthPct}%` }}><span className="lbl">{clip.lbl}</span></div>
|
||||
))}
|
||||
{subtitleCues.map((cue, i) => {
|
||||
const next = subtitleCues[i + 1];
|
||||
const endMs = next ? next.start_ms : tlRulerMs;
|
||||
const { leftPct, widthPct } = clipLayout(cue.start_ms, Math.max(0, endMs - cue.start_ms), tlRulerMs);
|
||||
return (
|
||||
<div className="clip subtitle" key={i} data-track="subtitle" data-label={cue.text} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}><span className="lbl">{cue.text}</span></div>
|
||||
);
|
||||
})}
|
||||
<div className="playhead" id="ed-playhead" style={{ left: "0%" }}><span className="ph-grab"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tl-track bgm-track">
|
||||
<div className="label bgm"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg></span>BGM</div>
|
||||
<div className="lane">
|
||||
<div className="clip bgm" data-track="bgm" data-label="温柔治愈钢琴" style={{ left: "0%", width: "100%" }}>
|
||||
<span className="wave"><svg viewBox="0 0 600 20" preserveAspectRatio="none" fill="currentColor">{ED_WAVE.map(([y, h], i) => <rect key={i} x={i * 4} y={y} width="2" height={h} />)}</svg></span>
|
||||
<span className="lbl">温柔治愈钢琴 · 0:42(循环 1 次,淡入淡出)</span>
|
||||
{bgmTracks.length > 0 && (
|
||||
<div className="tl-track bgm-track">
|
||||
<div className="label bgm"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg></span>BGM</div>
|
||||
<div className="lane">
|
||||
{bgmTracks.map((track) => {
|
||||
const { leftPct, widthPct } = clipLayout(track.start_ms, Math.max(0, tlRulerMs - track.start_ms), tlRulerMs);
|
||||
const name = assetName(track.asset) || "背景音乐";
|
||||
return (
|
||||
<div className="clip bgm" key={track.id} data-track="bgm" data-label={name} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}>
|
||||
<span className="wave"><svg viewBox="0 0 600 20" preserveAspectRatio="none" fill="currentColor">{ED_WAVE.map(([y, h], i) => <rect key={i} x={i * 4} y={y} width="2" height={h} />)}</svg></span>
|
||||
<span className="lbl">{name} · 音量 {track.volume}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ 合成预估 ~30s · 拼接 / 导出全程 0 token · 已结算 ¥1.39 ]</span></div>
|
||||
<div className="info"><span className="mono">[ 时间轴 {fmtMs(tlRulerMs)} · {tlClips.length} 段 · 拼接 / 导出全程 0 token ]</span></div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" onClick={() => goStage(4)}><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>
|
||||
<button className="btn" type="button">保存草稿</button>
|
||||
<button className="btn btn-primary btn-lg" type="button" onClick={onSubmitExport}>导出 MP4 · 1080P 9:16 <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></button>
|
||||
<button className="btn btn-primary btn-lg" type="button" disabled={!canExport} onClick={onSubmitExport}>导出 MP4 · {resolution.includes("1080") || resolution.includes("1920") ? "1080P" : resolution} {aspect} <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></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user