Files
ttstd-infinite-canvas/backend/internal/app/timeline_render_plan_test.go

149 lines
5.6 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 app
import (
"strings"
"testing"
)
func renderClipFixture(id string, kind string, trackID string, startMs int64, durationMs int64, storageKey string) renderClip {
clip := renderClip{ID: id, Kind: kind, TrackID: trackID, StartMs: startMs, DurationMs: durationMs, Volume: 1}
if storageKey != "" {
clip.DirectMedia = &struct {
ID string `json:"id"`
Kind string `json:"kind"`
StorageKey string `json:"storageKey"`
}{ID: "media-" + id, Kind: kind, StorageKey: storageKey}
}
return clip
}
func TestBuildRenderPlanExpandsGapAndSorts(t *testing.T) {
project := renderProject{
Version: 2,
Tracks: []renderTrack{{ID: "track-video-1", Kind: "video"}},
Clips: []renderClip{
renderClipFixture("clip-b", "video", "track-video-1", 3000, 2000, "resource:res-b"),
renderClipFixture("clip-a", "video", "track-video-1", 0, 2000, "resource:res-a"),
renderClipFixture("clip-sub", "subtitle", "track-subtitle-1", 0, 1000, ""),
},
}
plan := buildRenderPlan(project)
if !plan.HasMedia {
t.Fatal("HasMedia = false, want true")
}
// 前 0–3000ms 被 clip-a(0-2000) 覆盖后仍有 1000ms 空隙,应展开为黑场段。
if len(plan.Segments) != 3 {
t.Fatalf("segments = %d, want 3 (clip-a, gap, clip-b)", len(plan.Segments))
}
if plan.Segments[0].Kind != "video" || plan.Segments[0].Clip.ID != "clip-a" {
t.Fatalf("seg0 = %s/%s, want video/clip-a", plan.Segments[0].Kind, plan.Segments[0].Clip.ID)
}
if plan.Segments[1].Kind != "gap" || plan.Segments[1].DurationMs != 1000 {
t.Fatalf("seg1 = %s/%dms, want gap/1000ms", plan.Segments[1].Kind, plan.Segments[1].DurationMs)
}
if plan.Segments[2].Clip.ID != "clip-b" {
t.Fatalf("seg2 = %s, want clip-b (sorted by startMs)", plan.Segments[2].Clip.ID)
}
}
func TestBuildRenderPlanSkipsHiddenTrack(t *testing.T) {
hidden := false
project := renderProject{
Version: 2,
Tracks: []renderTrack{{ID: "track-hidden", Kind: "video", Visible: &hidden}},
Clips: []renderClip{renderClipFixture("clip-hidden", "video", "track-hidden", 0, 1000, "resource:res-x")},
}
plan := buildRenderPlan(project)
if len(plan.Segments) != 0 {
t.Fatalf("segments = %d, want 0 (hidden track skipped)", len(plan.Segments))
}
if plan.HasMedia {
t.Fatal("HasMedia = true, want false for hidden-only timeline")
}
}
func TestBuildRenderSubtitleSRT(t *testing.T) {
project := renderProject{
Version: 2,
Clips: []renderClip{
{ID: "sub-b", Kind: "subtitle", StartMs: 2000, DurationMs: 1000, Text: "第二条"},
{ID: "sub-a", Kind: "subtitle", StartMs: 0, DurationMs: 1000, Text: "第一条"},
{ID: "sub-blank", Kind: "subtitle", StartMs: 5000, DurationMs: 1000, Text: " "},
},
}
srt := buildRenderSubtitleSRT(project)
want := "1\n00:00:00,000 --> 00:00:01,000\n第一条\n\n2\n00:00:02,000 --> 00:00:03,000\n第二条\n\n"
if srt != want {
t.Fatalf("srt mismatch:\n got %q\nwant %q", srt, want)
}
if buildRenderSubtitleSRT(renderProject{Version: 2}) != "" {
t.Fatal("srt for empty timeline: want empty string")
}
}
func TestBuildRenderFFmpegArgsLayout(t *testing.T) {
plan := buildRenderPlan(renderProject{
Version: 2,
Tracks: []renderTrack{{ID: "track-video-1", Kind: "video"}},
Clips: []renderClip{
renderClipFixture("clip-a", "video", "track-video-1", 0, 2000, "resource:res-a"),
renderClipFixture("clip-b", "video", "track-video-1", 5000, 1000, "resource:res-b"),
},
})
plan.Segments[0].Source = &renderSource{ResourceID: "res-a", Path: "src-0.mp4", HasAudio: true}
plan.Segments[1].Source = &renderSource{ResourceID: "res-a", Path: "src-0.mp4", HasAudio: true}
plan.Segments[2].Source = &renderSource{ResourceID: "res-b", Path: "src-1.mp4", HasAudio: true}
args := buildRenderFFmpegArgs(plan, "render-output.mp4")
joined := strings.Join(args, " ")
if len(args) == 0 {
t.Fatal("args empty, want ffmpeg arguments")
}
for _, want := range []string{
"-filter_complex",
"concat=n=3:v=1:a=1",
"[vout]", "[aout]",
"libx264", "aac",
"color=c=black:s=1920x1080:r=30",
"anullsrc=r=44100:cl=stereo",
"render-output.mp4",
} {
if !strings.Contains(joined, want) {
t.Fatalf("args missing %q: %s", want, joined)
}
}
// 每段两个输入:视频 + 音频;末尾输入索引应为 5(3 段 × 2)。
if !strings.Contains(joined, "[5:a]") {
t.Fatalf("args missing final audio input label [5:a]: %s", joined)
}
if buildRenderFFmpegArgs(renderPlan{}, "out.mp4") != nil {
t.Fatal("args for empty plan: want nil")
}
}
func TestBuildRenderFFmpegArgsSilentFallbackUniqueLabels(t *testing.T) {
plan := renderPlan{Segments: []renderSegment{
{Kind: "video", DurationMs: 1000, Source: &renderSource{Path: "src-0.mp4", HasAudio: false}},
{Kind: "video", DurationMs: 1000, Source: &renderSource{Path: "src-1.mp4", HasAudio: false}},
}}
args := buildRenderFFmpegArgs(plan, "out.mp4")
joined := strings.Join(args, " ")
// 两段均无音轨:静音源标签必须按片段唯一,否则 filter_complex 报错。
if !strings.Contains(joined, "[silent0]") || !strings.Contains(joined, "[silent1]") {
t.Fatalf("silent labels not unique: %s", joined)
}
}
func TestMediaResourceID(t *testing.T) {
clip := renderClipFixture("a", "video", "track-1", 0, 1000, "resource:res-42")
if id, ok := mediaResourceID(clip); !ok || id != "res-42" {
t.Fatalf("mediaResourceID = %q/%v, want res-42/true", id, ok)
}
if _, ok := mediaResourceID(renderClip{}); ok {
t.Fatal("mediaResourceID for clip without directMedia: want false")
}
if _, ok := mediaResourceID(renderClipFixture("a", "video", "track-1", 0, 1000, "https://example.com/a.mp4")); ok {
t.Fatal("mediaResourceID for http storage key: want false")
}
}