|
|
|
@@ -10,6 +10,8 @@ import (
|
|
|
|
|
"os"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"runtime"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -46,7 +48,14 @@ func Run(ctx context.Context, aePath, workDir string, job *Job, onProgress Progr
|
|
|
|
|
}
|
|
|
|
|
outputPath := filepath.Join(outputDir, "output.mp4")
|
|
|
|
|
|
|
|
|
|
if aePath == "" {
|
|
|
|
|
// Mock render when AE isn't installed (aePath empty) OR when this job has no
|
|
|
|
|
// template project to render (AEPFilePath empty — the template bundle wasn't
|
|
|
|
|
// uploaded/promoted yet). Mock drives progress+preview to completion so the job
|
|
|
|
|
// doesn't hard-fail; a real render requires both AE and a downloaded .aep.
|
|
|
|
|
if aePath == "" || job.AEPFilePath == "" {
|
|
|
|
|
if aePath != "" && job.AEPFilePath == "" {
|
|
|
|
|
log.Printf("[job %s] no template .aep available — falling back to mock render", job.JobID)
|
|
|
|
|
}
|
|
|
|
|
return mockRender(ctx, job, outputPath, onProgress, onPreview)
|
|
|
|
|
}
|
|
|
|
|
return aeRender(ctx, aePath, job, outputPath, onProgress, onPreview)
|
|
|
|
@@ -97,6 +106,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. <dir>/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 +198,9 @@ func aeRender(ctx context.Context, aePath string, job *Job, outputPath string, o
|
|
|
|
|
// -project <path.aep>
|
|
|
|
|
// -comp <name> (or -rqindex 1 when no comp name is known)
|
|
|
|
|
// -output <output.mp4>
|
|
|
|
|
// 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 +243,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 outputPath, nil
|
|
|
|
|
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")
|
|
|
|
|
_ = os.Remove(actual) // drop the multi-GB intermediate
|
|
|
|
|
return mp4, nil
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
if pct < 90 {
|
|
|
|
|
pct += 5
|
|
|
|
|