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:
爱笑的毛毛虫
2026-09-05 02:52:21 +08:00
committed by GitHub
parent d9e0ea4be7
commit b20ea83201
20 changed files with 451 additions and 74 deletions

View File

@@ -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 }));
}

View File

@@ -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;
}