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

@@ -7,19 +7,20 @@ import (
)
type ProjectOverviewMetrics struct {
UnitCount int64 `gorm:"column:unit_count"`
CompletedUnitCount int64 `gorm:"column:completed_unit_count"`
TotalWordCount int64 `gorm:"column:total_word_count"`
UnitsWithoutText int64 `gorm:"column:units_without_text"`
UnitsWithoutShots int64 `gorm:"column:units_without_shots"`
CanvasCount int64 `gorm:"column:canvas_count"`
AssetCount int64 `gorm:"column:asset_count"`
ShotCount int64 `gorm:"column:shot_count"`
PendingCandidateCount int64 `gorm:"column:pending_candidate_count"`
ReadyStoryboardCount int64 `gorm:"column:ready_storyboard_count"`
ReadyPrevizCount int64 `gorm:"column:ready_previz_count"`
ReadyVideoCount int64 `gorm:"column:ready_video_count"`
StaleArtifactCount int64 `gorm:"column:stale_artifact_count"`
UnitCount int64 `gorm:"column:unit_count"`
CompletedUnitCount int64 `gorm:"column:completed_unit_count"`
TotalWordCount int64 `gorm:"column:total_word_count"`
UnitsWithoutText int64 `gorm:"column:units_without_text"`
UnitsWithoutShots int64 `gorm:"column:units_without_shots"`
CanvasCount int64 `gorm:"column:canvas_count"`
AssetCount int64 `gorm:"column:asset_count"`
ShotCount int64 `gorm:"column:shot_count"`
PendingCandidateCount int64 `gorm:"column:pending_candidate_count"`
ReadyStoryboardCount int64 `gorm:"column:ready_storyboard_count"`
ReadyPrevizCount int64 `gorm:"column:ready_previz_count"`
ReadyVideoCount int64 `gorm:"column:ready_video_count"`
TimelineRenderSucceededCount int64 `gorm:"column:timeline_render_succeeded_count"`
StaleArtifactCount int64 `gorm:"column:stale_artifact_count"`
}
type ProjectOverviewUnitRow struct {
@@ -62,8 +63,9 @@ func (r *Repository) ProjectOverviewMetrics(projectID string) (ProjectOverviewMe
(SELECT COUNT(DISTINCT shot_id) FROM shot_artifacts WHERE project_id = ? AND type = 'storyboard' AND selected = ? AND status = 'ready') AS ready_storyboard_count,
(SELECT COUNT(DISTINCT shot_id) FROM shot_artifacts WHERE project_id = ? AND type = 'action_board' AND selected = ? AND status = 'ready') AS ready_previz_count,
(SELECT COUNT(DISTINCT shot_id) FROM shot_artifacts WHERE project_id = ? AND type = 'video' AND selected = ? AND status = 'ready') AS ready_video_count,
(SELECT COUNT(*) FROM tasks WHERE project_id = ? AND type = ? AND status = ?) AS timeline_render_succeeded_count,
(SELECT COUNT(*) FROM shot_artifacts WHERE project_id = ? AND status = 'stale') AS stale_artifact_count
`, projectID, projectID, model.ProjectUnitStatusCompleted, projectID, projectID, projectID, model.ProjectUnitStatusDraft, projectID, projectID, projectID, projectID, projectID, true, projectID, true, projectID, true, projectID).Scan(&metrics).Error
`, projectID, projectID, model.ProjectUnitStatusCompleted, projectID, projectID, projectID, model.ProjectUnitStatusDraft, projectID, projectID, projectID, projectID, projectID, true, projectID, true, projectID, true, projectID, model.TaskTypeTimelineRender, model.TaskStatusSucceeded, projectID).Scan(&metrics).Error
return metrics, err
}

View File

