feat(editor): 剪辑成片验收闭环 - 概览渲染计数、导入/字幕/预览修复与本地 whisper 转写运行时 (#414)
* feat(editor): 剪辑成片验收闭环 - 概览渲染成功计数、媒体导入/字幕/预览修复与本地 whisper 转写运行时 - 概览:渲染成功计数接入项目概览 SQL/JSON,成片状态随 timeline_render 任务联动 - 修复:本地媒体导入后列表为空(挂载时服务端重同步 + 导入后校验重取)、 字幕工具残留 M6 占位文案、预览监视器悬浮遮挡层改为顶部信息条、 生成任务轮询连续查询失败即抛错不再空转至超时 - 本地转写:新增 scripts/start-whisper-local.sh 一键启动 whisper.cpp, local-development 文档补安装/启动/后端注入指引 - 同步 docs(overview/features、pending-test、index) * fix(assets): 素材库封面未知 kind 兜底,避免 AssetCover 渲染崩溃 运行时素材记录若带未知/脏 kind(历史遗留或本地存储异常),assetKindIcons[asset.kind] 得到 undefined,AssetCover/AssetDrawer 渲染 <KindIcon/> 触发 'Element type is invalid ... got: undefined' 使 /assets 整页崩溃。 新增 isKnownAssetKind 守卫:未知 kind 回退 FileText 图标与通用文案,脏数据不再崩页。 * fix(editor): mp4v 播放死循环修复——MPEG-4 Part 2 归入播放转码通道,监视器加终态护栏 - 后端把 mp4v 与 H.264/AV1/VP9 一并判为浏览器可播 → playbackStatus=none; Chromium/Firefox/Edge 实际均不支持 MPEG-4 Part 2,原件解码即失败。 现将其并入 H.265 转码通道(转 H.264 兼容副本);损坏文件转码失败落 failed,前端据此显示终止性错误而非死循环。 - 前端监视器轮询到 none/failed 或超时后不再回退 primary 重试;副本 URL 回退原件反复触发 onError 时保持终态文案,不再覆盖成"正在生成兼容版本"。 * feat(editor): 画布产物自动同步剪辑素材库,素材面板新增「画布素材」分组 - 打开剪辑器时自动把画布工作台当前采用(storyboard/action_board/分镜/导出成片, ready 且有资源)的产物 linkProjectAsset(source=canvas) 并入项目素材库; 后端幂等、前端按 storageKey+会话 ref 去重,失败静默下次进入自动补同步。 - LinkProjectAsset 支持 source 白名单(uploaded/canvas)并写入素材 payload, 供素材面板按来源分组;面板新增「画布素材」页签与分组,与「本地上传」「项目素材」并列。 - 补 source=canvas 写入断言测试。 * docs(progress): 记录 mp4v 播放死循环修复与画布产物素材同步的待浏览器验收项
This commit is contained in:
@@ -16,7 +16,7 @@ export type EditorHostContextValue = {
|
||||
/** 项目资产列表(M3.4 asset-ingest 消费;由宿主在进入编辑器时获取)。 */
|
||||
assets: ProjectAsset[];
|
||||
/** 重新拉取项目资产(导入媒体后刷新列表)。 */
|
||||
refreshAssets: () => Promise<void>;
|
||||
refreshAssets: () => Promise<ProjectAsset[] | null>;
|
||||
};
|
||||
|
||||
const EditorStoreContext = createContext<EditorStoreHook | null>(null);
|
||||
|
||||
@@ -34,15 +34,17 @@ const IMAGE_RE = /\.(png|jpe?g|webp|gif|avif)$/i;
|
||||
const AUDIO_RE = /\.(mp3|wav|m4a|ogg|flac|aac)$/i;
|
||||
const MEDIA_RE = /\.(mp4|mov|webm|mkv|m4v|avi)$/i;
|
||||
|
||||
/** 本地上传标记(assetFromUploadedResource 写入 payload.data.source)。 */
|
||||
/** 本地上传标记与画布产物标记(assetFromUploadedResource 写入 payload.data.source)。 */
|
||||
const SOURCE_UPLOADED = "uploaded";
|
||||
const SOURCE_CANVAS = "canvas";
|
||||
|
||||
type AssetFilter = "all" | "project" | "uploaded";
|
||||
type AssetFilter = "all" | "project" | "uploaded" | "canvas";
|
||||
|
||||
const FILTER_TABS: { id: AssetFilter; label: string }[] = [
|
||||
{ id: "all", label: "全部" },
|
||||
{ id: "project", label: "项目素材" },
|
||||
{ id: "uploaded", label: "本地上传" },
|
||||
{ id: "canvas", label: "画布素材" },
|
||||
];
|
||||
|
||||
/** 按 MIME 与扩展名推断媒体 kind;非媒体文件返回 null。 */
|
||||
@@ -201,15 +203,16 @@ function MediaPreview({ asset }: { asset: ProjectAsset }) {
|
||||
|
||||
function SourceBadge({ source }: { source: string }) {
|
||||
const uploaded = source === SOURCE_UPLOADED;
|
||||
const canvas = source === SOURCE_CANVAS;
|
||||
return (
|
||||
<span
|
||||
className={`rounded px-1 py-px text-[9px] leading-none ${
|
||||
uploaded
|
||||
uploaded || canvas
|
||||
? "bg-[var(--director-control-hover)] text-[var(--director-dock-fg-strong)]"
|
||||
: "bg-[var(--director-control-hover)] text-[var(--director-dock-fg)]/70"
|
||||
}`}
|
||||
>
|
||||
{uploaded ? "本地上传" : "项目素材"}
|
||||
{uploaded ? "本地上传" : canvas ? "画布素材" : "项目素材"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -230,7 +233,7 @@ export function EditorAssetIngest() {
|
||||
|
||||
const [filter, setFilter] = useState<AssetFilter>("all");
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [openGroups, setOpenGroups] = useState<{ uploaded: boolean; project: boolean }>({ uploaded: true, project: true });
|
||||
const [openGroups, setOpenGroups] = useState<{ uploaded: boolean; project: boolean; canvas: boolean }>({ uploaded: true, project: true, canvas: true });
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const lastAddKey = useRef<string | null>(null);
|
||||
const lastAddAt = useRef(0);
|
||||
@@ -264,6 +267,7 @@ export function EditorAssetIngest() {
|
||||
});
|
||||
const skipped = media.length - toImport.length;
|
||||
// 逐文件导入:单文件失败不中断整批,汇总失败数提示。
|
||||
const linkedIds: string[] = [];
|
||||
for (const { file, kind } of toImport) {
|
||||
try {
|
||||
// 上传前探测真实时长(视频/音频),随 meta 入库供时间线片段使用。
|
||||
@@ -291,6 +295,7 @@ export function EditorAssetIngest() {
|
||||
continue;
|
||||
}
|
||||
okCount += 1;
|
||||
linkedIds.push(resource.id);
|
||||
} catch (err) {
|
||||
failedNames.push(file.name);
|
||||
const detail = extractApiMessage(err);
|
||||
@@ -298,7 +303,12 @@ export function EditorAssetIngest() {
|
||||
}
|
||||
}
|
||||
if (okCount > 0) {
|
||||
await refreshAssets();
|
||||
// 刷新后校验本次挂载的素材是否都出现在列表里;若单次刷新因网络抖动
|
||||
// 或后端提交延迟而拿不到最新结果,立即再刷新一次,避免列表停留在旧快照。
|
||||
const firstList = await refreshAssets();
|
||||
if (firstList && linkedIds.some((id) => !firstList.some((asset) => asset.id === id))) {
|
||||
await refreshAssets();
|
||||
}
|
||||
setImportNote(skipped > 0 ? `已导入 ${okCount} 个,跳过 ${skipped} 个重复文件` : `已导入 ${okCount} 个媒体`);
|
||||
} else if (skipped > 0 && failedNames.length === 0) {
|
||||
setImportNote(`媒体库中已有同名素材,跳过 ${skipped} 个重复文件`);
|
||||
@@ -336,9 +346,11 @@ export function EditorAssetIngest() {
|
||||
};
|
||||
|
||||
const uploadedAssets = assets.filter((a) => a.source === SOURCE_UPLOADED);
|
||||
const projectAssets = assets.filter((a) => a.source !== SOURCE_UPLOADED);
|
||||
const groups: { id: "uploaded" | "project"; label: string; icon: typeof HardDrive; items: ProjectAsset[] }[] = [
|
||||
const canvasAssets = assets.filter((a) => a.source === SOURCE_CANVAS);
|
||||
const projectAssets = assets.filter((a) => a.source !== SOURCE_UPLOADED && a.source !== SOURCE_CANVAS);
|
||||
const groups: { id: "uploaded" | "project" | "canvas"; label: string; icon: typeof HardDrive; items: ProjectAsset[] }[] = [
|
||||
{ id: "uploaded", label: "本地上传", icon: HardDrive, items: uploadedAssets },
|
||||
{ id: "canvas", label: "画布素材", icon: Boxes, items: canvasAssets },
|
||||
{ id: "project", label: "项目素材", icon: Boxes, items: projectAssets },
|
||||
];
|
||||
const visibleGroups = groups.filter((g) => filter === "all" || g.id === filter);
|
||||
@@ -416,7 +428,7 @@ export function EditorAssetIngest() {
|
||||
) : visibleGroups.every((g) => g.items.length === 0) ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-4 text-center">
|
||||
<p className="text-xs text-[var(--director-dock-fg)]/60">
|
||||
{filter === "uploaded" ? "暂无本地上传的媒体" : filter === "project" ? "暂无项目素材" : "项目暂无资产"}
|
||||
{filter === "uploaded" ? "暂无本地上传的媒体" : filter === "canvas" ? "暂无画布素材,请先在画布工作台生成分镜/预演" : filter === "project" ? "暂无项目素材" : "项目暂无资产"}
|
||||
</p>
|
||||
<p className="max-w-[180px] text-[11px] leading-relaxed text-[var(--director-dock-fg)]/45">点击上方导入媒体,或将文件拖入此区域</p>
|
||||
</div>
|
||||
@@ -487,7 +499,7 @@ export function EditorAssetIngest() {
|
||||
<dt className="text-[var(--director-dock-fg)]/60">分类</dt>
|
||||
<dd className="truncate text-right text-[var(--director-dock-fg-strong)]">{asset.category}</dd>
|
||||
<dt className="text-[var(--director-dock-fg)]/60">来源</dt>
|
||||
<dd className="text-right text-[var(--director-dock-fg-strong)]">{source === SOURCE_UPLOADED ? "本地上传" : "项目素材"}</dd>
|
||||
<dd className="text-right text-[var(--director-dock-fg-strong)]">{source === SOURCE_UPLOADED ? "本地上传" : source === SOURCE_CANVAS ? "画布素材" : "项目素材"}</dd>
|
||||
{asset.durationMs ? (
|
||||
<>
|
||||
<dt className="text-[var(--director-dock-fg)]/60">时长</dt>
|
||||
|
||||
@@ -11,7 +11,25 @@
|
||||
// - 静音跟随所在音频/视频轨道(muted),不再硬编码 muted(否则"有声音"永远不成立)。
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, Film, Gauge, Pause, Play, SkipBack, SkipForward, StepBack, StepForward } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Captions,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Film,
|
||||
Gauge,
|
||||
Image as ImageIcon,
|
||||
LoaderCircle,
|
||||
Pause,
|
||||
Play,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
StepBack,
|
||||
StepForward,
|
||||
Type,
|
||||
Video,
|
||||
Volume2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useEditorStoreContext } from "@/components/editor/editor-context";
|
||||
import { formatTimelineTime } from "@/lib/timeline/timeline-view";
|
||||
@@ -67,6 +85,31 @@ function clipSourceTimeMs(clip: TimelineClip, transportMs: number): number {
|
||||
return Math.max(0, transportMs - clip.startMs + (clip.sourceStartMs ?? 0));
|
||||
}
|
||||
|
||||
function clipKindLabel(kind: TimelineClip["kind"]): string {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return "视频";
|
||||
case "image":
|
||||
return "图片";
|
||||
case "audio":
|
||||
return "音频";
|
||||
case "subtitle":
|
||||
return "字幕";
|
||||
case "text":
|
||||
return "文字";
|
||||
}
|
||||
}
|
||||
|
||||
function clipDisplayTitle(clip: TimelineClip): string {
|
||||
return clip.directMedia?.title || clip.title || clip.text || clip.nodeId || clip.id;
|
||||
}
|
||||
|
||||
function ClipKindIcon({ kind, className }: { kind: TimelineClip["kind"]; className?: string }) {
|
||||
const Icon =
|
||||
kind === "video" ? Video : kind === "image" ? ImageIcon : kind === "audio" ? Volume2 : kind === "subtitle" ? Captions : Type;
|
||||
return <Icon className={className} />;
|
||||
}
|
||||
|
||||
export function EditorPreviewMonitor() {
|
||||
const { project, transportMs, setTransportMs } = useEditorStoreContext();
|
||||
const durationMs = project?.durationMs ?? 0;
|
||||
@@ -95,11 +138,15 @@ export function EditorPreviewMonitor() {
|
||||
const [mediaTier, setMediaTier] = useState<"primary" | "variant">("primary");
|
||||
const [variantReadyTick, setVariantReadyTick] = useState(0);
|
||||
const [mediaErrorHint, setMediaErrorHint] = useState<string | null>(null);
|
||||
// 终态护栏:none/failed/超时/不可达后禁止再回退 primary 重试或覆盖终态文案。
|
||||
// 否则原件不可解码且后端无副本可生成时会 primary→variant→primary 无限循环。
|
||||
const mediaTerminalRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMediaTier("primary");
|
||||
setVariantReadyTick(0);
|
||||
setMediaErrorHint(null);
|
||||
mediaTerminalRef.current = false;
|
||||
}, [activeClip?.id, storageKey, activeMediaUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -116,16 +163,19 @@ export function EditorPreviewMonitor() {
|
||||
setVariantReadyTick((n) => n + 1);
|
||||
window.clearInterval(timer);
|
||||
} else if (res.playbackStatus === "failed") {
|
||||
mediaTerminalRef.current = true;
|
||||
setMediaErrorHint("兼容副本生成失败,可下载原片后用本地播放器观看。");
|
||||
window.clearInterval(timer);
|
||||
} else if (res.playbackStatus === "none") {
|
||||
// 后端判定无需转码(远端存储/无 ffmpeg/编码不可处理):无副本可等,
|
||||
// 停轮询回到原件原生播放,避免"正在生成"永挂。
|
||||
setMediaTier("primary");
|
||||
setMediaErrorHint(null);
|
||||
// 后端判定无播放副本可生成(远端存储/无 ffmpeg/编码不可处理)。
|
||||
// 原件此刻必然已 onError 失败才进入 variant 轮询,回退 primary 只会
|
||||
// 再次失败并切回 variant,形成无限循环 —— 直接进入终态提示并停轮询。
|
||||
mediaTerminalRef.current = true;
|
||||
setMediaErrorHint("视频编码此浏览器暂不支持,且无可生成的兼容副本;可下载原片转换格式后重新导入。");
|
||||
window.clearInterval(timer);
|
||||
} else if ((polls += 1) >= 120) {
|
||||
// processing 上限保护(约 5 分钟):转码异常卡死时不再无限轮询。
|
||||
mediaTerminalRef.current = true;
|
||||
setMediaErrorHint("兼容版本生成超时,可下载原片后用本地播放器观看。");
|
||||
window.clearInterval(timer);
|
||||
} else {
|
||||
@@ -136,6 +186,7 @@ export function EditorPreviewMonitor() {
|
||||
if (cancelled) return;
|
||||
failures += 1;
|
||||
if (failures >= 4) {
|
||||
mediaTerminalRef.current = true;
|
||||
window.clearInterval(timer);
|
||||
setMediaErrorHint("兼容版本生成服务暂不可达,请稍后重新打开预览重试。");
|
||||
}
|
||||
@@ -148,9 +199,12 @@ export function EditorPreviewMonitor() {
|
||||
}, [mediaTier, mediaResourceId]);
|
||||
|
||||
const handleMediaError = () => {
|
||||
// 已进入终态(副本生成失败/无副本可生成/超时/服务不可达):保持终态文案,
|
||||
// 不再改写为"正在生成"或回退重试 —— 副本 URL 在 failed/none 时会回退原件,
|
||||
// 反复 onError 会把失败提示覆盖成误导性的"正在生成兼容版本"。
|
||||
if (mediaTerminalRef.current) return;
|
||||
// 图片/无资源:无播放副本可切,仅提示。
|
||||
if (activeClip?.kind !== "video" || !mediaResourceId) {
|
||||
setMediaErrorHint("该媒体无法解码,可下载原片后用本地播放器观看。");
|
||||
return;
|
||||
}
|
||||
if (mediaTier === "primary") {
|
||||
@@ -351,6 +405,52 @@ export function EditorPreviewMonitor() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-[var(--director-sequencer-surface)]">
|
||||
{/* 顶部信息条:媒体标题/元数据常驻在画面外的标题栏,不再悬浮遮挡画面内容 */}
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{activeClip ? (
|
||||
<>
|
||||
<ClipKindIcon kind={activeClip.kind} className="size-3.5 shrink-0 text-[var(--director-dock-fg)]/55" />
|
||||
<span className="truncate text-xs font-medium text-[var(--director-dock-fg-strong)]" title={clipDisplayTitle(activeClip)}>
|
||||
{clipDisplayTitle(activeClip)}
|
||||
</span>
|
||||
<span className="hidden shrink-0 rounded-full bg-[var(--director-dock-active-surface)] px-1.5 py-0.5 text-[10px] leading-none text-[var(--director-dock-fg)]/85 sm:inline-flex">
|
||||
{clipKindLabel(activeClip.kind)}
|
||||
</span>
|
||||
{activeTrack && (
|
||||
<span className="hidden shrink-0 rounded-full border border-[var(--director-sequencer-border)] px-1.5 py-0.5 text-[10px] leading-none text-[var(--director-dock-fg)]/55 md:inline-flex">
|
||||
{activeTrack.label}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="truncate text-xs text-[var(--director-dock-fg)]/40">预览监视器</span>
|
||||
)}
|
||||
</div>
|
||||
{mediaErrorHint && (
|
||||
<span
|
||||
title={mediaErrorHint}
|
||||
className="flex min-w-0 max-w-[42%] items-center gap-1.5 rounded-md bg-[var(--director-danger)]/10 px-2 py-1 text-[11px] text-[var(--director-danger)]"
|
||||
>
|
||||
{mediaErrorHint.includes("正在生成") ? (
|
||||
<LoaderCircle className="size-3 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<AlertTriangle className="size-3 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{mediaErrorHint}</span>
|
||||
{mediaResourceId && (
|
||||
<a
|
||||
href={resourceFileUrl(mediaResourceId)}
|
||||
download
|
||||
title="下载原片,用本地播放器观看"
|
||||
className="grid size-4 shrink-0 place-items-center rounded hover:bg-[var(--director-danger)]/15"
|
||||
>
|
||||
<Download className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 预览画面:恒黑舞台,占满控制条以上剩余空间(Concat 监视器风格) */}
|
||||
<div className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black p-6">
|
||||
{showEmpty ? (
|
||||
@@ -388,25 +488,6 @@ export function EditorPreviewMonitor() {
|
||||
onError={handleMediaError}
|
||||
/>
|
||||
) : null}
|
||||
{mediaErrorHint && (
|
||||
<div className="absolute inset-x-0 bottom-0 flex flex-col items-center gap-2 bg-black/70 px-4 py-3 backdrop-blur">
|
||||
<p className="text-xs leading-relaxed text-white/90">{mediaErrorHint}</p>
|
||||
{mediaResourceId && (
|
||||
<a
|
||||
href={resourceFileUrl(mediaResourceId)}
|
||||
download
|
||||
className="text-[11px] underline decoration-white/40 underline-offset-2 text-white/70 hover:text-white"
|
||||
>
|
||||
下载原片用本地播放器观看
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeClip && (
|
||||
<div className="absolute left-4 top-4 rounded-md bg-black/60 px-2 py-1 text-[11px] text-white/90 backdrop-blur">
|
||||
{activeClip.nodeId || activeClip.id} · {activeClip.kind}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 播放控制条:画面下方的独立一行(Concat 布局),不悬浮叠加、无进度条;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// 字幕工具(editor-shell 预设插件贡献 subtitle-tool 插槽,M3.5)。
|
||||
// SRT 导入导出(复用 srt-parser)+ 「从节点重建字幕片段」命令(§3.1 快照契约:
|
||||
// 节点 subtitleEntries 权威 → 重建时间线字幕快照,替换过期快照,单向显式同步)。
|
||||
// AI 高亮在 M6(editor-ai-assistant + ai.text 权限)接入,此处留占位。
|
||||
// AI 字幕高亮与关键词标注已在 M6 落地,入口在画布「字幕」弹窗
|
||||
//(canvas-subtitle-dialog.tsx,走 ai.text 权限门禁):对权威节点字幕条目生成
|
||||
// 高亮/关键词,并随字幕条目渲染。此处(时间线字幕工具)字幕片段只是快照,
|
||||
// 编辑器预览尚无字幕文本渲染层,高亮视觉无处挂载,故不在此重复接 AI;
|
||||
// 待 M4 数据源接入、预览可渲染字幕文本后,再将高亮打通到时间线片段。
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
@@ -117,10 +121,11 @@ export function EditorSubtitleTools() {
|
||||
<div className="rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)] p-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-[var(--director-dock-fg-strong)]">AI 高亮</span>
|
||||
<span className="rounded-full bg-[var(--director-dock-active-surface)] px-1.5 py-0.5 text-[10px] text-[var(--director-dock-fg)]/80">M6</span>
|
||||
<span className="rounded-full bg-[var(--director-dock-active-surface)] px-1.5 py-0.5 text-[10px] text-[var(--director-dock-fg)]/80">可用</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] leading-relaxed text-[var(--director-dock-fg)]/55">
|
||||
AI 字幕高亮与关键词标注将在 M6(ai-assistant 插件 + ai.text 权限)接入。
|
||||
AI 字幕高亮与关键词标注已实现,入口在画布「字幕」弹窗(点击字幕 → AI 高亮,走 ai.text 权限)。
|
||||
时间线字幕片段由该字幕快照重建而来;待编辑器预览支持字幕文本渲染后,高亮会随快照同步到这里。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1097,8 +1097,13 @@ function AssetCard({
|
||||
);
|
||||
}
|
||||
|
||||
function isKnownAssetKind(kind: unknown): kind is AssetKind {
|
||||
return kind === "image" || kind === "video" || kind === "audio" || kind === "model" || kind === "text";
|
||||
}
|
||||
|
||||
function AssetCover({ asset, selected, isTrash = false, onSelect, onOpen, menuItems }: { asset: LibraryAsset; selected: boolean; isTrash?: boolean; onSelect: (selected: boolean) => void; onOpen: () => void; menuItems: MenuProps["items"] }) {
|
||||
const KindIcon = assetKindIcons[asset.kind];
|
||||
const kind = isKnownAssetKind(asset.kind) ? asset.kind : undefined;
|
||||
const KindIcon = kind ? assetKindIcons[kind] : FileText;
|
||||
const clock = asset.kind === "video" || asset.kind === "audio" ? formatAssetClock(asset.data.durationMs) : null;
|
||||
const showPlay = asset.kind === "video";
|
||||
const isLight = asset.kind === "audio" || asset.kind === "text" || asset.kind === "model";
|
||||
@@ -1133,7 +1138,7 @@ function AssetCover({ asset, selected, isTrash = false, onSelect, onOpen, menuIt
|
||||
<span className="assets-cover-badges">
|
||||
<span className="assets-cover-badge is-kind">
|
||||
<KindIcon />
|
||||
{assetKindLabel(asset.kind)}
|
||||
{kind ? assetKindLabel(kind) : "素材"}
|
||||
</span>
|
||||
{isTrash ? <span className="assets-cover-badge is-category !bg-amber-500/85 !text-white">回收站</span> : <span className="assets-cover-badge is-category">{assetCategoryLabel(asset.category)}</span>}
|
||||
</span>
|
||||
@@ -1320,7 +1325,8 @@ function AssetFilterGroup({
|
||||
|
||||
function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAsset | null; onClose: () => void; onCopy: (asset: LibraryAsset) => void; onDownload: (asset: LibraryAsset) => void }) {
|
||||
const facts = asset ? assetArchiveFacts(asset) : [];
|
||||
const KindIcon = asset ? assetKindIcons[asset.kind] : Clapperboard;
|
||||
const kind = asset && isKnownAssetKind(asset.kind) ? asset.kind : undefined;
|
||||
const KindIcon = asset ? (kind ? assetKindIcons[kind] : FileText) : Clapperboard;
|
||||
return (
|
||||
<Drawer className="library-drawer" title="素材档案" open={Boolean(asset)} size="large" onClose={onClose}>
|
||||
{asset ? (
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { ProjectAsset, ProjectDetail } from "@/services/api/projects";
|
||||
import { listProjectAssets } from "@/services/api/projects";
|
||||
import type { ProjectAsset, ProjectDetail, ShotArtifact } from "@/services/api/projects";
|
||||
import { linkProjectAsset, listProjectAssets } from "@/services/api/projects";
|
||||
import { useEditorSlots, type EditorSlotRegistration } from "@/lib/plugins/editor-slot-registry";
|
||||
import { pluginMayRenderEditorSlot } from "@/lib/plugins/plugin-permission-check";
|
||||
import { EditorStoreProvider } from "@/components/editor/editor-context";
|
||||
@@ -269,11 +269,14 @@ export default function ProjectEditorView({ detail }: { detail: ProjectDetail })
|
||||
const [timelineH, setTimelineH] = useState(240);
|
||||
const splitterRef = useRef<{ startY: number; startH: number } | null>(null);
|
||||
|
||||
const refreshAssets = async () => {
|
||||
const refreshAssets = async (): Promise<ProjectAsset[] | null> => {
|
||||
try {
|
||||
setAssets((await listProjectAssets(projectId)).assets);
|
||||
const list = (await listProjectAssets(projectId)).assets;
|
||||
setAssets(list);
|
||||
return list;
|
||||
} catch {
|
||||
// 刷新失败保留现有列表;导入面板内联提示错误
|
||||
// 刷新失败保留现有列表;调用方可据此决定是否重试
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -294,6 +297,19 @@ export default function ProjectEditorView({ detail }: { detail: ProjectDetail })
|
||||
const scope = getActiveUserScope();
|
||||
const projectId = detail.project.id;
|
||||
|
||||
// 挂载/切换项目时以服务端为准同步一次资产:detail.assets 只是详情页快照,
|
||||
// 上个会话导入但详情未携带(或导入后未刷新)的素材会在进入编辑器后补现。
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const list = await refreshAssets();
|
||||
if (!cancelled && list) setAssets(list);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
const store = useState(() =>
|
||||
createEditorStore({
|
||||
saveTimeline: async (project: TimelineProject) => {
|
||||
@@ -318,6 +334,58 @@ export default function ProjectEditorView({ detail }: { detail: ProjectDetail })
|
||||
};
|
||||
}, [scope, projectId, store]);
|
||||
|
||||
// 画布产物自动同步:detail.shotArtifacts 中当前采用(storyboard/action_board/
|
||||
// 分镜/导出成片,且 ready + 有资源)尚未作为素材进本项目时,自动
|
||||
// linkProjectAsset(source=canvas) 并入素材库 —— 剪辑器“素材选项”直接可用画布产物。
|
||||
// 后端幂等(已链接直接返回),失败静默,下次进入编辑器自动补同步;
|
||||
// 已完成集合记录在 ref,避免同一会话重复请求。
|
||||
const syncedCanvasResourcesRef = useRef<Set<string>>(new Set());
|
||||
const canvasSyncKeys = detail.shotArtifacts
|
||||
.filter(
|
||||
(a): a is ShotArtifact & { resourceId: string } =>
|
||||
a.selected &&
|
||||
a.status === "ready" &&
|
||||
!!a.resourceId &&
|
||||
["storyboard", "action_board", "start_frame", "end_frame", "video", "delivery"].includes(a.type),
|
||||
)
|
||||
.map((a) => `resource:${a.resourceId}`)
|
||||
.join(",");
|
||||
useEffect(() => {
|
||||
if (!canvasSyncKeys) return;
|
||||
const keys = canvasSyncKeys.split(",");
|
||||
// 已同步过的(本会话 ref)或已存在于项目素材的(storageKey 命中)均跳过 ——
|
||||
// storageKey 与 assetFromUploadedResource 合成格式一致(resource:<id>)。
|
||||
const existing = new Set(assets.map((a) => a.storageKey));
|
||||
const pending = keys.filter((key) => !syncedCanvasResourcesRef.current.has(key) && !existing.has(key));
|
||||
if (!pending.length) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
for (const key of pending) {
|
||||
if (cancelled) break;
|
||||
const resourceId = key.slice("resource:".length);
|
||||
try {
|
||||
await linkProjectAsset(projectId, { assetId: resourceId, category: "material", source: "canvas" });
|
||||
syncedCanvasResourcesRef.current.add(key);
|
||||
} catch {
|
||||
// 单个同步失败不阻塞其余;留待下次进入编辑器重试
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
// 直接拉取最新列表(setAssets 稳定)而非经 refreshAssets,
|
||||
// 避免闭包依赖变化导致 effect 反复触发
|
||||
try {
|
||||
const list = (await listProjectAssets(projectId)).assets;
|
||||
if (!cancelled) setAssets(list);
|
||||
} catch {
|
||||
// 刷新失败保留现有列表,下次进入编辑器再同步
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canvasSyncKeys, projectId, assets]);
|
||||
|
||||
return (
|
||||
<EditorStoreProvider store={store} host={{ projectId, assets, refreshAssets }}>
|
||||
<div ref={workbenchRef} className="editor-workbench relative flex h-full min-h-0 flex-col bg-[var(--director-workspace-bg)]">
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function ProjectOverviewView({ detail, overview }: ProjectDetailV
|
||||
{ id: "storyboard", icon: Clapperboard, label: "分镜脚本与画面", description: "拆分镜头并确认构图、对白和时长", metric: `${metrics.shotCount} 镜 · ${metrics.readyStoryboardCount} 张图`, href: workflowHref("storyboard"), complete: metrics.shotCount > 0 },
|
||||
{ id: "previz", icon: PlaySquare, label: "动作预演", description: "检查表演节拍、运镜和连续性", metric: `${metrics.readyPrevizCount}/${metrics.shotCount || 0} 镜`, href: workflowHref("previz"), complete: metrics.shotCount > 0 && metrics.readyPrevizCount === metrics.shotCount },
|
||||
{ id: "video", icon: Film, label: "镜头视频", description: "逐镜生成、筛选版本并锁定成片", metric: `${metrics.readyVideoCount}/${metrics.shotCount || 0} 镜`, href: workflowHref("video"), complete: metrics.shotCount > 0 && metrics.readyVideoCount === metrics.shotCount },
|
||||
{ id: "editor", icon: Scissors, label: "剪辑成片", description: "在时间线中编排镜头、添加字幕并输出成片", metric: metrics.shotCount > 0 && metrics.readyVideoCount === metrics.shotCount ? "可以开始剪辑" : `还差 ${Math.max(0, metrics.shotCount - metrics.readyVideoCount)} 镜`, href: `/projects/${project.id}/editor`, complete: false },
|
||||
{ id: "editor", icon: Scissors, label: "剪辑成片", description: "在时间线中编排镜头、添加字幕并输出成片", metric: metrics.renderSucceededCount > 0 ? `已输出 ${metrics.renderSucceededCount} 个成片` : metrics.shotCount > 0 && metrics.readyVideoCount === metrics.shotCount ? "可以开始剪辑" : `还差 ${Math.max(0, metrics.shotCount - metrics.readyVideoCount)} 镜`, href: `/projects/${project.id}/editor`, complete: metrics.renderSucceededCount > 0 },
|
||||
{ id: "delivery", icon: PackageCheck, label: "交付与打包", description: "检查缺失镜头并整理最终产物", metric: metrics.readyVideoCount && metrics.readyVideoCount === metrics.shotCount ? "可以交付" : `还差 ${Math.max(0, metrics.shotCount - metrics.readyVideoCount)} 镜`, href: workflowHref("delivery"), complete: metrics.shotCount > 0 && metrics.readyVideoCount === metrics.shotCount },
|
||||
];
|
||||
const gaps = [
|
||||
|
||||
@@ -278,6 +278,7 @@ export type ProjectOverviewMetrics = {
|
||||
readyStoryboardCount: number;
|
||||
readyPrevizCount: number;
|
||||
readyVideoCount: number;
|
||||
renderSucceededCount: number;
|
||||
staleArtifactCount: number;
|
||||
};
|
||||
|
||||
@@ -479,7 +480,7 @@ export function unlinkCanvasProject(projectId: string, canvasId: string) {
|
||||
return request<{ canvasId: string }>(api.delete(`/projects/${encodeURIComponent(projectId)}/canvases/${encodeURIComponent(canvasId)}`));
|
||||
}
|
||||
|
||||
export function linkProjectAsset(projectId: string, input: { assetId: string; category: AssetCategory; folderId?: string; title?: string }, signal?: AbortSignal) {
|
||||
export function linkProjectAsset(projectId: string, input: { assetId: string; category: AssetCategory; folderId?: string; title?: string; source?: "uploaded" | "canvas" }, signal?: AbortSignal) {
|
||||
return request<{ asset: ProjectAsset }>(api.post(`/projects/${encodeURIComponent(projectId)}/assets`, input, { signal }));
|
||||
}
|
||||
|
||||
|
||||
@@ -532,6 +532,7 @@ export async function waitForGenerationTask(id: string, options?: WaitForGenerat
|
||||
const intervalMs = options?.intervalMs || 2000;
|
||||
let lastTask = options?.initialTask;
|
||||
let lastQueryError: unknown;
|
||||
let consecutiveFailures = 0;
|
||||
try {
|
||||
while (Date.now() - startedAt < (options?.timeoutMs || taskWaitTimeoutMs(lastTask))) {
|
||||
if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
@@ -540,9 +541,16 @@ export async function waitForGenerationTask(id: string, options?: WaitForGenerat
|
||||
task = await queryGenerationTask(id, { signal: options?.signal });
|
||||
lastTask = task;
|
||||
lastQueryError = undefined;
|
||||
consecutiveFailures = 0;
|
||||
options?.onTaskUpdate?.(task);
|
||||
} catch (error) {
|
||||
lastQueryError = error;
|
||||
consecutiveFailures += 1;
|
||||
// 连续失败说明查询通道已不可用,继续轮询只会空转到整体超时;
|
||||
// 保留少量容忍度(约 10 秒)以跳过瞬时抖动后直接报错,便于用户尽早处理。
|
||||
if (consecutiveFailures >= 5) {
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
await delay(intervalMs, options?.signal);
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user