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:
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user