@@ -11,12 +11,20 @@ import (
"infinite-canvas/backend/internal/model"
)
// 资产来源:媒体导入合成/画布产物自动同步。画布产物用 canvas 以便素材面板分组。
const (
AssetSourceUploaded = "uploaded"
AssetSourceCanvas = "canvas"
)
type LinkProjectAssetRequest struct {
AssetID string `json:"assetId"`
Category string `json:"category"`
FolderID *string `json:"folderId"`
// Title 媒体导入场景下由前端携带原始文件名;已有资产时忽略。
Title string `json:"title"`
// Source 仅媒体导入合成时生效:uploaded(默认)或 canvas(画布产物自动同步)。
Source string `json:"source"`
}
type UpdateProjectAssetRequest struct {
@@ -105,6 +113,10 @@ func (s *Service) LinkProjectAsset(userID string, projectID string, req LinkProj
return ProjectAssetSummary{}, err
}
assetID := strings.TrimSpace(req.AssetID)
source := strings.TrimSpace(req.Source)
if source != AssetSourceCanvas {
source = AssetSourceUploaded
}
asset, err := s.repo.AssetForUser(userID, assetID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return ProjectAssetSummary{}, err
@@ -112,7 +124,7 @@ func (s *Service) LinkProjectAsset(userID string, projectID string, req LinkProj
if asset == nil {
// 媒体导入只落 resources 表;首次链接时按资源元数据合成资产记录,
// 避免“资源存在但无资产记录”导致导入永远失败。
asset, err = s.assetFromUploadedResource(userID, assetID, req.Title)
asset, err = s.assetFromUploadedResource(userID, assetID, req.Title, source)
if err != nil {
return ProjectAssetSummary{}, err
}
@@ -173,7 +185,7 @@ func (s *Service) LinkProjectAsset(userID string, projectID string, req LinkProj
// assetFromUploadedResource 把媒体导入上传的 Resource 合成为资产记录(内存构造,不落库)。
// 资产创建与项目链接由 LinkProjectAsset 事务原子提交,避免失败留下孤立资产。
func (s *Service) assetFromUploadedResource(userID string, resourceID string, title string) (*model.Asset, error) {
func (s *Service) assetFromUploadedResource(userID string, resourceID string, title string, source string) (*model.Asset, error) {
resource, err := s.repo.ResourceForUser(userID, resourceID)
if err != nil {
return nil, err
@@ -192,7 +204,7 @@ func (s *Service) assetFromUploadedResource(userID string, resourceID string, ti
"durationMs": resource.DurationMs,
"status": resource.Status,
"mediaType": resource.Kind,
"source": "uploaded",
"source": source,
},
})
if err != nil {

View File

@@ -1,6 +1,7 @@
package service
import (
"encoding/json"
"testing"
"infinite-canvas/backend/internal/model"
@@ -58,6 +59,7 @@ func TestLinkProjectAssetCreatesAssetFromUploadedResource(t *testing.T) {
AssetID: "resource-1",
Category: "",
Title: "开场.mp4",
Source: AssetSourceCanvas,
})
if err != nil {
t.Fatalf("LinkProjectAsset: %v", err)
@@ -76,6 +78,20 @@ func TestLinkProjectAssetCreatesAssetFromUploadedResource(t *testing.T) {
if asset.Category != model.AssetCategoryMaterial {
t.Errorf("asset.Category = %q, want material", asset.Category)
}
if summary.Source != AssetSourceCanvas {
t.Errorf("summary.Source = %q, want canvas", summary.Source)
}
var payload struct {
Data struct {
Source string `json:"source"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(asset.PayloadJSON), &payload); err != nil {
t.Fatalf("payload unmarshal: %v", err)
}
if payload.Data.Source != AssetSourceCanvas {
t.Errorf("payload.source = %q, want canvas", payload.Data.Source)
}
}
// TestLinkProjectAssetMissingResourceKeepsFailingClosed 资源与资产都不存在时仍应报错,

View File

@@ -33,6 +33,7 @@ type ProjectOverviewMetrics struct {
ReadyStoryboardCount int64 `json:"readyStoryboardCount"`
ReadyPrevizCount int64 `json:"readyPrevizCount"`
ReadyVideoCount int64 `json:"readyVideoCount"`
RenderSucceededCount int64 `json:"renderSucceededCount"`
StaleArtifactCount int64 `json:"staleArtifactCount"`
}
@@ -152,7 +153,7 @@ func (s *Service) ProjectOverview(userID string, projectID string) (ProjectOverv
UnitsWithoutText: row.UnitsWithoutText, UnitsWithoutShots: row.UnitsWithoutShots, CanvasCount: row.CanvasCount,
AssetCount: row.AssetCount, ShotCount: row.ShotCount, PendingCandidateCount: row.PendingCandidateCount,
ReadyStoryboardCount: row.ReadyStoryboardCount, ReadyPrevizCount: row.ReadyPrevizCount, ReadyVideoCount: row.ReadyVideoCount,
StaleArtifactCount: row.StaleArtifactCount,
RenderSucceededCount: row.TimelineRenderSucceededCount, StaleArtifactCount: row.StaleArtifactCount,
}
return nil
})

View File

@@ -121,6 +121,33 @@ func TestProjectOverviewUsesAggregatesWithoutLoadingProjectCollections(t *testin
}
}
func TestProjectOverviewCountsSucceededTimelineRenders(t *testing.T) {
service, db := newProjectWorkbenchReadTestService(t)
project := seedWorkbenchProject(t, db)
for index := 0; index < 2; index++ {
task := model.Task{ID: fmt.Sprintf("render-%02d", index), UserID: "user-1", ProjectID: project.ID, Type: model.TaskTypeTimelineRender, Status: model.TaskStatusSucceeded}
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
}
distractors := []model.Task{
{ID: "render-failed", UserID: "user-1", ProjectID: project.ID, Type: model.TaskTypeTimelineRender, Status: model.TaskStatusFailed},
{ID: "transcribe-ok", UserID: "user-1", ProjectID: project.ID, Type: model.TaskTypeTimelineTranscription, Status: model.TaskStatusSucceeded},
}
for _, task := range distractors {
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
}
overview, err := service.ProjectOverview("user-1", project.ID)
if err != nil {
t.Fatal(err)
}
if overview.Metrics.RenderSucceededCount != 2 {
t.Fatalf("render succeeded count = %d, want 2", overview.Metrics.RenderSucceededCount)
}
}
func TestProjectUnitWorkspaceIsolatesShotsAndBoundAssetsByUnit(t *testing.T) {
service, db := newProjectWorkbenchReadTestService(t)
project := seedWorkbenchProject(t, db)

View File

@@ -180,7 +180,10 @@ func (s *Service) maybeStartPlaybackTranscode(resource *model.Resource) {
}
src := filepath.Join(s.dataDir, "resources", filepath.FromSlash(resource.ObjectKey))
switch probeVideoCodec(src) {
case videoCodecH265:
case videoCodecH265, videoCodecMPEG4:
// H.265 与 MPEG-4 Part 2(mp4v)Chromium/Firefox/Edge 均不能解码:
// H.265 需转 H.264;MPEG-4 Part 2 浏览器不支持,老式/损坏文件只能转码兜底
// (转码失败会落 failed,前端据此显示终止性错误,不再出现死循环)。
// 原子抢占(空/none → processing):并发上传 + 回填、多实例同抢时
// 仅一个能成功置位,其余直接返回,避免重复转码。
claimed, err := s.repo.ClaimPlaybackTranscode(resource.ID)
@@ -189,9 +192,10 @@ func (s *Service) maybeStartPlaybackTranscode(resource *model.Resource) {
}
resource.PlaybackStatus = model.PlaybackStatusProcessing
go s.runPlaybackTranscode(resource.UserID, resource.ID, src)
case videoCodecH264, videoCodecAV1, videoCodecVP9, videoCodecMPEG4, "":
// H.264 浏览器可直接解码;AV1/VP9/MPEG4 暂不转码;探针读不出编码(非 mp4 /
// moov 在尾部 / 加密容器)也无法处理 —— 均标 none,避免重复探测与前端无限轮询。
case videoCodecH264, videoCodecAV1, videoCodecVP9, "":
// H.264 浏览器可直接解码;AV1/VP9 现代浏览器可直接解码,均不需转码;
// 探针读不出编码(非 mp4 / moov 在尾部 / 加密容器)也无法处理 —— 均标 none,
// 避免重复探测与前端无限轮询。
markPlaybackNone(s, resource)
}
}