feat(editor): 素材面板改造与视频 codec 存量重判 (#419)
* refactor(editor): 画布素材统一到项目素材 - 移除「画布素材」分组、筛选页签、来源徽标与详情「来源」行 - 画布产物(source=canvas)并入「项目素材」组,与普通项目素材一致展示 - 数据层仍写入 canvas 来源标记,仅供追溯,不再驱动任何 UI 分组 * fix(editor): 启动回填重判存量 none 视频的 codec - Repository.PlaybackNoneVideos: 取旧规则遗留(provider=local, playback_status='none')的本地视频 - BackfillPlaybackTranscodes 追加有界重判(每次启动至多 20 条最旧行): H.264 保持 none, H.265/MPEG-4 Part 2 自动触发转码, 使 codec 判定规则变更覆盖规则变更前已导入的文件 - 新增守护测试 TestBackfillRejudgesLegacyNoneVideos: 存量 none 的 H.264 保持、mp4v 转码落 failed * feat(editor): 素材展开改为浮层详情卡,列表不再被挤压 - 移除行内展开块:此前点击素材行会在分组内内联撑开预览+详情+按钮,把列表下方素材挤走 - 新增 AssetDetailSheet 浮层:覆盖在列表视口上方,列表 DOM 原位不动、滚动位置不丢 - 浮层自带头部(类型图标+标题+关闭)、滚动内容区(预览/元数据/描述)与固定底部「添加到时间线」 - 键盘可达:Esc 或关闭按钮退出;聚焦管理 + aria-label + 尊重 prefers-reduced-motion
This commit is contained in:
1 parent
923471f829
commit
7ab9f05c41
5 files changed
+220
-58
No files matched your search
@@ -1073,6 +1073,20 @@ func (r *Repository) PlaybackPendingVideos(limit int) ([]model.Resource, error)
|
||||
return resources, err
|
||||
}
|
||||
|
||||
// PlaybackNoneVideos 返回存量本地视频中旧逻辑遗留、停在 none 的行
|
||||
// (规则变更前 H.265/MPEG-4 Part 2 曾被误判为浏览器可播并落 none)。
|
||||
// 服务启动回填时对它们重新按 codec 判定,让判定规则变更覆盖规则变更前已导入的文件。
|
||||
func (r *Repository) PlaybackNoneVideos(limit int) ([]model.Resource, error) {
|
||||
var resources []model.Resource
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
err := r.db.Where("kind = ? AND status = ? AND provider = ? AND playback_status = ?",
|
||||
"video", model.ResourceStatusReady, "local", model.PlaybackStatusNone).
|
||||
Order("created_at asc").Limit(limit).Find(&resources).Error
|
||||
return resources, err
|
||||
}
|
||||
|
||||
// ClaimPlaybackTranscode 原子地把待判定(空/none)视频置为 processing,返回是否抢占成功。
|
||||
// 多实例或多 goroutine 并发转同一资源时仅一个能成功置位,其余返回 false 直接放弃,
|
||||
// 避免重复转码同一份文件。
|
||||
|
||||
@@ -301,7 +301,8 @@ func (s *Service) OpenResourcePlaybackRange(userID string, resourceID string) (*
|
||||
}
|
||||
|
||||
// BackfillPlaybackTranscodes 在服务启动后扫描存量本地视频:未判定 codec 的补判定,
|
||||
// H.265 触发转码、H.264 标记 none。幂等:maybeStartPlaybackTranscode 先置
|
||||
// H.265/MPEG-4 Part 2 触发转码、H.264 标记 none;再对旧规则遗留的 none 行做一次
|
||||
// 有界重判(见 PlaybackNoneVideos)。幂等:maybeStartPlaybackTranscode 先置
|
||||
// processing/none 再入库,重复扫描不会重复转码。
|
||||
func (s *Service) BackfillPlaybackTranscodes() {
|
||||
// 上次进程可能崩溃在转码中途(状态卡 processing),先重置为待判定。
|
||||
@@ -309,10 +310,20 @@ func (s *Service) BackfillPlaybackTranscodes() {
|
||||
for {
|
||||
resources, err := s.repo.PlaybackPendingVideos(20)
|
||||
if err != nil || len(resources) == 0 {
|
||||
return
|
||||
break
|
||||
}
|
||||
for i := range resources {
|
||||
s.maybeStartPlaybackTranscode(&resources[i])
|
||||
}
|
||||
}
|
||||
// 旧版本曾把 H.265/MPEG-4 Part 2 误判为浏览器可播并落 none;对存量 none 行
|
||||
// 做一次有界重判(H.264 保持 none,H.265/MPEG-4 Part 2 触发转码),使 codec
|
||||
// 判定规则的变更覆盖规则变更前已导入的文件。每次启动最多重判 20 条最旧行,
|
||||
// 天然收敛且不会重复转码(claim 原子地把 none → processing)。
|
||||
legacy, err := s.repo.PlaybackNoneVideos(20)
|
||||
if err == nil {
|
||||
for i := range legacy {
|
||||
s.maybeStartPlaybackTranscode(&legacy[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,12 @@ package service
|
||||
import (
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
// stsdBox 构造仅含 first-sample-entry fourcc 的最小 stsd box(置于 moov 切片内)。
|
||||
@@ -63,3 +67,77 @@ func TestProbeVideoCodecReadsRealFile(t *testing.T) {
|
||||
t.Errorf("probeVideoCodec = %q, want h265", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackfillRejudgesLegacyNoneVideos 覆盖修复:判定规则变更前(H.265/MPEG-4 Part 2
|
||||
// 曾被误判可播)落 none 的存量本地视频,启动回填必须重新按 codec 判定——
|
||||
// H.264 保持 none(幂等),MPEG-4 触发转码并最终落到 failed/ready 终态。
|
||||
func TestBackfillRejudgesLegacyNoneVideos(t *testing.T) {
|
||||
service, db := newProjectAssetLinkTestService(t)
|
||||
dataDir := t.TempDir()
|
||||
service.dataDir = dataDir
|
||||
|
||||
seedLegacy := func(id, fourcc string) {
|
||||
t.Helper()
|
||||
stsd := stsdBoxWithFourcc(fourcc)
|
||||
moov := make([]byte, 8+len(stsd))
|
||||
binary.BigEndian.PutUint32(moov[0:4], uint32(len(moov)))
|
||||
copy(moov[4:8], "moov")
|
||||
copy(moov[8:], stsd)
|
||||
ftyp := make([]byte, 16)
|
||||
binary.BigEndian.PutUint32(ftyp[0:4], 16)
|
||||
copy(ftyp[4:8], "ftyp")
|
||||
copy(ftyp[8:12], "isom")
|
||||
full := append(append([]byte{}, ftyp...), moov...)
|
||||
rel := filepath.Join("clips", id+".mp4")
|
||||
p := filepath.Join(dataDir, "resources", filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, full, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := model.Resource{
|
||||
ID: id, UserID: "user-1", Kind: "video", Status: model.ResourceStatusReady,
|
||||
Provider: "local", ObjectKey: rel, PlaybackStatus: model.PlaybackStatusNone,
|
||||
}
|
||||
if err := db.Create(&res).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
seedLegacy("legacy-h264", "avc1")
|
||||
seedLegacy("legacy-mpeg4", "mp4v")
|
||||
|
||||
service.BackfillPlaybackTranscodes()
|
||||
|
||||
var h264 model.Resource
|
||||
if err := db.First(&h264, "id = ?", "legacy-h264").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h264.PlaybackStatus != model.PlaybackStatusNone {
|
||||
t.Fatalf("H.264 存量 none 行被错误改判为 %q", h264.PlaybackStatus)
|
||||
}
|
||||
|
||||
// MPEG-4 行应被抢占转码。fake mp4 不含真实视频流,ffmpeg 解码必败 → failed;
|
||||
// 若某环境恰有同名 ready 副本则也接受。轮询直到终态,避免 goroutine 竞态。
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Log("无 ffmpeg,跳过 MPEG-4 终态断言")
|
||||
return
|
||||
}
|
||||
var mp4v model.Resource
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
if err := db.First(&mp4v, "id = ?", "legacy-mpeg4").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mp4v.PlaybackStatus == model.PlaybackStatusFailed || mp4v.PlaybackStatus == model.PlaybackStatusReady {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("MPEG-4 存量行未达终态,停在 %q(error=%q)", mp4v.PlaybackStatus, mp4v.PlaybackError)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if mp4v.PlaybackStatus != model.PlaybackStatusFailed {
|
||||
t.Fatalf("fake mp4v 应转码失败落 failed,实际 %q", mp4v.PlaybackStatus)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user