149 lines
5.6 KiB
Go
149 lines
5.6 KiB
Go
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")
|
||
}
|
||
}
|