All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m37s
消息中心:全量渲染 → 真·后端分页滚动加载 - backend(ops/views): NotificationPagination(10/页,page_size 可覆盖)+ 响应回 type_counts(按收件人绝对计数,不受分页/搜索影响) - frontend(messages): 自管分页,滚到底加载下一批;tab/搜索走服务端并重置到第1页; 代号作废在途旧请求防切换卡空白;乐观标已读;「已加载 X / Y」分母用当前筛选总数 - api/App/types: listNotifications 支持 page/page_size/search;allNotifications 携带 type_counts 命令面板(侧边栏搜索):修复点开后 UI 错位 - app-shell: 遮罩 className 漏了基类 shell-command-bg(只有 .show)致无定位塌到左下; 补回基类 + header 类名对齐 .shell-command-h - messages-page.css: 工作台收进视口高度,收件箱在面板内滚动 本次提交一并带入此前若干未提交 WIP(account/ai-tools/library/pipeline/products/settings + accounts/ai/assets/billing/projects 后端),按用户要求整体推 dev。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1209 lines
86 KiB
TypeScript
1209 lines
86 KiB
TypeScript
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||
import type { ChangeEvent, CSSProperties } from "react";
|
||
import { Play } from "lucide-react";
|
||
import type { Asset, BillingSummary, ExportPoll, Product, Project, Team, TimelineSavePayload, 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 { MediaLightbox } from "../components/overlays";
|
||
import { IconKitSvg } from "../components/IconKitSvg";
|
||
|
||
// 真实资产缩略图注入:与全站一致用 --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: "基础资产" },
|
||
{ n: 3, label: "故事板" },
|
||
{ n: 4, label: "视频" },
|
||
{ n: 5, label: "拼接导出" }
|
||
];
|
||
|
||
export function PipelinePage(props: {
|
||
project: Project;
|
||
loading: boolean;
|
||
navigate: (page: Page, options?: { projectId?: string; productId?: string }) => void;
|
||
user: User;
|
||
team: Team;
|
||
products: Product[];
|
||
projects: Project[];
|
||
assets: Asset[];
|
||
billing: BillingSummary | null;
|
||
notice: Notice | null;
|
||
unreadCount: number;
|
||
avatarChar: string;
|
||
logout: () => void;
|
||
onRefresh: () => void;
|
||
onGenerateScript: (prompt: string) => void;
|
||
onAdoptScript: (scriptId: string) => void | Promise<unknown>;
|
||
onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string) => void;
|
||
onGenerateStoryboard: (prompt: string) => void;
|
||
onSkipStoryboard: () => void;
|
||
onSubmitVideo: (segmentId: string, prompt: string) => void;
|
||
onPollVideo: (segmentId: string) => void;
|
||
onSubmitAllVideos: (prompt: string) => void;
|
||
onPollVideosQuiet: () => void | Promise<void>;
|
||
onPollAllVideos: () => void;
|
||
exportResult: ExportPoll | null;
|
||
onRefreshExport: () => void;
|
||
onUploadVideoSegment: (segmentId: string, file: File) => void;
|
||
onUploadBgm: (file: File, volume: number) => void;
|
||
onSaveTimeline: (payload: TimelineSavePayload) => void;
|
||
onSubmitExport: (payload?: TimelineSavePayload) => void;
|
||
}) {
|
||
const {
|
||
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
|
||
onGenerateScript, onAdoptScript, onGenerateBaseAsset, onGenerateStoryboard, onSkipStoryboard,
|
||
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport,
|
||
onUploadVideoSegment, onUploadBgm, onSaveTimeline, 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 || "" : "");
|
||
// 缩略图解析:优先用后端内嵌的 preview_url(不受团队 assets 分页 20 条影响),回退到 assets 列表解析
|
||
type GroupLike = { adopted_asset: string | null; adopted_asset_url?: string; candidate_assets?: string[]; candidate_asset_urls?: Record<string, string> };
|
||
const groupMainUrl = (g?: GroupLike | null): string =>
|
||
g?.adopted_asset_url || assetUrl(g?.adopted_asset) || (g?.candidate_assets?.[0] ? (g?.candidate_asset_urls?.[g.candidate_assets[0]] || assetUrl(g.candidate_assets[0])) : "");
|
||
const candUrl = (g: GroupLike | null | undefined, id: string): string => g?.candidate_asset_urls?.[id] || assetUrl(id);
|
||
const frameUrl = (f?: { asset: string; asset_url?: string } | null): string => f?.asset_url || assetUrl(f?.asset);
|
||
const segUrl = (s?: { adopted_asset?: string | null; adopted_asset_url?: string } | null): string => s?.adopted_asset_url || assetUrl(s?.adopted_asset);
|
||
|
||
// ── Stage 1:脚本(采用版优先,否则最近一版)+ 镜头列表 ──
|
||
const scripts = [...(project.script_versions ?? [])];
|
||
const currentScript =
|
||
scripts.find((s) => s.is_adopted) ||
|
||
[...scripts].sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""))[0] ||
|
||
null;
|
||
const scriptAdopted = Boolean(currentScript?.is_adopted);
|
||
const shots = [...(currentScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order);
|
||
|
||
// ── 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"];
|
||
const [assetTab, setAssetTab] = useState<"product" | "person" | "scene">("product");
|
||
function jumpAssetSection(kind: "product" | "person" | "scene") {
|
||
setAssetTab(kind);
|
||
if (typeof document !== "undefined") {
|
||
document.getElementById(`asset-sec-${kind}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}
|
||
}
|
||
|
||
// ── 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);
|
||
const initHash = typeof location !== "undefined" ? location.hash.match(/#stage-(\d)/) : null;
|
||
const [viewStage, setViewStage] = useState(initHash ? Number(initHash[1]) : 1);
|
||
const [navigated, setNavigated] = useState(Boolean(initHash));
|
||
const activeDot = navigated ? viewStage : projectStage;
|
||
const completed = Math.max(projectStage - 1, activeDot - 1);
|
||
const [chatText, setChatText] = useState("");
|
||
// 媒体预览灯箱(视频片段播放 / 故事板分镜放大)
|
||
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
|
||
const [chatMode, setChatMode] = useState<"ai" | "theme" | "manual">("ai");
|
||
const [chatAttachments, setChatAttachments] = useState<Array<{ name: string; chars: number }>>([]);
|
||
const chatTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||
const chatFileRef = useRef<HTMLInputElement | null>(null);
|
||
function clearChat() {
|
||
setChatText("");
|
||
setChatAttachments([]);
|
||
setChatMode("ai");
|
||
}
|
||
function pickScriptMode() {
|
||
setChatMode("manual");
|
||
chatFileRef.current?.click();
|
||
}
|
||
function onPickScriptFile(event: ChangeEvent<HTMLInputElement>) {
|
||
const file = event.target.files?.[0];
|
||
event.target.value = "";
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const text = String(reader.result || "").trim();
|
||
setChatText((prev) => (prev ? `${prev}\n${text}` : text));
|
||
setChatAttachments((list) => [...list, { name: file.name, chars: text.length }]);
|
||
setChatMode("manual");
|
||
chatTextareaRef.current?.focus();
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
function focusThemeMode() {
|
||
setChatMode("theme");
|
||
chatTextareaRef.current?.focus();
|
||
}
|
||
const [storyboardPrompt, setStoryboardPrompt] = useState("统一商品、人物、场景风格,生成可直接指导视频的分镜图");
|
||
const [videoPrompt, setVideoPrompt] = useState("竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感");
|
||
const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version));
|
||
|
||
// ── Stage 5 · 真实视频播放器:时间轴 clips 当作播放列表,逐段播真实视频文件 ──
|
||
const isVideoAsset = (id: string | null | undefined): boolean => {
|
||
const a = id ? byId.get(id) : null;
|
||
if (!a) return false;
|
||
if (a.asset_type === "video") return true;
|
||
const f = a.files?.find((x) => x.is_primary) || a.files?.[0];
|
||
return !!f && /video\//.test(f.content_type || "");
|
||
};
|
||
// ── Stage 5 · 编辑器状态(可改片段/字幕文本/转场/BGM音量,本地编辑,保存草稿/导出时落盘)──
|
||
type EdClipState = { key: string; asset: string; url: string; isVideo: boolean; durMs: number; trimStartMs: number; trimEndMs: number | null; subtitle: string };
|
||
type EditorState = { clips: EdClipState[]; subtitleEnabled: boolean; subtitleStyle: string; transition: string; bgmVolume: number };
|
||
const buildInitialEditor = useCallback((): EditorState => {
|
||
const tl = project.timeline;
|
||
const sub = (tl?.subtitle_tracks ?? [])[0];
|
||
const savedTexts: string[] = (sub?.content ?? []).map((c) => String(c?.text || ""));
|
||
const scriptScript = (project.script_versions ?? []).find((s) => s.is_adopted) || (project.script_versions ?? [])[0];
|
||
const scriptTexts = [...(scriptScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order).map((s) => (s.narration || "").trim());
|
||
const subFor = (i: number) => savedTexts[i] || scriptTexts[i] || "";
|
||
const baseClips: EdClipState[] = tl?.clips?.length
|
||
? [...tl.clips].sort((a, b) => a.sort_order - b.sort_order).map((c, i) => ({
|
||
key: `c${i}-${c.id}`, asset: c.asset, url: c.asset_url || assetUrl(c.asset),
|
||
isVideo: c.asset_is_video ?? isVideoAsset(c.asset), durMs: c.duration_ms || 0,
|
||
trimStartMs: c.trim_start_ms || 0, trimEndMs: c.trim_end_ms ?? null, subtitle: subFor(i)
|
||
}))
|
||
: segments.filter((s) => s.adopted_asset).map((s, i) => ({
|
||
key: `s${i}-${s.id}`, asset: s.adopted_asset as string, url: segUrl(s), isVideo: true,
|
||
durMs: (s.target_duration_seconds || 0) * 1000, trimStartMs: 0, trimEndMs: null, subtitle: subFor(i)
|
||
}));
|
||
const bgm = (tl?.bgm_tracks ?? [])[0];
|
||
return {
|
||
clips: baseClips,
|
||
subtitleEnabled: sub ? sub.enabled : true,
|
||
subtitleStyle: (sub?.style?.key as string) || "plain",
|
||
transition: tl?.metadata?.transition?.type || "none",
|
||
bgmVolume: bgm?.volume ?? 60
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [project.timeline, project.script_versions, segments]);
|
||
const [edState, setEdState] = useState<EditorState>(buildInitialEditor);
|
||
const [edHistory, setEdHistory] = useState<EditorState[]>([]);
|
||
const [edFuture, setEdFuture] = useState<EditorState[]>([]);
|
||
const [selectedClip, setSelectedClip] = useState(0);
|
||
const [propsTab, setPropsTab] = useState<"subtitle" | "transition" | "bgm">("subtitle");
|
||
const edHydratedRef = useRef(false);
|
||
const bgmFileRef = useRef<HTMLInputElement | null>(null);
|
||
const videoUploadRef = useRef<HTMLInputElement | null>(null);
|
||
const [uploadTargetSeg, setUploadTargetSeg] = useState<string | null>(null);
|
||
// 提交一个新编辑态(进 undo 栈)
|
||
const commitEdit = useCallback((next: EditorState) => {
|
||
setEdHistory((h) => [...h.slice(-49), edState]);
|
||
setEdFuture([]);
|
||
setEdState(next);
|
||
}, [edState]);
|
||
|
||
const edClips = edState.clips.map((c) => ({ id: c.key, assetId: c.asset, url: c.url, isVideo: c.isVideo, durMs: c.durMs }));
|
||
const edTotalMs = edClips.reduce((sum, c) => sum + c.durMs, 0) || tlRulerMs;
|
||
const edOffsetMs = (idx: number) => edClips.slice(0, idx).reduce((sum, c) => sum + c.durMs, 0);
|
||
|
||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||
const [edIdx, setEdIdx] = useState(0);
|
||
const [edPlaying, setEdPlaying] = useState(false);
|
||
const [edClipMs, setEdClipMs] = useState(0);
|
||
const edCur = edClips[Math.min(edIdx, Math.max(0, edClips.length - 1))] || null;
|
||
const edGlobalMs = edOffsetMs(edIdx) + edClipMs;
|
||
|
||
const gotoClip = useCallback((idx: number, atEnd = false) => {
|
||
if (idx < 0 || idx >= edClips.length) return;
|
||
setEdIdx(idx);
|
||
setEdClipMs(atEnd ? Math.max(0, (edClips[idx]?.durMs || 0) - 200) : 0);
|
||
}, [edClips]);
|
||
|
||
// 在时间轴上跳转到全局毫秒位置(点击标尺 seek)
|
||
const seekToMs = useCallback((globalMs: number) => {
|
||
let acc = 0;
|
||
for (let i = 0; i < edClips.length; i += 1) {
|
||
const dur = edClips[i].durMs || 0;
|
||
if (globalMs <= acc + dur || i === edClips.length - 1) {
|
||
const within = Math.max(0, Math.min(dur, globalMs - acc));
|
||
setEdIdx(i);
|
||
setEdClipMs(within);
|
||
if (edClips[i].isVideo && videoRef.current) videoRef.current.currentTime = within / 1000;
|
||
return;
|
||
}
|
||
acc += dur;
|
||
}
|
||
}, [edClips]);
|
||
|
||
const togglePlay = useCallback(() => {
|
||
setEdPlaying((p) => {
|
||
const next = !p;
|
||
const v = videoRef.current;
|
||
if (edCur?.isVideo && v) {
|
||
if (next) v.play().catch(() => undefined);
|
||
else v.pause();
|
||
}
|
||
return next;
|
||
});
|
||
}, [edCur]);
|
||
|
||
// 上一帧 / 下一帧:视频按 ±1/25s 逐帧 seek,越界切相邻段;静态图切相邻片段
|
||
const stepFrame = useCallback((dir: 1 | -1) => {
|
||
const v = videoRef.current;
|
||
if (edCur?.isVideo && v && v.duration) {
|
||
const t = v.currentTime + dir * (1 / 25);
|
||
if (t < 0) { gotoClip(edIdx - 1, true); return; }
|
||
if (t > v.duration) { gotoClip(edIdx + 1, false); return; }
|
||
v.currentTime = t;
|
||
setEdClipMs(t * 1000);
|
||
} else {
|
||
gotoClip(edIdx + dir, false);
|
||
}
|
||
}, [edCur, edIdx, gotoClip]);
|
||
|
||
// 静态图(无视频文件)播放:定时推进虚拟播放头,到段尾自动进下一段
|
||
useEffect(() => {
|
||
if (!edPlaying || edCur?.isVideo || edClips.length === 0) return;
|
||
const tick = window.setInterval(() => {
|
||
setEdClipMs((ms) => {
|
||
const dur = edCur?.durMs || 2000;
|
||
if (ms + 120 >= dur) {
|
||
if (edIdx + 1 < edClips.length) { setEdIdx(edIdx + 1); return 0; }
|
||
setEdPlaying(false);
|
||
return dur;
|
||
}
|
||
return ms + 120;
|
||
});
|
||
}, 120);
|
||
return () => window.clearInterval(tick);
|
||
}, [edPlaying, edCur, edIdx, edClips.length]);
|
||
|
||
// 切换片段时同步 video 进度,正在播放则续播
|
||
useEffect(() => {
|
||
const v = videoRef.current;
|
||
if (!v || !edCur?.isVideo) return;
|
||
v.currentTime = edClipMs / 1000;
|
||
if (edPlaying) v.play().catch(() => undefined);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [edIdx]);
|
||
|
||
// 键盘:仅 stage5 生效(空格播放/暂停,←/→ 逐帧)
|
||
useEffect(() => {
|
||
if (viewStage !== 5) return;
|
||
function onKey(e: KeyboardEvent) {
|
||
const tag = (e.target as HTMLElement)?.tagName;
|
||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||
if (e.code === "Space") { e.preventDefault(); togglePlay(); }
|
||
else if (e.code === "ArrowLeft") { e.preventDefault(); stepFrame(-1); }
|
||
else if (e.code === "ArrowRight") { e.preventDefault(); stepFrame(1); }
|
||
}
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, [viewStage, togglePlay, stepFrame]);
|
||
|
||
// Stage 4:有运行中的视频段时静默轮询推进(本机无 Celery worker,前端驱动 poll-video-segment),进度条/缩略图实时刷新
|
||
const activeVideoCount = segments.filter((s) => ["running", "queued"].includes(s.status)).length;
|
||
useEffect(() => {
|
||
if (viewStage !== 4 || activeVideoCount === 0) return;
|
||
const timer = window.setInterval(() => { void onPollVideosQuiet(); }, 5000);
|
||
return () => window.clearInterval(timer);
|
||
}, [viewStage, activeVideoCount, onPollVideosQuiet]);
|
||
|
||
// Stage 5:进入拼接页时回填已有导出成片(若此前导过)
|
||
useEffect(() => {
|
||
if (viewStage !== 5) return;
|
||
onRefreshExport();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [viewStage]);
|
||
|
||
// 确认脚本:采用当前脚本(后端推进 SCRIPT→BASE_ASSETS),再进入资产阶段。无脚本时仅切视图。
|
||
async function confirmScript() {
|
||
if (currentScript && !scriptAdopted) {
|
||
await onAdoptScript(currentScript.id);
|
||
}
|
||
goStage(2);
|
||
}
|
||
|
||
// ── Stage 5 编辑器:水合 + 片段操作 + 撤销/重做 + 保存负载 ──
|
||
const [edZoom, setEdZoom] = useState(100);
|
||
useEffect(() => {
|
||
if (viewStage !== 5) { edHydratedRef.current = false; return; }
|
||
if (edHydratedRef.current) return;
|
||
const hasData = Boolean(project.timeline?.clips?.length) || segments.some((s) => s.adopted_asset);
|
||
if (!hasData) return;
|
||
edHydratedRef.current = true;
|
||
setEdState(buildInitialEditor());
|
||
setEdHistory([]); setEdFuture([]); setSelectedClip(0);
|
||
}, [viewStage, project, segments, buildInitialEditor]);
|
||
|
||
const edUndo = useCallback(() => {
|
||
setEdHistory((h) => {
|
||
if (!h.length) return h;
|
||
setEdFuture((f) => [edState, ...f].slice(0, 50));
|
||
setEdState(h[h.length - 1]);
|
||
return h.slice(0, -1);
|
||
});
|
||
}, [edState]);
|
||
const edRedo = useCallback(() => {
|
||
setEdFuture((f) => {
|
||
if (!f.length) return f;
|
||
setEdHistory((h) => [...h, edState].slice(-50));
|
||
setEdState(f[0]);
|
||
return f.slice(1);
|
||
});
|
||
}, [edState]);
|
||
|
||
function edDeleteClip(idx: number) {
|
||
if (idx < 0 || idx >= edState.clips.length || edState.clips.length <= 1) return;
|
||
commitEdit({ ...edState, clips: edState.clips.filter((_, i) => i !== idx) });
|
||
setSelectedClip((s) => Math.max(0, Math.min(s, edState.clips.length - 2)));
|
||
}
|
||
function edCopyClip(idx: number) {
|
||
const c = edState.clips[idx];
|
||
if (!c) return;
|
||
const dup = { ...c, key: `${c.key}-copy-${Date.now()}` };
|
||
commitEdit({ ...edState, clips: [...edState.clips.slice(0, idx + 1), dup, ...edState.clips.slice(idx + 1)] });
|
||
}
|
||
function edSplitAtPlayhead() {
|
||
const idx = edIdx;
|
||
const c = edState.clips[idx];
|
||
if (!c || c.durMs <= 400) return;
|
||
const offset = Math.max(200, Math.min(c.durMs - 200, edClipMs));
|
||
const a: EdClipState = { ...c, key: `${c.key}-a-${Date.now()}`, durMs: offset, trimEndMs: c.trimStartMs + offset };
|
||
const b: EdClipState = { ...c, key: `${c.key}-b-${Date.now()}`, durMs: c.durMs - offset, trimStartMs: c.trimStartMs + offset, trimEndMs: c.trimEndMs };
|
||
commitEdit({ ...edState, clips: [...edState.clips.slice(0, idx), a, b, ...edState.clips.slice(idx + 1)] });
|
||
}
|
||
|
||
const buildSavePayload = useCallback((): TimelineSavePayload => {
|
||
let acc = 0;
|
||
const content = edState.clips.map((c) => { const start = acc; acc += c.durMs; return { start_ms: Math.round(start), text: c.subtitle || "" }; });
|
||
return {
|
||
clips: edState.clips.map((c) => ({ asset: c.asset, duration_ms: Math.round(c.durMs), trim_start_ms: Math.round(c.trimStartMs), trim_end_ms: c.trimEndMs == null ? null : Math.round(c.trimEndMs) })),
|
||
subtitle: { enabled: edState.subtitleEnabled, style_key: edState.subtitleStyle, content },
|
||
bgm: { volume: edState.bgmVolume },
|
||
transition: { type: edState.transition }
|
||
};
|
||
}, [edState]);
|
||
|
||
// 字幕文本编辑(打字不进 undo 栈,避免逐字刷历史)
|
||
function setClipSubtitle(idx: number, text: string) {
|
||
setEdState((s) => ({ ...s, clips: s.clips.map((c, i) => (i === idx ? { ...c, subtitle: text } : c)) }));
|
||
}
|
||
// 片段拖拽重排
|
||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||
function reorderClip(from: number, to: number) {
|
||
if (from === to || from < 0 || to < 0 || from >= edState.clips.length || to >= edState.clips.length) return;
|
||
const clips = [...edState.clips];
|
||
const [moved] = clips.splice(from, 1);
|
||
clips.splice(to, 0, moved);
|
||
commitEdit({ ...edState, clips });
|
||
setSelectedClip(to);
|
||
}
|
||
// 转场实时预览:切片段时若有转场,画面做一次淡场(导出才是真 xfade,这里给即时视觉提示)
|
||
const [fadeKey, setFadeKey] = useState(0);
|
||
const prevEdIdxRef = useRef(edIdx);
|
||
useEffect(() => {
|
||
if (prevEdIdxRef.current !== edIdx) {
|
||
prevEdIdxRef.current = edIdx;
|
||
if (edState.transition !== "none") setFadeKey((k) => k + 1);
|
||
}
|
||
}, [edIdx, edState.transition]);
|
||
|
||
// Stage 4 / 5 文件上传
|
||
function onPickVideoFile(event: ChangeEvent<HTMLInputElement>) {
|
||
const file = event.target.files?.[0];
|
||
event.target.value = "";
|
||
if (file && uploadTargetSeg) onUploadVideoSegment(uploadTargetSeg, file);
|
||
setUploadTargetSeg(null);
|
||
}
|
||
function triggerVideoUpload(segmentId: string) {
|
||
setUploadTargetSeg(segmentId);
|
||
videoUploadRef.current?.click();
|
||
}
|
||
function onPickBgmFile(event: ChangeEvent<HTMLInputElement>) {
|
||
const file = event.target.files?.[0];
|
||
event.target.value = "";
|
||
if (file) onUploadBgm(file, edState.bgmVolume);
|
||
}
|
||
|
||
// 真实商品名 + 封面资产 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);
|
||
setNavigated(true);
|
||
if (typeof location !== "undefined") location.hash = `stage-${n}`;
|
||
}
|
||
function dotCls(n: number) {
|
||
if (n === activeDot) return "sp-dot active";
|
||
if (n <= completed) return "sp-dot done";
|
||
return "sp-dot";
|
||
}
|
||
|
||
return (
|
||
<div className="app pipeline-page">
|
||
<Sidebar page="pipeline" navigate={navigate} user={user} team={team} products={products} projects={projects} />
|
||
<main>
|
||
<Decorations />
|
||
<header className="topbar">
|
||
<div className="pipeline-topbar-left">
|
||
<a className="btn btn-ghost pipeline-back" href="/projects" onClick={(event) => { event.preventDefault(); navigate("projects"); }} aria-label="返回视频项目">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M19 12H5" /><path d="M12 19l-7-7 7-7" /></svg>
|
||
返回视频项目
|
||
</a>
|
||
<div className="pipeline-topbar-title" title={project.name}>
|
||
{project.name}<span className="mono">// PIPELINE</span>
|
||
</div>
|
||
</div>
|
||
<div className="right">
|
||
<span className="balance-chip" onClick={() => navigate("account")}>
|
||
<IconKitSvg name="creditCard" />
|
||
余额 <strong>{money(billing?.account.balance)}</strong>
|
||
</span>
|
||
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
|
||
<IconKitSvg name="bell" />
|
||
{unreadCount > 0 && <span className="count-noti">{unreadCount}</span>}
|
||
</button>
|
||
<div className="topbar-avatar" onDoubleClick={logout} title="账户(双击退出)">
|
||
<span>{avatarChar}</span>
|
||
</div>
|
||
</div>
|
||
<div className="stage-pill" id="stage-pill">
|
||
{STAGE_STEPS.map((step, index) => (
|
||
<Fragment key={step.n}>
|
||
<a className={dotCls(step.n)} data-stage={step.n} href={`#stage-${step.n}`} onClick={(event) => { event.preventDefault(); goStage(step.n); }}>
|
||
<span className="d"></span><span className="l">{step.label}</span>
|
||
</a>
|
||
{index < STAGE_STEPS.length - 1 && <span className={`sp-line${step.n <= completed ? " done" : ""}`}></span>}
|
||
</Fragment>
|
||
))}
|
||
</div>
|
||
</header>
|
||
|
||
<div className="content content--fh content--fh-flat" id="page-content">
|
||
<CornerMarks />
|
||
{notice && <ToastLike notice={notice} />}
|
||
|
||
{/* ============= STAGE 1 · 脚本 ============= */}
|
||
<section className={`stage${viewStage === 1 ? " active" : ""}`} data-stage-pane="1">
|
||
<div className="stage-script">
|
||
<div className="pane shot-list">
|
||
<div className="pane-h">
|
||
<div className="shot-headline">
|
||
<strong>镜头脚本</strong>
|
||
<span className="muted-2 mono" id="shots-meta" style={{ fontSize: "11px" }}>
|
||
{shots.length ? `· ${shots.length} 镜 · ${scriptAdopted ? "已采用" : "待采用"}` : "· 空 · 待生成"}
|
||
</span>
|
||
</div>
|
||
<div className="script-brief-summary" aria-label="当前创作方向">
|
||
<span className="pill neutral script-brief-pill"><span className="k">来源</span><span className="v" id="brief-source">未选择</span></span>
|
||
<span className="pill neutral script-brief-pill"><span className="k">风格</span><span className="v" id="brief-style">待确认</span></span>
|
||
<span className="pill neutral script-brief-pill"><span className="k">人物</span><span className="v" id="brief-persona">待确认</span></span>
|
||
</div>
|
||
<div className="script-tags" id="script-tags">
|
||
<div className="tag-group" data-kind="char">
|
||
<span className="tg-lbl">// 人物</span>
|
||
<button className="tag-add" type="button" aria-label="添加人物" onClick={() => { focusThemeMode(); setChatText((prev) => prev || "增加一个人物角色:"); }}>+</button>
|
||
</div>
|
||
<div className="tag-group" data-kind="scene">
|
||
<span className="tg-lbl">// 场景</span>
|
||
<button className="tag-add" type="button" aria-label="添加场景" onClick={() => { focusThemeMode(); setChatText((prev) => prev || "增加一个场景:"); }}>+</button>
|
||
</div>
|
||
</div>
|
||
<span className="spacer"></span>
|
||
<button className="btn btn-ghost btn-sm" type="button" id="chat-regen-btn" disabled={loading} onClick={() => onGenerateScript("整体重新生成 · 突出商品卖点,节奏紧凑,适合短视频投放")}>↻ 整体重写</button>
|
||
</div>
|
||
<div className="shots-body" id="shots-body">
|
||
{shots.length ? shots.map((shot) => (
|
||
<div className="shot-card" key={shot.id}>
|
||
<div className="shot-n">{shot.sort_order + 1}</div>
|
||
<div className="shot-main">
|
||
<div className="shot-meta">// 场 {shot.sort_order + 1} · {shot.duration_seconds || 15}s</div>
|
||
<div className="shot-narration">{shot.narration?.trim() || "(本镜暂无文案,可在右侧重写脚本)"}</div>
|
||
</div>
|
||
</div>
|
||
)) : (
|
||
<div className="shots-empty">
|
||
<div className="empty-ico"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="14" rx="2" /><path d="M3 10h18M9 5v14" /></svg></div>
|
||
<div className="empty-title">还没有镜头脚本</div>
|
||
<div className="empty-hint">// 跟右侧脚本助手对话<br />选择一种方式生成你的第一稿</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="stage-script-gutter" id="stage-script-gutter" role="separator" aria-orientation="vertical" aria-label="拖动调整脚本助手宽度"></div>
|
||
|
||
<div className="pane chat-pane">
|
||
<div className="pane-h">
|
||
<div className="ai-avatar">AI</div>
|
||
<strong>脚本助手</strong>
|
||
<span className="muted-2 mono" style={{ fontSize: "11px" }}>· GPT-4o</span>
|
||
<span className="spacer"></span>
|
||
<button className="btn btn-ghost btn-sm" type="button" id="chat-clear-btn" disabled={!chatText && chatAttachments.length === 0} onClick={clearChat}>清空对话</button>
|
||
</div>
|
||
<div className="chat-body" id="chat-body">
|
||
<div className="chat-empty">
|
||
<div className="ce-title">选择一种生成方式开始</div>
|
||
<div className="ce-hint">// 三种,由「最省事」到「最保真原意」</div>
|
||
<div className="chat-modes">
|
||
<button className={`chat-mode${chatMode === "ai" ? " primary" : ""}`} type="button" data-mode="ai" disabled={loading} onClick={() => { setChatMode("ai"); onGenerateScript("AI 全生 · 突出商品卖点,节奏紧凑,适合短视频投放"); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z" /></svg>AI 全生</button>
|
||
<button className={`chat-mode${chatMode === "theme" ? " primary" : ""}`} type="button" data-mode="theme" onClick={focusThemeMode}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18h6" /><path d="M10 22h4" /><path d="M15 14a4.65 4.65 0 0 0 1.4-2.5A6 6 0 1 0 6 8c0 1 .23 2.23 1.5 3.5" /></svg>一句话主题</button>
|
||
<button className={`chat-mode${chatMode === "manual" ? " primary" : ""}`} type="button" data-mode="manual" onClick={pickScriptMode}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>自带脚本</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="chat-input">
|
||
<div className="chat-input-card">
|
||
<div className="chat-attach-row" id="chat-attach-row" hidden={chatAttachments.length === 0}>
|
||
{chatAttachments.map((att, index) => (
|
||
<span className="chip" key={`${att.name}-${index}`} style={{ marginRight: 6 }}>
|
||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>
|
||
{att.name} · {att.chars} 字
|
||
<button type="button" aria-label="移除附件" style={{ marginLeft: 4, background: "none", border: 0, cursor: "pointer", color: "inherit" }} onClick={() => setChatAttachments((list) => list.filter((_, i) => i !== index))}>×</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
<textarea ref={chatTextareaRef} className="chat-input-area" id="chat-textarea" placeholder={chatMode === "theme" ? "用一句话描述主题,如:熬夜党的早八续命面膜" : chatMode === "manual" ? "粘贴或上传你的脚本,AI 将据此生成镜头脚本" : "直接说怎么改,如:更像小红书种草 / 换成熬夜党"} rows={2} value={chatText} onChange={(event) => setChatText(event.target.value)}></textarea>
|
||
<input ref={chatFileRef} type="file" accept=".txt,.md,.text,text/plain" style={{ display: "none" }} onChange={onPickScriptFile} />
|
||
<div className="chat-input-foot">
|
||
<button className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本附件" aria-label="上传脚本附件" onClick={() => chatFileRef.current?.click()}>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
|
||
</button>
|
||
<span className="spacer"></span>
|
||
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || !chatText.trim()} onClick={() => { onGenerateScript(chatText.trim()); clearChat(); }}>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="stage-foot">
|
||
<div className="info"><span className="mono">[ LLM 用量 ~2.4k tokens · ¥0.04 · 失败不扣 · 通过后扣 ]</span></div>
|
||
<div className="hstack">
|
||
<button className="btn" type="button" disabled={loading} onClick={() => onGenerateScript("整体重新生成 · 突出商品卖点,节奏紧凑")}><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>
|
||
</div>
|
||
</section>
|
||
|
||
{/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */}
|
||
{viewStage === 2 && (() => {
|
||
const productGroup = groupsByKind("product")[0] || null;
|
||
const productAssetUrl = groupMainUrl(productGroup) || 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">
|
||
{KIND_ORDER.map((kind) => {
|
||
const list = groupsByKind(kind);
|
||
const adopted = list.filter((g) => g.adopted_asset).length;
|
||
return (
|
||
<div className={`ttab${kind === assetTab ? " active" : ""}`} key={kind} data-jump={`asset-sec-${kind}`} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => jumpAssetSection(kind)}>
|
||
<span>{KIND_LABEL[kind]}</span><span className="num">{list.length ? `${adopted}/${list.length}` : "0"}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
<div className="info">
|
||
基础资产是后续故事板的素材。所有卡片同时展示,点左侧分类直接定位。
|
||
<br /><br />
|
||
<strong className="mono">// 人物 +¥0.20/张</strong>
|
||
<strong className="mono">// 场景 +¥0.15/张</strong>
|
||
<span style={{ color: "var(--black-alpha-48)" }}>商品图无成本(直接复用商品库)</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="asset-main">
|
||
<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={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>
|
||
)}
|
||
<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">{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} 三视图`)}>
|
||
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z" /></svg>
|
||
AI 生成三视图
|
||
</button>
|
||
</div>
|
||
</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${candUrl(productGroup, productCandidates[0]) ? " has-mock-media" : ""}`} id="prod-preview-img" style={candUrl(productGroup, productCandidates[0]) ? mediaStyle(candUrl(productGroup, 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${candUrl(productGroup, id) ? " has-mock-media" : ""}`} key={id} style={{ ...(candUrl(productGroup, id) ? mediaStyle(candUrl(productGroup, id)) : {}), width: "44px", height: "44px", flex: "0 0 44px" }}><span className="ph-frame"></span></div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{(["person", "scene"] as const).map((kind) => {
|
||
const list = groupsByKind(kind);
|
||
const genPrompt = kind === "person"
|
||
? `${productName} 真人模特出镜,自然光,商品上身展示,9:16 竖屏`
|
||
: `${productName} 使用场景,氛围统一,干净构图,9:16 竖屏`;
|
||
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>
|
||
<button className="btn-aigen" type="button" data-stop disabled={loading} onClick={() => onGenerateBaseAsset(kind, genPrompt)}>
|
||
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z" /></svg>
|
||
AI 生成{KIND_LABEL[kind]}
|
||
</button>
|
||
</div>
|
||
{list.length ? (
|
||
<div className="asset-grid-2">
|
||
{list.map((group, gi) => {
|
||
const mainUrl = groupMainUrl(group);
|
||
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${candUrl(group, id) ? " has-mock-media" : ""}`} key={id} style={{ ...(candUrl(group, id) ? mediaStyle(candUrl(group, id)) : {}), width: "40px", height: "40px", flex: "0 0 40px" }}><span className="ph-frame"></span></div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="placeholder" style={{ minHeight: "120px", flexDirection: "column", gap: "10px" }}>
|
||
<span className="ph-frame">// 暂无{KIND_LABEL[kind]}资产 · 点上方「AI 生成{KIND_LABEL[kind]}」生成</span>
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="stage-foot">
|
||
<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">
|
||
{sbFrames.length ? sbFrames.map((frame, idx) => {
|
||
const url = frameUrl(frame);
|
||
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>
|
||
{(() => {
|
||
const url = frameUrl(sbActiveFrame);
|
||
return (
|
||
<div className={`placeholder sb-main-img${url ? " has-mock-media" : ""}`} id="sb-main-img" style={url ? { ...mediaStyle(url), cursor: "zoom-in" } : undefined} role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击放大" : undefined} onClick={url ? () => setPreview({ src: url, kind: "image", name: `场 ${sbSelected + 1}` }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, kind: "image", name: `场 ${sbSelected + 1}` }); } } : 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">{sbActiveFrame ? `场 ${sbSelected + 1}` : "—"}</span></strong>
|
||
<span className="spacer"></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">
|
||
<span className="warn-ic" aria-hidden="true">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4M12 17h.01" /></svg>
|
||
</span>
|
||
<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">{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>
|
||
{adoptedStoryboard ? "整张重跑" : "生成故事板"}
|
||
</button>
|
||
<span className="spacer"></span>
|
||
<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">{storyboards.length}</span>)</div>
|
||
<div className="sb-history-row" id="sb-history-row">
|
||
{storyboards.length ? storyboards.map((ver) => {
|
||
const cover = frameUrl([...(ver.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order)[0]);
|
||
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">
|
||
{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 整张输出 · {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>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
{/* ============= STAGE 4 · 视频(video_segments,adopted_asset 缩略 + 状态 + 时长)============= */}
|
||
{viewStage === 4 && (() => {
|
||
const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0;
|
||
const anyStarted = segments.some((s) => ["running", "succeeded", "queued"].includes(s.status));
|
||
const statusText = !segments.length
|
||
? "暂无片段"
|
||
: segDone === segments.length
|
||
? "已完成所有场次"
|
||
: activeVideoCount > 0
|
||
? `生成中 · ${activeVideoCount} 段进行中(自动刷新)`
|
||
: "待生成";
|
||
return (
|
||
<section className="stage active" data-stage-pane="4">
|
||
<div className="queue-bar">
|
||
<div>
|
||
<div style={{ fontSize: "14px", fontWeight: 600 }}>视频生成 · {segDone} / {segments.length} 完成</div>
|
||
<div className="muted-2 mono" style={{ fontSize: "11px", marginTop: "3px", letterSpacing: ".02em" }}>// 每场 Seedance 生成 · {statusText}</div>
|
||
</div>
|
||
<div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
|
||
<span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
|
||
<button className="btn btn-sm btn-primary" type="button" disabled={loading || !segments.length || activeVideoCount > 0} onClick={() => onSubmitAllVideos(videoPrompt)}>{anyStarted ? "↻ 全部重跑" : "▶ 开始生成视频"}</button>
|
||
<button className="btn btn-sm" type="button" disabled={loading || !segments.length} onClick={() => triggerVideoUpload((segments.find((s) => s.status !== "succeeded") || segments[0]).id)}>
|
||
<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>
|
||
<input ref={videoUploadRef} type="file" accept="video/*" style={{ display: "none" }} onChange={onPickVideoFile} />
|
||
|
||
{segments.length ? (
|
||
<div className="video-grid" id="video-grid">
|
||
{segments.map((seg) => {
|
||
const url = segUrl(seg);
|
||
const tone = statusPill(seg.status);
|
||
const busy = ["running", "queued"].includes(seg.status);
|
||
return (
|
||
<div className="video-card" key={seg.id} data-video-id={seg.id}>
|
||
<div className="placeholder video-thumb" style={{ position: "relative", overflow: "hidden" }} role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击播放" : undefined} onClick={url ? () => setPreview({ src: url, kind: "video", name: `场 ${seg.sort_order + 1}` }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, kind: "video", name: `场 ${seg.sort_order + 1}` }); } } : undefined}>
|
||
{url
|
||
? <video src={url} muted playsInline preload="metadata" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
|
||
: <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 || busy} onClick={() => onSubmitVideo(seg.id, `${videoPrompt} 第 ${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds} 秒`)}>{busy ? "生成中…" : "重跑"}</button>
|
||
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading} onClick={() => triggerVideoUpload(seg.id)}>上传</button>
|
||
<span className="spacer"></span>
|
||
{url
|
||
? <a className="btn btn-ghost btn-sm" href={url} target="_blank" rel="noreferrer" data-vstop>下载</a>
|
||
: <button className="btn btn-ghost btn-sm" type="button" data-vstop disabled>下载</button>}
|
||
</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">[ 已完成 {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>
|
||
);
|
||
})()}
|
||
{/* ============= STAGE 5 · 拼接导出(timeline.clips / subtitle_tracks / bgm_tracks 真实定位)============= */}
|
||
{viewStage === 5 && (() => {
|
||
const previewUrl = edCur?.url || assetUrl(tlClips[0]?.asset) || segUrl(segments.find((s) => s.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 ? "背景音乐" : "");
|
||
const showVideo = !!(edCur?.isVideo && edCur.url);
|
||
// 拼接成片:导出成功后用整片预览/下载;导出中显示进度
|
||
const finalUrl = exportResult?.status === "succeeded" ? (exportResult.output_url || "") : "";
|
||
const exporting = exportResult?.status === "queued" || exportResult?.status === "running";
|
||
const exportFailed = exportResult?.status === "failed";
|
||
// ── 编辑器派生(随本地编辑实时变化的时间轴)──
|
||
const STYLE_SWATCHES = [
|
||
{ key: "plain", demo: "", nm: "朴素白底" }, { key: "cinema", demo: "b", nm: "影视黑底" },
|
||
{ key: "handwrite", demo: "c", nm: "手写描边" }, { key: "variety", demo: "d", nm: "综艺暖黄" }
|
||
];
|
||
const TRANSITIONS = [
|
||
{ key: "none", nm: "无转场" }, { key: "fade", nm: "淡入淡出" }, { key: "dissolve", nm: "溶解" },
|
||
{ key: "slideleft", nm: "左滑" }, { key: "wiperight", nm: "擦除" }
|
||
];
|
||
const edRulerMs = edTotalMs;
|
||
const edRuler = buildRuler(edRulerMs / 1000);
|
||
const edOffsets = edClips.map((_, i) => edClips.slice(0, i).reduce((sum, c) => sum + c.durMs, 0));
|
||
const serverBgm = (project.timeline?.bgm_tracks ?? [])[0] || null;
|
||
const serverBgmUrl = serverBgm?.asset_url || "";
|
||
const serverBgmName = serverBgm?.asset_name || (serverBgm ? "背景音乐" : "");
|
||
const subVisible = edState.subtitleEnabled;
|
||
return (
|
||
<section className="stage active" data-stage-pane="5">
|
||
<div className="editor">
|
||
<div className="editor-preview">
|
||
<div className={`canvas${!showVideo && !finalUrl && previewUrl ? " has-mock-media" : ""}`} id="ed-canvas" style={!showVideo && !finalUrl && previewUrl ? mediaStyle(previewUrl) : undefined}>
|
||
{finalUrl ? (
|
||
<>
|
||
<video
|
||
src={finalUrl}
|
||
controls
|
||
playsInline
|
||
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "contain", background: "#000", borderRadius: "inherit" }}
|
||
/>
|
||
<span className="pill ok" style={{ position: "absolute", top: 10, left: 10, zIndex: 4 }}><span className="dot"></span>成片</span>
|
||
</>
|
||
) : showVideo ? (
|
||
<video
|
||
ref={videoRef}
|
||
src={edCur!.url}
|
||
playsInline
|
||
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }}
|
||
onTimeUpdate={(e) => setEdClipMs(e.currentTarget.currentTime * 1000)}
|
||
onEnded={() => { if (edIdx + 1 < edClips.length) gotoClip(edIdx + 1, false); else setEdPlaying(false); }}
|
||
onPlay={() => setEdPlaying(true)}
|
||
onPause={() => setEdPlaying(false)}
|
||
/>
|
||
) : (
|
||
<span id="ed-canvas-label">{exporting ? `拼接中… ${exportResult?.progress ?? 0}%` : `${aspect} 预览 · ${resolution}${edClips.length ? ` · 片段 ${Math.min(edIdx + 1, edClips.length)}/${edClips.length}` : ""}`}</span>
|
||
)}
|
||
{showVideo && !finalUrl && edState.transition !== "none" && <div key={fadeKey} className="ed-xfade-flash" aria-hidden="true" />}
|
||
</div>
|
||
<div className="controls">
|
||
<button className="ctl-btn" type="button" id="ed-prev-btn" title="上一帧 (←)" onClick={() => stepFrame(-1)} disabled={edClips.length === 0}><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="播放 / 暂停 (空格)" onClick={togglePlay} disabled={edClips.length === 0}>
|
||
{edPlaying
|
||
? <svg id="ed-play-icon" width="16" height="16" viewBox="0 0 16 16"><path d="M4 3h3v10H4zM9 3h3v10H9z" fill="currentColor" /></svg>
|
||
: <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="下一帧 (→)" onClick={() => stepFrame(1)} disabled={edClips.length === 0}><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">{fmtMs(edGlobalMs)}</span> / <span id="ed-total-time">{fmtMs(edTotalMs)}</span></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="editor-props">
|
||
<div className="props-tabs">
|
||
<div className={propsTab === "subtitle" ? "active" : ""} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setPropsTab("subtitle")}>字幕</div>
|
||
<div className={propsTab === "transition" ? "active" : ""} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setPropsTab("transition")}>转场</div>
|
||
<div className={propsTab === "bgm" ? "active" : ""} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setPropsTab("bgm")}>BGM</div>
|
||
</div>
|
||
|
||
{propsTab === "subtitle" && (
|
||
<>
|
||
<div className="props-row" style={{ marginBottom: 8 }}>
|
||
<span className="k">烧入字幕</span>
|
||
<button className={`btn btn-sm ${edState.subtitleEnabled ? "btn-primary" : "btn-ghost"}`} type="button" onClick={() => commitEdit({ ...edState, subtitleEnabled: !edState.subtitleEnabled })}>{edState.subtitleEnabled ? "已开启" : "已关闭"}</button>
|
||
</div>
|
||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 字幕样式(导出烧入)</div>
|
||
<div className="style-swatch">
|
||
{STYLE_SWATCHES.map((sw) => (
|
||
<div className={`swatch-card${edState.subtitleStyle === sw.key ? " selected" : ""}`} key={sw.key} role="button" tabIndex={0} style={{ cursor: "pointer", opacity: edState.subtitleEnabled ? 1 : 0.5 }} onClick={() => commitEdit({ ...edState, subtitleStyle: sw.key, subtitleEnabled: true })}>
|
||
<div className={`demo${sw.demo ? ` ${sw.demo}` : ""}`}>真实分享</div><div className="nm">{sw.nm}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, margin: "12px 0 6px", letterSpacing: ".04em" }}>// 字幕文本(默认取脚本旁白,可逐段改)</div>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px", maxHeight: "186px", overflowY: "auto" }}>
|
||
{edState.clips.map((c, idx) => (
|
||
<div key={c.key} style={{ display: "flex", gap: "6px", alignItems: "flex-start" }}>
|
||
<span className="mono" style={{ fontSize: "10px", color: "var(--black-alpha-48)", marginTop: "7px", flex: "0 0 auto" }}>{idx + 1}</span>
|
||
<textarea value={c.subtitle} onChange={(e) => setClipSubtitle(idx, e.target.value)} rows={1} disabled={!edState.subtitleEnabled} placeholder={`第 ${idx + 1} 段字幕`} style={{ flex: 1, minWidth: 0, resize: "vertical", fontSize: "12px", lineHeight: 1.4, padding: "4px 6px", border: "1px solid var(--border-faint)", borderRadius: "6px", background: "var(--surface)", color: "var(--accent-black)", fontFamily: "inherit" }} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{propsTab === "transition" && (
|
||
<>
|
||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 片段间转场(导出 xfade 烧入)</div>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||
{TRANSITIONS.map((tr) => (
|
||
<button className={`btn btn-sm ${edState.transition === tr.key ? "btn-primary" : "btn-ghost"}`} key={tr.key} type="button" style={{ justifyContent: "flex-start" }} onClick={() => commitEdit({ ...edState, transition: tr.key })}>{tr.nm}</button>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{propsTab === "bgm" && (
|
||
<>
|
||
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 背景音乐(导出混音)</div>
|
||
<div className="props-row"><span style={{ fontSize: "12px", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{serverBgm ? serverBgmName : "未设置 BGM"}</span></div>
|
||
{serverBgmUrl && <audio src={serverBgmUrl} controls style={{ width: "100%", height: 30, marginBottom: 8 }} />}
|
||
<div className="props-row"><span className="k">音量 {edState.bgmVolume}</span>
|
||
<input type="range" min={0} max={100} value={edState.bgmVolume} onChange={(e) => setEdState((s) => ({ ...s, bgmVolume: Number(e.target.value) }))} style={{ flex: 1 }} />
|
||
</div>
|
||
<button className="btn btn-sm" type="button" disabled={loading} onClick={() => bgmFileRef.current?.click()} style={{ marginTop: 6 }}>{serverBgm ? "替换 BGM" : "上传 BGM"}</button>
|
||
<input ref={bgmFileRef} type="file" accept="audio/*" style={{ display: "none" }} onChange={onPickBgmFile} />
|
||
</>
|
||
)}
|
||
|
||
<div className="divider"></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" value={fmtMs(edRulerMs)} readOnly /></div>
|
||
<div className="props-row"><span className="k">片段</span><input className="input-mini" value={`${edClips.length} 段`} readOnly /></div>
|
||
<div className="props-row"><span className="k">字幕</span><input className="input-mini" value={subVisible ? `${edClips.length} 条` : "关"} readOnly /></div>
|
||
<div className="props-row"><span className="k">转场</span><input className="input-mini" value={(TRANSITIONS.find((t) => t.key === edState.transition) || TRANSITIONS[0]).nm} readOnly /></div>
|
||
<div className="props-row"><span className="k">分辨率</span><span className="mono" style={{ fontSize: "11.5px" }}>{resolution}</span></div>
|
||
</div>
|
||
|
||
<div className="timeline" id="ed-timeline" style={{ overflowX: edZoom > 100 ? "auto" : "hidden" }}>
|
||
<div className="tl-toolbar">
|
||
<button className="tl-action" type="button" title="撤销" disabled={!edHistory.length} onClick={edUndo}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7v6h6" /><path d="M21 17a9 9 0 0 0-15-6.7L3 13" /></svg></button>
|
||
<button className="tl-action" type="button" title="重做" disabled={!edFuture.length} onClick={edRedo}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 7v6h-6" /><path d="M3 17a9 9 0 0 1 15-6.7L21 13" /></svg></button>
|
||
<span className="tl-sep"></span>
|
||
<button className="tl-action" type="button" title="在播放头处分割所在片段" disabled={!edClips.length} onClick={edSplitAtPlayhead}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M20 4L8.12 15.88" /><path d="M14.47 14.48L20 20" /><path d="M8.12 8.12L12 12" /></svg>分割</button>
|
||
<button className="tl-action" type="button" title="复制选中片段" disabled={!edClips.length} onClick={() => edCopyClip(selectedClip)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>复制</button>
|
||
<button className="tl-action danger" type="button" title="删除选中片段" disabled={edClips.length <= 1} onClick={() => edDeleteClip(selectedClip)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /><path d="M19 6l-1.5 14a2 2 0 0 1-2 1.8H8.5a2 2 0 0 1-2-1.8L5 6" /></svg>删除</button>
|
||
<span className="spacer"></span>
|
||
<div className="tl-zoom"><span className="lbl">// zoom {edZoom}%</span><input type="range" min={100} max={300} value={edZoom} onChange={(e) => setEdZoom(Number(e.target.value))} /></div>
|
||
</div>
|
||
|
||
<div style={{ width: `${edZoom}%`, minWidth: "100%" }}>
|
||
<div className="tl-ruler">
|
||
<div className="l">// time</div>
|
||
<div
|
||
className="rule-track"
|
||
id="ed-ruler"
|
||
style={{ cursor: edClips.length ? "pointer" : "default" }}
|
||
onClick={(event) => {
|
||
if (!edClips.length) return;
|
||
const rect = event.currentTarget.getBoundingClientRect();
|
||
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||
seekToMs(frac * edRulerMs);
|
||
}}
|
||
>
|
||
{edRuler.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>
|
||
))}
|
||
{edClips.length > 0 && (
|
||
<span style={{ position: "absolute", top: 0, bottom: 0, left: `${Math.min(100, (edGlobalMs / (edRulerMs || 1)) * 100)}%`, width: "2px", background: "var(--heat)", zIndex: 5, pointerEvents: "none" }} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<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">
|
||
{edClips.length > 0 && (
|
||
<span style={{ position: "absolute", top: 0, bottom: 0, left: `${Math.min(100, (edGlobalMs / (edRulerMs || 1)) * 100)}%`, width: "2px", background: "var(--heat)", zIndex: 6, pointerEvents: "none" }} />
|
||
)}
|
||
{edClips.length ? edClips.map((c, idx) => {
|
||
const { leftPct, widthPct } = clipLayout(edOffsets[idx], c.durMs, edRulerMs);
|
||
const lbl = assetName(c.assetId) || `片段 ${idx + 1}`;
|
||
const frameCount = Math.max(1, Math.round(c.durMs / 1000));
|
||
return (
|
||
<div
|
||
className="clip video"
|
||
key={c.id}
|
||
data-track="video"
|
||
data-label={lbl}
|
||
draggable
|
||
onDragStart={() => setDragIdx(idx)}
|
||
onDragOver={(e) => e.preventDefault()}
|
||
onDrop={(e) => { e.preventDefault(); if (dragIdx != null) reorderClip(dragIdx, idx); setDragIdx(null); }}
|
||
onDragEnd={() => setDragIdx(null)}
|
||
title="拖拽可重排片段"
|
||
style={{ left: `${leftPct}%`, width: `${widthPct}%`, cursor: "grab", opacity: dragIdx === idx ? 0.4 : 1, outline: idx === selectedClip ? "2px solid var(--heat)" : undefined, outlineOffset: "-2px" }}
|
||
onClick={() => { setSelectedClip(idx); gotoClip(idx, false); }}
|
||
>
|
||
<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">
|
||
{subVisible && edState.clips.map((c, idx) => {
|
||
const { leftPct, widthPct } = clipLayout(edOffsets[idx], c.durMs, edRulerMs);
|
||
const text = c.subtitle || `字幕 ${idx + 1}`;
|
||
return (
|
||
<div className="clip subtitle" key={c.key} data-track="subtitle" data-label={text} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}><span className="lbl">{text}</span></div>
|
||
);
|
||
})}
|
||
<div className="playhead" id="ed-playhead" style={{ left: `${Math.min(100, (edGlobalMs / (edRulerMs || 1)) * 100)}%` }}><span className="ph-grab"></span></div>
|
||
</div>
|
||
</div>
|
||
|
||
{serverBgm && (
|
||
<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={serverBgmName} 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">{serverBgmName} · 音量 {edState.bgmVolume}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="stage-foot">
|
||
<div className="info">
|
||
<span className="mono">[ 时间轴 {fmtMs(edRulerMs)} · {edClips.length} 段 · 拼接 / 导出全程 0 token ]</span>
|
||
{exporting && <span className="mono" style={{ marginLeft: 10, color: "var(--heat)" }}>// 拼接中 {exportResult?.progress ?? 0}%</span>}
|
||
{finalUrl && <span className="mono" style={{ marginLeft: 10, color: "var(--heat)" }}>// 成片已就绪</span>}
|
||
{exportFailed && <span className="mono" style={{ marginLeft: 10, color: "var(--err, #d33)" }}>// 导出失败:{exportResult?.error_message || "请重试"}</span>}
|
||
{!canExport && !finalUrl && !exporting && <span className="mono" style={{ marginLeft: 10, color: "var(--black-alpha-48)" }}>// 待全部视频片段生成完成后可导出</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" disabled={loading} onClick={() => onSaveTimeline(buildSavePayload())}>保存草稿</button>
|
||
{finalUrl && (
|
||
<a className="btn" href={finalUrl} 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={!canExport || loading || exporting} onClick={() => onSubmitExport(buildSavePayload())}>
|
||
{exporting ? "拼接中…" : finalUrl ? "重新导出" : "导出 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>
|
||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
|
||
</div>
|
||
);
|
||
}
|