refactor(*): 平台架构 - 拆分后端领域模块并同步插件协议与模型能力

This commit is contained in:
ddCat
2026-09-11 14:02:49 +08:00
parent 916043fb5a
commit 6e5cbc8b05
644 changed files with 62731 additions and 2320 deletions

View File

@@ -0,0 +1,200 @@
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, nil, 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
}