Files
ttstd-infinite-canvas/backend/internal/service/video_transcode.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

319 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"errors"
"strings"
"infinite-canvas/backend/internal/model"
)
// 播放副本转码:HEVC/H.265 原片在 Chrome/Firefox 等无法解码(<video> 黑屏),
// 上传就绪后若探测到 hvc1/hev1 且本机可用 ffmpeg,则异步转 H.264/AAC 到本地
// playback 目录,供 file 端点 variant=playback 读取。转码只作用于本地原件;
// OSS 原件不转码(避免每次上传拉取远端)。
// ErrPlaybackNotReady 表示资源没有可用的浏览器兼容播放副本(未转码/转码中/失败),
// file 端点应回退 serve 原件。
var ErrPlaybackNotReady = errors.New("播放副本尚未就绪")
const (
playbackDirName = "playback"
videoCodecH264 = "h264"
videoCodecH265 = "h265"
videoCodecAV1 = "av1"
videoCodecVP9 = "vp9"
videoCodecMPEG4 = "mpeg4"
probeMaxMoovSize = 128 << 20
)
// probeVideoCodec 解析本地 mp4 的 stsd 首个视频 sample entry fourcc,返回 h264/h265 等。
// 非 mp4 容器或解析失败返回空串(调用方按“无需转码”处理,前端仍可用原件)。
func probeVideoCodec(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
// 顶层 box 遍历(跳过 mdat 数据体),定位 moov。
var moovSize int64
var moovData []byte
pos := int64(0)
for {
boxType, size, err := readMP4BoxHeaderAt(f, pos)
if err != nil {
return ""
}
switch boxType {
case "moov":
if size > probeMaxMoovSize {
return ""
}
moovData = make([]byte, size-8)
if _, err := f.ReadAt(moovData, pos+8); err != nil {
return ""
}
moovSize = size
}
pos += size
if moovSize != 0 {
break
}
if size < 8 {
return ""
}
}
return codecFromMoov(moovData)
}
// readMP4BoxHeaderAt 定位文件 pos 处的 box(8/16 字节头),返回类型与总大小。
func readMP4BoxHeaderAt(r io.ReaderAt, pos int64) (string, int64, error) {
var hdr [16]byte
if _, err := r.ReadAt(hdr[:8], pos); err != nil {
return "", 0, err
}
size := int64(binary.BigEndian.Uint32(hdr[:4]))
boxType := string(hdr[4:8])
if size == 1 {
if _, err := r.ReadAt(hdr[8:16], pos+8); err != nil {
return "", 0, err
}
size = int64(binary.BigEndian.Uint64(hdr[8:16]))
}
if size < 8 {
return "", 0, fmt.Errorf("invalid box size %d", size)
}
return boxType, size, nil
}
// codecFromMoov 在 moov 子树中找出所有 stsd,取首个视频 sample entry fourcc。
func codecFromMoov(moov []byte) string {
for _, stsdBody := range boxBodies(moov, "stsd") {
// stsd = fullbox(4) + entry_count(4) + entries…
// 首个 sample entry:entry_size(4) + fourcc(4) → fourcc 在 body+12。
if stsdBody+16 > len(moov) {
continue
}
fourcc := string(moov[stsdBody+12 : stsdBody+16])
switch fourcc {
case "avc1":
return videoCodecH264
case "hvc1", "hev1":
return videoCodecH265
case "av01":
return videoCodecAV1
case "vp09":
return videoCodecVP9
case "mp4v":
return videoCodecMPEG4
}
}
return ""
}
// boxBodies 在 data 中递归查找类型为 want 的 box,返回其 body 起始偏移。
// 只下钻容器 box(moov/trak/mdia/minf/stbl),避免误入 sample entry 内部。
func boxBodies(data []byte, want string) []int {
var out []int
var walk func(start, end int)
walk = func(start, end int) {
pos := start
for pos+8 <= end {
size := int(binary.BigEndian.Uint32(data[pos : pos+4]))
boxType := string(data[pos+4 : pos+8])
hdr := 8
if size == 1 {
if pos+16 > end {
return
}
size = int(binary.BigEndian.Uint64(data[pos+8 : pos+16]))
hdr = 16
}
if size < 8 || pos+size > end {
return
}
if boxType == want {
out = append(out, pos+hdr)
}
switch boxType {
case "moov", "trak", "mdia", "minf", "stbl":
walk(pos+hdr, pos+size)
}
pos += size
}
}
walk(0, len(data))
return out
}
// maybeStartPlaybackTranscode 由上传就绪路径调用;H.265 且 ffmpeg 可用时置
// processing 并异步转码。H.264 直接标记 none,避免每次上传重复探测。
func (s *Service) maybeStartPlaybackTranscode(resource *model.Resource) {
if resource == nil || resource.Kind != "video" {
return
}
// 远端存储(OSS 等)不落本地副本:标 none 表示无需转码,前端按原生播放/直链处理,
// 避免状态留空被当成 processing 无限轮询(markPlaybackNone 幂等)。
if resource.Provider != "local" || resource.Status != model.ResourceStatusReady {
if resource.Provider != "local" {
markPlaybackNone(s, resource)
}
return
}
if resource.PlaybackStatus != "" && resource.PlaybackStatus != model.PlaybackStatusNone {
return
}
// 不可转码场景(无 ffmpeg)落 none:状态为空会被前端当成 processing 无限轮询,
// 也会让启动回填每次重试探测。none 表示"无需转码",按原生播放处理。
if _, err := exec.LookPath("ffmpeg"); err != nil {
markPlaybackNone(s, resource)
return
}
src := filepath.Join(s.dataDir, "resources", filepath.FromSlash(resource.ObjectKey))
switch probeVideoCodec(src) {
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)
if err != nil || !claimed {
return
}
resource.PlaybackStatus = model.PlaybackStatusProcessing
go s.runPlaybackTranscode(resource.UserID, resource.ID, src)
case videoCodecH264, videoCodecAV1, videoCodecVP9, "":
// H.264 浏览器可直接解码;AV1/VP9 现代浏览器可直接解码,均不需转码;
// 探针读不出编码(非 mp4 / moov 在尾部 / 加密容器)也无法处理 —— 均标 none,
// 避免重复探测与前端无限轮询。
markPlaybackNone(s, resource)
}
}
// markPlaybackNone 将资源标记为无需播放副本(幂等,写失败不影响上传主流程)。
func markPlaybackNone(s *Service, resource *model.Resource) {
if resource.PlaybackStatus == model.PlaybackStatusNone {
return
}
resource.PlaybackStatus = model.PlaybackStatusNone
_ = s.repo.SaveResource(resource)
}
// runPlaybackTranscode 转码本地原件到 playback/<id>.mp4 并回写状态(幂等按 id 重载)。
func (s *Service) runPlaybackTranscode(userID string, resourceID string, src string) {
// 转码 goroutine 意外 panic 时把状态落 failed,避免 processing 卡死到下次重启。
defer func() {
if r := recover(); r != nil {
if res, err := s.repo.ResourceForUser(userID, resourceID); err == nil && res != nil {
res.PlaybackStatus = model.PlaybackStatusFailed
res.PlaybackError = clipText(fmt.Sprintf("转码 panic:%v", r), 1000)
_ = s.repo.SaveResource(res)
}
}
}()
status := model.PlaybackStatusFailed
objectKey := ""
var errText string
dst := filepath.Join(s.dataDir, playbackDirName, resourceID+".mp4")
// 副本已存在且为 H.264(上次转码完成但回写随进程崩溃丢失):直接置 ready 不重转。
if probeVideoCodec(dst) == videoCodecH264 {
status = model.PlaybackStatusReady
objectKey = resourceID + ".mp4"
} else if err := runH264Transcode(src, dst); err != nil {
errText = clipText(err.Error(), 1000)
} else {
status = model.PlaybackStatusReady
objectKey = resourceID + ".mp4"
}
res, err := s.repo.ResourceForUser(userID, resourceID)
if err != nil || res == nil {
return
}
res.PlaybackStatus = status
res.PlaybackObjectKey = objectKey
res.PlaybackError = errText
_ = s.repo.SaveResource(res)
}
// runH264Transcode 用 ffmpeg 将任意输入转为 H.264/AAC mp4(faststart、yuv420p、偶数尺寸)。
func runH264Transcode(src string, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
return err
}
cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", src,
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
"-pix_fmt", "yuv420p", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart",
dst)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("ffmpeg 转码失败:%s", clipText(msg, 800))
}
return nil
}
func clipText(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max]
}
// OpenResourcePlaybackRange 打开浏览器兼容播放副本(本地 ffmpeg 转码的 H.264)。
// 仅当资源为本地存储且副本 ready 时可用;否则返回 ErrPlaybackNotReady,调用方回退原件。
func (s *Service) OpenResourcePlaybackRange(userID string, resourceID string) (*ResourceStream, error) {
resource, err := s.repo.ResourceForUser(userID, resourceID)
if err != nil {
return nil, err
}
if resource == nil || resource.Status != model.ResourceStatusReady || resource.Provider != "local" ||
resource.PlaybackStatus != model.PlaybackStatusReady || resource.PlaybackObjectKey == "" {
return nil, ErrPlaybackNotReady
}
body, err := os.Open(filepath.Join(s.dataDir, playbackDirName, filepath.FromSlash(resource.PlaybackObjectKey)))
if err != nil {
return nil, err
}
playback := *resource
playback.MimeType = "video/mp4"
playback.ObjectKey = filepath.Join(playbackDirName, resource.PlaybackObjectKey)
size := int64(0)
if st, err := body.Stat(); err == nil {
size = st.Size()
}
return &ResourceStream{Resource: &playback, Body: body, StatusCode: http.StatusOK, ContentLength: size, AcceptRanges: "bytes"}, nil
}
// BackfillPlaybackTranscodes 在服务启动后扫描存量本地视频:未判定 codec 的补判定,
// H.265 触发转码、H.264 标记 none。幂等:maybeStartPlaybackTranscode 先置
// processing/none 再入库,重复扫描不会重复转码。
func (s *Service) BackfillPlaybackTranscodes() {
// 上次进程可能崩溃在转码中途(状态卡 processing),先重置为待判定。
_ = s.repo.ResetStuckPlaybackTranscodes()
for {
resources, err := s.repo.PlaybackPendingVideos(20)
if err != nil || len(resources) == 0 {
return
}
for i := range resources {
s.maybeStartPlaybackTranscode(&resources[i])
}
}
}