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