diff --git a/services/node-agent/internal/runner/runner.go b/services/node-agent/internal/runner/runner.go index 4292ee9..cb5e472 100644 --- a/services/node-agent/internal/runner/runner.go +++ b/services/node-agent/internal/runner/runner.go @@ -10,6 +10,8 @@ import ( "os" "os/exec" "path/filepath" + "runtime" + "strings" "time" ) @@ -97,6 +99,87 @@ func mockRender(ctx context.Context, job *Job, outputPath string, onProgress Pro return outputPath, nil } +// findRenderedOutput locates the file aerender actually produced. The requested +// path is e.g. /output.mp4, but the output module may have written +// output.avi / output.mov / output.mp4. Prefer an exact match, then .mp4, then +// the largest output.* file in the directory. +func findRenderedOutput(requested string) string { + if st, err := os.Stat(requested); err == nil && st.Size() > 0 { + return requested + } + dir := filepath.Dir(requested) + base := strings.TrimSuffix(filepath.Base(requested), filepath.Ext(requested)) // "output" + matches, _ := filepath.Glob(filepath.Join(dir, base+".*")) + var best string + var bestSize int64 = -1 + for _, m := range matches { + st, err := os.Stat(m) + if err != nil || st.IsDir() { + continue + } + // Prefer .mp4 immediately. + if strings.EqualFold(filepath.Ext(m), ".mp4") && st.Size() > 0 { + return m + } + if st.Size() > bestSize { + best, bestSize = m, st.Size() + } + } + return best +} + +// ffmpegPath locates an ffmpeg binary: $FFMPEG_PATH, then `ffmpeg(.exe)` next to +// the agent executable, then PATH. Returns "" when none is found. +func ffmpegPath() string { + if p := os.Getenv("FFMPEG_PATH"); p != "" { + if _, err := os.Stat(p); err == nil { + return p + } + } + name := "ffmpeg" + if runtime.GOOS == "windows" { + name = "ffmpeg.exe" + } + if exe, err := os.Executable(); err == nil { + cand := filepath.Join(filepath.Dir(exe), name) + if _, err := os.Stat(cand); err == nil { + return cand + } + } + if p, err := exec.LookPath(name); err == nil { + return p + } + return "" +} + +// transcodeToMP4 converts a lossless AE render (AVI/MOV) to a web-playable H.264 +// MP4 using ffmpeg. Returns the .mp4 path. Errors if ffmpeg is unavailable. +func transcodeToMP4(ctx context.Context, src, requested string) (string, error) { + ff := ffmpegPath() + if ff == "" { + return "", fmt.Errorf("ffmpeg not found (set FFMPEG_PATH or place ffmpeg.exe next to the agent)") + } + dst := strings.TrimSuffix(requested, filepath.Ext(requested)) + ".mp4" + args := []string{ + "-y", "-i", src, + "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-b:a", "192k", + "-movflags", "+faststart", + dst, + } + log.Printf("[ffmpeg] %s %v", ff, args) + cmd := exec.CommandContext(ctx, ff, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("ffmpeg: %w", err) + } + if st, err := os.Stat(dst); err != nil || st.Size() == 0 { + return "", fmt.Errorf("ffmpeg produced no output") + } + return dst, nil +} + // ── Real AE render via aerender.exe ────────────────────────────────────────── func aeRender(ctx context.Context, aePath string, job *Job, outputPath string, onProgress ProgressFn, onPreview PreviewFn) (string, error) { @@ -108,6 +191,9 @@ func aeRender(ctx context.Context, aePath string, job *Job, outputPath string, o // -project // -comp (or -rqindex 1 when no comp name is known) // -output + // Modern AE can't reliably write H.264 directly from aerender, so we let it + // render with the project's output module (typically Lossless AVI/MOV) and + // transcode to MP4 with ffmpeg afterwards (see transcodeToMP4). // Without -comp/-rqindex, aerender ignores -output and renders nothing. args := []string{"-project", job.AEPFilePath} if job.CompName != "" { @@ -150,8 +236,28 @@ func aeRender(ctx context.Context, aePath string, job *Job, outputPath string, o if err != nil { return "", fmt.Errorf("aerender exit: %w", err) } + // Find what aerender actually wrote (output.avi / .mov / .mp4). + actual := findRenderedOutput(outputPath) + if actual == "" { + return "", fmt.Errorf("aerender finished but no output file found in %s", filepath.Dir(outputPath)) + } + // Already an MP4? done. + if strings.EqualFold(filepath.Ext(actual), ".mp4") { + _ = onProgress(ctx, 95, "Encoding complete") + return actual, nil + } + // Transcode the lossless render → H.264 MP4 (much smaller, web-playable). + _ = onProgress(ctx, 92, "Transcoding to MP4…") + mp4, terr := transcodeToMP4(ctx, actual, outputPath) + if terr != nil { + // ffmpeg missing/failed — fall back to the raw render so the job + // still delivers a file (large, but valid). + log.Printf("[ae] transcode failed (%v) — uploading raw %s", terr, filepath.Ext(actual)) + return actual, nil + } _ = onProgress(ctx, 95, "Encoding complete") - return outputPath, nil + _ = os.Remove(actual) // drop the multi-GB intermediate + return mp4, nil case <-ticker.C: if pct < 90 { pct += 5