201 lines
6.5 KiB
Go
201 lines
6.5 KiB
Go
package app
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"infinite-canvas/backend/internal/model"
|
||
)
|
||
|
||
const renderFfmpegEnv = "CANVAS_FFMPEG_PATH"
|
||
|
||
// processTimelineRender 执行时间线渲染:按快照把引用的媒体落盘 →
|
||
// ffprobe 探测音轨 → ffmpeg concat 合成 → 产物写入资源存储。
|
||
// 渲染是本地重编码,不经模型路由与计费;失败一律落明确终态。
|
||
func (w *taskWorkerCoordinator) processTimelineRender(task *model.Task, ctx context.Context) error {
|
||
s := w.service
|
||
ffmpegBin, err := renderFfmpegBinary()
|
||
if err != nil {
|
||
return w.failTimelineTask(task, "渲染失败", err.Error())
|
||
}
|
||
var input timelineRenderInput
|
||
if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
|
||
return w.failTimelineTask(task, "渲染失败", "任务缺少有效的时间线快照")
|
||
}
|
||
plan := buildRenderPlan(input.Timeline)
|
||
if !plan.HasMedia {
|
||
return w.failTimelineTask(task, "渲染失败", "时间线没有可渲染的媒体片段")
|
||
}
|
||
|
||
if err := w.progress(task, "准备媒体…", 10); err != nil {
|
||
return err
|
||
}
|
||
workDir, cleanup, err := materializeRenderSources(ctx, s, task.UserID, &plan)
|
||
if cleanup != nil {
|
||
defer cleanup()
|
||
}
|
||
if err != nil {
|
||
return w.failTimelineTask(task, "渲染失败", err.Error())
|
||
}
|
||
for _, seg := range plan.Segments {
|
||
if seg.Source == nil || seg.Kind == "image" {
|
||
continue
|
||
}
|
||
hasAudio, probeErr := probeHasAudioStream(ctx, seg.Source.Path)
|
||
if probeErr != nil {
|
||
return w.failTimelineTask(task, "渲染失败", probeErr.Error())
|
||
}
|
||
seg.Source.HasAudio = hasAudio
|
||
}
|
||
|
||
args := buildRenderFFmpegArgs(plan, filepath.Join(workDir, "render-output.mp4"))
|
||
if len(args) == 0 {
|
||
return w.failTimelineTask(task, "渲染失败", "无法生成渲染命令")
|
||
}
|
||
if err := w.progress(task, "正在渲染…", 30); err != nil {
|
||
return err
|
||
}
|
||
cmd := exec.CommandContext(ctx, ffmpegBin, args...)
|
||
cmd.Dir = workDir
|
||
output, runErr := cmd.CombinedOutput()
|
||
if runErr != nil {
|
||
detail := strings.TrimSpace(string(output))
|
||
if len(detail) > 400 {
|
||
detail = detail[len(detail)-400:]
|
||
}
|
||
return w.failTimelineTask(task, "渲染失败", fmt.Sprintf("ffmpeg 渲染失败:%s", detail))
|
||
}
|
||
|
||
if err := w.progress(task, "写入资源…", 85); err != nil {
|
||
return err
|
||
}
|
||
renderedPath := filepath.Join(workDir, "render-output.mp4")
|
||
file, err := os.Open(renderedPath)
|
||
if err != nil {
|
||
return w.failTimelineTask(task, "渲染失败", "读取渲染产物失败")
|
||
}
|
||
defer file.Close()
|
||
stat, err := file.Stat()
|
||
if err != nil || stat.Size() == 0 {
|
||
return w.failTimelineTask(task, "渲染失败", "渲染产物为空")
|
||
}
|
||
durationMs := planDurationMs(plan)
|
||
fileName := fmt.Sprintf("timeline-render-%s.mp4", time.Now().Format("20060102-150405"))
|
||
resource, _, err := s.storeResource(task.UserID, "media", fileName, "video/mp4", stat.Size(), renderWidth, renderHeight, durationMs, file, nil, false)
|
||
if err != nil || resource == nil {
|
||
return w.failTimelineTask(task, "渲染失败", "保存渲染产物失败")
|
||
}
|
||
|
||
result := timelineRenderResult{
|
||
ResourceID: resource.ID,
|
||
FileName: fileName,
|
||
Size: stat.Size(),
|
||
DurationMs: durationMs,
|
||
SubtitleSRT: plan.SubtitleSRT,
|
||
}
|
||
payload, err := json.Marshal(result)
|
||
if err != nil {
|
||
return w.failTimelineTask(task, "渲染失败", "渲染结果序列化失败")
|
||
}
|
||
task.Status = model.TaskStatusSucceeded
|
||
task.Stage = "渲染完成"
|
||
task.Progress = 100
|
||
task.ResultJSON = string(payload)
|
||
completedAt := time.Now()
|
||
task.CompletedAt = &completedAt
|
||
if err := s.repo.SaveTaskCompletion(task, model.TaskStatusRunning, nil); err != nil {
|
||
return fmt.Errorf("写入渲染完成态失败: %w", err)
|
||
}
|
||
s.logInfo(task.UserID, task.ID, fmt.Sprintf("时间线渲染完成,时长 %.1fs", float64(durationMs)/1000), "")
|
||
return nil
|
||
}
|
||
|
||
func planDurationMs(plan renderPlan) int64 {
|
||
var total int64
|
||
for _, seg := range plan.Segments {
|
||
total += seg.DurationMs
|
||
}
|
||
return total
|
||
}
|
||
|
||
func renderFfmpegBinary() (string, error) {
|
||
if configured := strings.TrimSpace(os.Getenv(renderFfmpegEnv)); configured != "" {
|
||
return configured, nil
|
||
}
|
||
path, err := exec.LookPath("ffmpeg")
|
||
if err != nil {
|
||
return "", fmt.Errorf("渲染依赖未安装(需要 ffmpeg,可通过 %s 指定)", renderFfmpegEnv)
|
||
}
|
||
return path, nil
|
||
}
|
||
|
||
// materializeRenderSources 把计划中每个媒体片段对应的资源下载到临时目录,
|
||
// 同资源复用同一份本地文件;返回工作目录与清理函数。
|
||
func materializeRenderSources(ctx context.Context, s *Service, userID string, plan *renderPlan) (string, func(), error) {
|
||
tmpDir, err := os.MkdirTemp("", "yingce-render-*")
|
||
if err != nil {
|
||
return "", nil, fmt.Errorf("创建临时目录失败: %w", err)
|
||
}
|
||
cleanup := func() { _ = os.RemoveAll(tmpDir) }
|
||
cache := map[string]string{}
|
||
for i := range plan.Segments {
|
||
seg := &plan.Segments[i]
|
||
resourceID, ok := mediaResourceID(seg.Clip)
|
||
if !ok {
|
||
// 无可用媒体引用时渲染为黑场段,保证时间轴连续。
|
||
continue
|
||
}
|
||
path, cached := cache[resourceID]
|
||
if !cached {
|
||
_, reader, err := s.OpenResource(userID, resourceID)
|
||
if err != nil || reader == nil {
|
||
cleanup()
|
||
return "", nil, fmt.Errorf("无法读取时间线引用的媒体,可能已被删除")
|
||
}
|
||
ext := extForMime("video/mp4")
|
||
path = filepath.Join(tmpDir, fmt.Sprintf("src-%d%s", len(cache), ext))
|
||
file, err := os.Create(path)
|
||
if err != nil {
|
||
reader.Close()
|
||
cleanup()
|
||
return "", nil, fmt.Errorf("写入临时媒体失败: %w", err)
|
||
}
|
||
if _, err := io.Copy(file, reader); err != nil {
|
||
file.Close()
|
||
reader.Close()
|
||
cleanup()
|
||
return "", nil, fmt.Errorf("读取时间线媒体失败: %w", err)
|
||
}
|
||
file.Close()
|
||
reader.Close()
|
||
cache[resourceID] = path
|
||
}
|
||
seg.Source = &renderSource{ResourceID: resourceID, Clip: seg.Clip, Path: path, Ext: filepath.Ext(path)}
|
||
}
|
||
return tmpDir, cleanup, nil
|
||
}
|
||
|
||
// probeHasAudioStream 用 ffprobe 判断媒体是否含音轨,供滤镜图选择静音回退。
|
||
func probeHasAudioStream(ctx context.Context, path string) (bool, error) {
|
||
bin, err := exec.LookPath("ffprobe")
|
||
if err != nil {
|
||
return false, fmt.Errorf("媒体探测依赖未安装(需要 ffprobe)")
|
||
}
|
||
cmd := exec.CommandContext(ctx, bin,
|
||
"-v", "error", "-select_streams", "a:0", "-show_entries", "stream=index",
|
||
"-of", "csv=p=0", path)
|
||
output, runErr := cmd.Output()
|
||
if runErr != nil {
|
||
// 探测失败按无音轨处理,渲染仍可产出静音视频。
|
||
return false, nil
|
||
}
|
||
return strings.TrimSpace(string(output)) != "", nil
|
||
}
|