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

277 lines
8.9 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 (
"fmt"
"sort"
"strings"
)
// 时间线渲染:把前端 TimelineProject 快照(tracks/clips 平铺,见
// web/src/types/timeline.ts TimelineProject v2)展开为可执行的 ffmpeg 步骤。
// 与前端 timeline-to-ffmpeg.ts 保持同一数据流:trim(按 sourceStartMs 裁切)
// → 空隙补黑场静音 → concat。字幕不烧录(依赖 libass),单独产出 SRT。
//
// 输入布局统一为「每个片段两个输入:视频源 + 音频源」,空隙展开为独立
// 黑场片段,因此第 i 个片段的视频输入为 2i、音频输入为 2i+1。
type renderClip struct {
ID string `json:"id"`
Kind string `json:"kind"`
TrackID string `json:"trackId"`
StartMs int64 `json:"startMs"`
DurationMs int64 `json:"durationMs"`
SourceStartMs int64 `json:"sourceStartMs"`
SourceDurationMs int64 `json:"sourceDurationMs"`
Volume float64 `json:"volume"`
Text string `json:"text"`
DirectMedia *struct {
ID string `json:"id"`
Kind string `json:"kind"`
StorageKey string `json:"storageKey"`
} `json:"directMedia"`
}
type renderTrack struct {
ID string `json:"id"`
Kind string `json:"kind"`
Visible *bool `json:"visible"`
Muted bool `json:"muted"`
}
type renderProject struct {
Version int `json:"version"`
Tracks []renderTrack `json:"tracks"`
Clips []renderClip `json:"clips"`
DurationMs int64 `json:"durationMs"`
}
type renderSource struct {
ResourceID string
Clip renderClip
Path string
Ext string
// HasAudio 由 ffprobe 探测得到;无音轨的媒体改为静音源,避免 map 失败。
HasAudio bool
}
// renderSegment 是渲染序列中的一段;Kind 为 video/image 表示媒体片段,
// gap 表示补黑场静音的空隙段(无 Source)。
type renderSegment struct {
Kind string
DurationMs int64
GapMs int64
Clip renderClip
Source *renderSource
}
type renderPlan struct {
Segments []renderSegment
SubtitleSRT string
HasMedia bool
}
// buildRenderPlan 挑选可见视频/图片轨片段按 startMs 排序,并把片段之间的
// 空隙展开为黑场段,使渲染序列在时间轴上连续。
func buildRenderPlan(project renderProject) renderPlan {
visible := map[string]bool{}
for _, track := range project.Tracks {
visible[track.ID] = track.Visible == nil || *track.Visible
}
clips := make([]renderClip, 0, len(project.Clips))
for _, clip := range project.Clips {
if !visible[clip.TrackID] {
continue
}
if clip.Kind != "video" && clip.Kind != "image" {
continue
}
if clip.DurationMs <= 0 {
continue
}
clips = append(clips, clip)
}
sort.SliceStable(clips, func(i, j int) bool {
if clips[i].StartMs == clips[j].StartMs {
return clips[i].ID < clips[j].ID
}
return clips[i].StartMs < clips[j].StartMs
})
plan := renderPlan{SubtitleSRT: buildRenderSubtitleSRT(project)}
cursor := int64(0)
for _, clip := range clips {
if gap := clip.StartMs - cursor; gap > 0 {
plan.Segments = append(plan.Segments, renderSegment{Kind: "gap", DurationMs: gap, GapMs: gap})
}
plan.Segments = append(plan.Segments, renderSegment{Kind: clip.Kind, DurationMs: clip.DurationMs, Clip: clip})
cursor = clip.StartMs + clip.DurationMs
}
for _, seg := range plan.Segments {
if _, ok := mediaResourceID(seg.Clip); ok {
plan.HasMedia = true
break
}
}
return plan
}
// mediaResourceID 从片段的 directMedia.storageKey(resource:<id>)还原资源 ID。
func mediaResourceID(clip renderClip) (string, bool) {
if clip.DirectMedia == nil {
return "", false
}
key := strings.TrimSpace(clip.DirectMedia.StorageKey)
if !strings.HasPrefix(key, "resource:") {
return "", false
}
id := strings.TrimSpace(strings.TrimPrefix(key, "resource:"))
return id, id != ""
}
func buildRenderSubtitleSRT(project renderProject) string {
subtitle := make([]renderClip, 0, len(project.Clips))
for _, clip := range project.Clips {
if clip.Kind != "subtitle" || strings.TrimSpace(clip.Text) == "" || clip.DurationMs <= 0 {
continue
}
subtitle = append(subtitle, clip)
}
sort.SliceStable(subtitle, func(i, j int) bool {
if subtitle[i].StartMs == subtitle[j].StartMs {
return subtitle[i].ID < subtitle[j].ID
}
return subtitle[i].StartMs < subtitle[j].StartMs
})
if len(subtitle) == 0 {
return ""
}
var out strings.Builder
for i, clip := range subtitle {
fmt.Fprintf(&out, "%d\n%s --> %s\n%s\n\n",
i+1,
formatSRTTimestamp(clip.StartMs),
formatSRTTimestamp(clip.StartMs+clip.DurationMs),
strings.TrimSpace(clip.Text))
}
return out.String()
}
const (
renderWidth = 1920
renderHeight = 1080
renderFPS = 30
renderSampleRate = 44100
)
// buildRenderFFmpegArgs 依据渲染计划生成 ffmpeg 参数(工作目录内相对路径)。
// 每段固定两个输入:视频源与音频源,末尾 concat 为单路输出。
func buildRenderFFmpegArgs(plan renderPlan, target string) []string {
if len(plan.Segments) == 0 {
return nil
}
scale := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2",
renderWidth, renderHeight, renderWidth, renderHeight)
args := []string{"-nostdin", "-y"}
for _, seg := range plan.Segments {
seconds := fmt.Sprintf("%.3f", float64(seg.DurationMs)/1000)
switch seg.Kind {
case "gap":
args = append(args,
"-f", "lavfi", "-t", seconds,
"-i", fmt.Sprintf("color=c=black:s=%dx%d:r=%d", renderWidth, renderHeight, renderFPS),
"-f", "lavfi", "-t", seconds,
"-i", fmt.Sprintf("anullsrc=r=%d:cl=stereo", renderSampleRate),
)
case "image":
if seg.Source == nil {
args = append(args,
"-f", "lavfi", "-t", seconds,
"-i", fmt.Sprintf("color=c=black:s=%dx%d:r=%d", renderWidth, renderHeight, renderFPS),
"-f", "lavfi", "-t", seconds,
"-i", fmt.Sprintf("anullsrc=r=%d:cl=stereo", renderSampleRate),
)
continue
}
args = append(args,
"-loop", "1", "-t", seconds, "-i", seg.Source.Path,
"-f", "lavfi", "-t", seconds,
"-i", fmt.Sprintf("anullsrc=r=%d:cl=stereo", renderSampleRate),
)
default:
if seg.Source == nil {
args = append(args,
"-f", "lavfi", "-t", seconds,
"-i", fmt.Sprintf("color=c=black:s=%dx%d:r=%d", renderWidth, renderHeight, renderFPS),
"-f", "lavfi", "-t", seconds,
"-i", fmt.Sprintf("anullsrc=r=%d:cl=stereo", renderSampleRate),
)
continue
}
if seg.Clip.SourceStartMs > 0 {
args = append(args, "-ss", fmt.Sprintf("%.3f", float64(seg.Clip.SourceStartMs)/1000))
}
args = append(args, "-t", seconds, "-i", seg.Source.Path)
// 音频输入复用同一媒体文件;无音轨时 ffmpeg 会因 map 失败,
// 由调用方探测后回退为静音源(见 renderAudioFallback)。
if seg.Clip.SourceStartMs > 0 {
args = append(args, "-ss", fmt.Sprintf("%.3f", float64(seg.Clip.SourceStartMs)/1000))
}
args = append(args, "-t", seconds, "-i", seg.Source.Path)
}
}
filters := make([]string, 0, len(plan.Segments)*2+1)
labels := make([]string, 0, len(plan.Segments)*2)
for i, seg := range plan.Segments {
videoIn := 2 * i
audioIn := 2*i + 1
videoLabel := fmt.Sprintf("v%d", i)
audioLabel := fmt.Sprintf("a%d", i)
if seg.Source != nil && seg.Kind != "image" && !seg.Source.HasAudio {
// 无音轨媒体:改用静音源,避免 map 失败。
audioIn = videoIn
filters = append(filters,
fmt.Sprintf("[%d:v]fps=%d,%s,setsar=1[%s]", videoIn, renderFPS, scale, videoLabel))
silentLabel := fmt.Sprintf("silent%d", i)
filters = append(filters,
fmt.Sprintf("anullsrc=r=%d:cl=stereo[%s]", renderSampleRate, silentLabel))
filters = append(filters,
fmt.Sprintf("[%s]atrim=0:%.3f,asetpts=N/SR/TB[%s]", silentLabel, float64(seg.DurationMs)/1000, audioLabel))
} else {
volume := 1.0
if seg.Clip.Volume > 0 {
volume = seg.Clip.Volume
}
filters = append(filters,
fmt.Sprintf("[%d:v]fps=%d,%s,setsar=1[%s]", videoIn, renderFPS, scale, videoLabel))
filters = append(filters,
fmt.Sprintf("[%d:a]aformat=sample_fmts=fltp:sample_rates=%d:channel_layouts=stereo,volume=%.3f,apad[%s]",
audioIn, renderSampleRate, volume, audioLabel))
}
labels = append(labels, videoLabel, audioLabel)
}
var concatInputs strings.Builder
for _, label := range labels {
fmt.Fprintf(&concatInputs, "[%s]", label)
}
filters = append(filters, fmt.Sprintf("%sconcat=n=%d:v=1:a=1[vout][aout]", concatInputs.String(), len(plan.Segments)))
args = append(args,
"-filter_complex", strings.Join(filters, ";"),
"-map", "[vout]", "-map", "[aout]",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart",
"-t", fmt.Sprintf("%.3f", planTotalSeconds(plan)),
target,
)
return args
}
func planTotalSeconds(plan renderPlan) float64 {
var total int64
for _, seg := range plan.Segments {
total += seg.DurationMs
}
return float64(total) / 1000
}