Files
ttstd-infinite-canvas/backend/internal/service/project_workbench_read.go
爱笑的毛毛虫 1953052313 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 播放死循环修复与画布产物素材同步的待浏览器验收项
2026-09-05 02:52:21 +08:00

423 lines
15 KiB
Go

package service
import (
"infinite-canvas/backend/internal/model"
"golang.org/x/sync/errgroup"
)
type ProjectCore struct {
Project model.Project `json:"project"`
}
type ProjectUnitSummaries struct {
Units []model.ProjectUnit `json:"units"`
CanvasCounts map[string]int64 `json:"canvasCounts"`
}
type ProjectOverview struct {
Metrics ProjectOverviewMetrics `json:"metrics"`
Units []ProjectOverviewUnit `json:"units"`
}
type ProjectOverviewMetrics struct {
UnitCount int64 `json:"unitCount"`
CompletedUnitCount int64 `json:"completedUnitCount"`
TotalWordCount int64 `json:"totalWordCount"`
UnitsWithoutText int64 `json:"unitsWithoutText"`
UnitsWithoutShots int64 `json:"unitsWithoutShots"`
CanvasCount int64 `json:"canvasCount"`
AssetCount int64 `json:"assetCount"`
ShotCount int64 `json:"shotCount"`
PendingCandidateCount int64 `json:"pendingCandidateCount"`
ReadyStoryboardCount int64 `json:"readyStoryboardCount"`
ReadyPrevizCount int64 `json:"readyPrevizCount"`
ReadyVideoCount int64 `json:"readyVideoCount"`
RenderSucceededCount int64 `json:"renderSucceededCount"`
StaleArtifactCount int64 `json:"staleArtifactCount"`
}
type ProjectOverviewUnit struct {
Unit model.ProjectUnit `json:"unit"`
ShotCount int64 `json:"shotCount"`
CandidateCount int64 `json:"candidateCount"`
CanvasCount int64 `json:"canvasCount"`
}
type ProjectUnitWorkspace struct {
Unit model.ProjectUnit `json:"unit"`
Workflows []ProjectWorkflowDetail `json:"workflows"`
Shots []model.Shot `json:"shots"`
ShotRevisions []model.ShotRevision `json:"shotRevisions"`
ShotArtifacts []model.ShotArtifact `json:"shotArtifacts"`
ShotReferences []ProjectShotAssetReference `json:"shotReferences"`
AssetCandidates []model.ProjectAssetCandidate `json:"assetCandidates"`
Assets []ProjectAssetSummary `json:"assets"`
Tasks []TaskSummary `json:"tasks"`
}
// ProjectShotAssetReference keeps the immutable visual version selected by the
// shot while also exposing the asset's current card configuration (notably its
// voice binding) for generation-time prompt assembly.
type ProjectShotAssetReference struct {
model.ShotAssetReference
Asset ProjectAssetSummary `json:"asset"`
ReferencedVersion ProjectShotAssetReferenceVersion `json:"referencedVersion"`
}
type ProjectShotAssetReferenceVersion struct {
ID string `json:"id"`
AssetID string `json:"assetId"`
Version int `json:"version"`
Representations []CharacterRepresentationSummary `json:"representations"`
}
type ProjectCanvasPage struct {
Canvases []model.CanvasProject `json:"canvases"`
CanvasUnitLinks []model.CanvasUnitLink `json:"canvasUnitLinks"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int64 `json:"total"`
HasMore bool `json:"hasMore"`
}
type ProjectAssetCandidatePage struct {
Candidates []model.ProjectAssetCandidate `json:"candidates"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int64 `json:"total"`
HasMore bool `json:"hasMore"`
}
type ProjectAssetPage struct {
Assets []ProjectAssetSummary `json:"assets"`
CategoryCounts map[string]int64 `json:"categoryCounts"`
FolderCounts map[string]int64 `json:"folderCounts"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int64 `json:"total"`
HasMore bool `json:"hasMore"`
}
func (s *Service) ProjectCore(userID string, projectID string) (ProjectCore, error) {
project, err := s.repo.ProjectForUser(userID, projectID)
if err != nil {
return ProjectCore{}, err
}
if s.reconcileCharacterTurnaroundTasks(userID, project.ID) {
project, err = s.repo.ProjectForUser(userID, projectID)
if err != nil {
return ProjectCore{}, err
}
}
return ProjectCore{Project: *project}, nil
}
func (s *Service) ProjectUnitSummaries(userID string, projectID string) (ProjectUnitSummaries, error) {
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
return ProjectUnitSummaries{}, err
}
var units []model.ProjectUnit
var canvasCounts map[string]int64
var group errgroup.Group
group.Go(func() error {
var err error
units, err = s.repo.ProjectUnitSummaries(projectID)
return err
})
group.Go(func() error {
var err error
canvasCounts, err = s.repo.ProjectUnitCanvasCounts(projectID)
return err
})
if err := group.Wait(); err != nil {
return ProjectUnitSummaries{}, err
}
return ProjectUnitSummaries{Units: units, CanvasCounts: canvasCounts}, nil
}
func (s *Service) ProjectOverview(userID string, projectID string) (ProjectOverview, error) {
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
return ProjectOverview{}, err
}
var metrics ProjectOverviewMetrics
var units []ProjectOverviewUnit
var group errgroup.Group
group.Go(func() error {
row, err := s.repo.ProjectOverviewMetrics(projectID)
if err != nil {
return err
}
metrics = ProjectOverviewMetrics{
UnitCount: row.UnitCount, CompletedUnitCount: row.CompletedUnitCount, TotalWordCount: row.TotalWordCount,
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,
RenderSucceededCount: row.TimelineRenderSucceededCount, StaleArtifactCount: row.StaleArtifactCount,
}
return nil
})
group.Go(func() error {
rows, err := s.repo.ProjectOverviewUnits(projectID, 8)
if err != nil {
return err
}
units = make([]ProjectOverviewUnit, 0, len(rows))
for _, row := range rows {
units = append(units, ProjectOverviewUnit{Unit: row.ProjectUnit, ShotCount: row.ShotCount, CandidateCount: row.CandidateCount, CanvasCount: row.CanvasCount})
}
return nil
})
if err := group.Wait(); err != nil {
return ProjectOverview{}, err
}
return ProjectOverview{Metrics: metrics, Units: units}, nil
}
func (s *Service) ProjectUnitWorkspace(userID string, projectID string, unitID string) (ProjectUnitWorkspace, error) {
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
return ProjectUnitWorkspace{}, err
}
unit, err := s.repo.ProjectUnit(projectID, unitID)
if err != nil {
return ProjectUnitWorkspace{}, err
}
result := ProjectUnitWorkspace{Unit: *unit}
var projectTasks []TaskSummary
var shotReferences []model.ShotAssetReference
var group errgroup.Group
group.Go(func() error {
var err error
result.Shots, err = s.repo.ProjectUnitShots(projectID, unitID)
return err
})
group.Go(func() error {
var err error
result.ShotRevisions, err = s.repo.ProjectUnitShotRevisions(projectID, unitID)
return err
})
group.Go(func() error {
var err error
result.ShotArtifacts, err = s.repo.ProjectUnitShotArtifacts(projectID, unitID)
return err
})
group.Go(func() error {
var err error
shotReferences, err = s.repo.ProjectUnitShotAssetReferences(projectID, unitID)
return err
})
group.Go(func() error {
var err error
result.AssetCandidates, err = s.repo.ProjectUnitAssetCandidates(projectID, unitID)
return err
})
group.Go(func() error {
assets, err := s.repo.ProjectUnitAssets(userID, projectID, unitID)
if err != nil {
return err
}
result.Assets = make([]ProjectAssetSummary, len(assets))
var summaries errgroup.Group
summaries.SetLimit(8)
for index := range assets {
index := index
summaries.Go(func() error {
summary, summaryErr := s.projectAssetSummary(userID, projectID, &assets[index])
if summaryErr == nil {
result.Assets[index] = summary
}
return summaryErr
})
}
return summaries.Wait()
})
group.Go(func() error {
instances, err := s.repo.ProjectWorkflowInstancesForUnit(projectID, unitID)
if err != nil {
return err
}
result.Workflows = make([]ProjectWorkflowDetail, 0, len(instances))
for _, instance := range instances {
steps, stepsErr := s.repo.WorkflowSteps(instance.ID)
if stepsErr != nil {
return stepsErr
}
result.Workflows = append(result.Workflows, ProjectWorkflowDetail{Instance: instance, Steps: steps})
}
return nil
})
group.Go(func() error {
recent, err := s.TasksWithOptions(userID, TaskListOptions{Limit: 100, ProjectID: projectID})
if err != nil {
return err
}
active, err := s.TasksWithOptions(userID, TaskListOptions{Limit: 100, ProjectID: projectID, ActiveOnly: true})
if err != nil {
return err
}
seen := make(map[string]struct{}, len(recent)+len(active))
projectTasks = make([]TaskSummary, 0, len(recent)+len(active))
for _, task := range append(active, recent...) {
if _, exists := seen[task.ID]; exists {
continue
}
seen[task.ID] = struct{}{}
projectTasks = append(projectTasks, task)
}
return nil
})
if err := group.Wait(); err != nil {
return ProjectUnitWorkspace{}, err
}
assetByID := make(map[string]ProjectAssetSummary, len(result.Assets))
for _, asset := range result.Assets {
assetByID[asset.ID] = asset
}
versionIDs := make([]string, 0, len(shotReferences))
for _, reference := range shotReferences {
versionIDs = append(versionIDs, reference.AssetVersionID)
}
versions, err := s.repo.ProjectAssetVersionsByIDs(projectID, versionIDs)
if err != nil {
return ProjectUnitWorkspace{}, err
}
storedRepresentations, err := s.repo.AssetRepresentationsByVersionIDs(versionIDs)
if err != nil {
return ProjectUnitWorkspace{}, err
}
versionByID := make(map[string]model.AssetVersion, len(versions))
for _, version := range versions {
versionByID[version.ID] = version
}
representationsByVersionID := make(map[string][]CharacterRepresentationSummary, len(versionIDs))
for _, representation := range storedRepresentations {
representationsByVersionID[representation.AssetVersionID] = append(representationsByVersionID[representation.AssetVersionID], CharacterRepresentationSummary{
ID: representation.ID, ResourceID: representation.ResourceID, MediaType: representation.MediaType, Role: representation.Role,
})
}
result.ShotReferences = make([]ProjectShotAssetReference, 0, len(shotReferences))
for _, reference := range shotReferences {
version, exists := versionByID[reference.AssetVersionID]
if !exists {
return ProjectUnitWorkspace{}, BadAuthRequest("镜头引用的资产版本不可用")
}
asset, exists := assetByID[version.AssetID]
if !exists {
return ProjectUnitWorkspace{}, BadAuthRequest("镜头引用的项目资产不可用")
}
representations := representationsByVersionID[version.ID]
if representations == nil {
representations = []CharacterRepresentationSummary{}
}
result.ShotReferences = append(result.ShotReferences, ProjectShotAssetReference{
ShotAssetReference: reference,
Asset: asset,
ReferencedVersion: ProjectShotAssetReferenceVersion{
ID: version.ID, AssetID: version.AssetID, Version: version.Version, Representations: representations,
},
})
}
shotIDs := make(map[string]struct{}, len(result.Shots))
for _, shot := range result.Shots {
shotIDs[shot.ID] = struct{}{}
}
result.Tasks = make([]TaskSummary, 0, len(projectTasks))
for _, task := range projectTasks {
context := task.ClientContext
if context == nil {
continue
}
_, belongsToShot := shotIDs[context.ShotID]
if context.ChapterID == unitID || belongsToShot {
result.Tasks = append(result.Tasks, task)
}
}
return result, nil
}
func (s *Service) ProjectCanvasesPage(userID string, projectID string, page int, pageSize int) (ProjectCanvasPage, error) {
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
return ProjectCanvasPage{}, err
}
page, pageSize = normalizeProjectPage(page, pageSize, 100)
canvases, total, err := s.repo.ProjectCanvasSummariesPage(userID, projectID, page, pageSize)
if err != nil {
return ProjectCanvasPage{}, err
}
ids := make([]string, 0, len(canvases))
for _, canvas := range canvases {
ids = append(ids, canvas.ID)
}
links, err := s.repo.ProjectCanvasUnitLinksForCanvases(projectID, ids)
if err != nil {
return ProjectCanvasPage{}, err
}
return ProjectCanvasPage{Canvases: canvases, CanvasUnitLinks: links, Page: page, PageSize: pageSize, Total: total, HasMore: int64(page*pageSize) < total}, nil
}
func (s *Service) ProjectAssetCandidatesPage(userID string, projectID string, page int, pageSize int, unitID string, status string, category string, query string) (ProjectAssetCandidatePage, error) {
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
return ProjectAssetCandidatePage{}, err
}
page, pageSize = normalizeProjectPage(page, pageSize, 200)
candidates, total, err := s.repo.ProjectAssetCandidatesPage(projectID, page, pageSize, unitID, status, category, query)
if err != nil {
return ProjectAssetCandidatePage{}, err
}
return ProjectAssetCandidatePage{Candidates: candidates, Page: page, PageSize: pageSize, Total: total, HasMore: int64(page*pageSize) < total}, nil
}
func (s *Service) ProjectAssetsPage(userID string, projectID string, page int, pageSize int, category string, mediaType string, status string, folderID *string, query string) (ProjectAssetPage, error) {
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
return ProjectAssetPage{}, err
}
page, pageSize = normalizeProjectPage(page, pageSize, 80)
assets, total, err := s.repo.ProjectAssetsPage(userID, projectID, page, pageSize, category, mediaType, status, folderID, query)
if err != nil {
return ProjectAssetPage{}, err
}
summaries := make([]ProjectAssetSummary, len(assets))
var group errgroup.Group
group.SetLimit(8)
for index := range assets {
index := index
group.Go(func() error {
summary, summaryErr := s.projectAssetSummary(userID, projectID, &assets[index])
if summaryErr != nil {
return summaryErr
}
summaries[index] = summary
return nil
})
}
if err := group.Wait(); err != nil {
return ProjectAssetPage{}, err
}
categoryRows, folderRows, err := s.repo.ProjectAssetFacets(projectID)
if err != nil {
return ProjectAssetPage{}, err
}
categoryCounts := make(map[string]int64, len(categoryRows))
for _, row := range categoryRows {
categoryCounts[row.Key] = row.Count
}
folderCounts := make(map[string]int64, len(folderRows))
for _, row := range folderRows {
folderCounts[row.Key] = row.Count
}
return ProjectAssetPage{Assets: summaries, CategoryCounts: categoryCounts, FolderCounts: folderCounts, Page: page, PageSize: pageSize, Total: total, HasMore: int64(page*pageSize) < total}, nil
}
func normalizeProjectPage(page int, pageSize int, maximum int) (int, int) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 40
}
if pageSize > maximum {
pageSize = maximum
}
return page, pageSize
}