feat(render+templates): Remotion engine, 16 branded templates (incl. 3D), seconds pricing, coming-soon
Render engine - Add Remotion (code-based) as a 2nd render engine alongside After Effects. node-agent dispatches on Job.Engine; RunRemotion maps bindings -> --props, renders native then ffmpeg-scales to the quality tier (aspect-preserving). - content.projects.render_engine + render_remotion_comp (migration 32); render-svc claim resolves engine and routes (skips .aep for Remotion). - Admin TemplatesAdmin gains an engine picker + Remotion composition id field. Template pack (services/remotion) - 16 branded, Persian (Vazirmatn), color- and text-editable templates, each in 3 aspects (16:9 / 1:1 / 9:16): LogoMotion, Opener, InstaPromo, YouTubeIntro, Slideshow, HappyBirthday, SalePromo, QuoteCard, EventInvite, Countdown, GlitterReveal (editable logo image), NowruzGreeting (animated characters), and 4 cinematic 3D templates via @remotion/three (Hero3D, Nowruz3D, Birthday3D, Promo3D) with reflections + bloom/DOF/vignette. - scripts/seed_remotion_templates.py seeds containers/projects/scenes/colors. Pricing - Rewrite /pricing to the seconds-based model (charge = length x resolution), data-driven from /v1/plans, Toman, broker checkout. Coming-soon - Persian experimental-build overlay on all pages (launch date + countdown). Fixes - middleware matcher bypasses all static asset paths; catalog mapping passes cover image + preview video so real thumbnails render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -515,6 +515,13 @@ func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) {
|
||||
binds = append(binds, runner.Binding{Key: b.Key, Type: b.Type, Value: b.Value})
|
||||
}
|
||||
|
||||
// Default empty engine to AfterEffects for backwards-compat with older
|
||||
// orchestrators that don't send the field yet.
|
||||
engine := job.Engine
|
||||
if engine == "" {
|
||||
engine = runner.EngineAfterEffects
|
||||
}
|
||||
|
||||
rJob := &runner.Job{
|
||||
JobID: job.JobID,
|
||||
SavedProjectID: job.SavedProjectID,
|
||||
@@ -523,6 +530,8 @@ func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) {
|
||||
FrameRate: job.FrameRate,
|
||||
HasMusic: job.HasMusic,
|
||||
HasVoiceover: job.HasVoiceover,
|
||||
Engine: engine,
|
||||
RemotionDir: a.cfg.RemotionProjectDir,
|
||||
AEPFilePath: aepPath,
|
||||
CompName: job.CompName,
|
||||
AfterFxPath: a.cfg.AfterFxPath,
|
||||
|
||||
@@ -152,6 +152,9 @@ type ClaimedJob struct {
|
||||
FrameRate int `json:"frame_rate"`
|
||||
HasMusic bool `json:"has_music"`
|
||||
HasVoiceover bool `json:"has_voiceover"`
|
||||
// Engine selects the render engine: "AfterEffects" (default) or "Remotion".
|
||||
// For Remotion jobs CompName is the composition id and AEPDownloadURL is empty.
|
||||
Engine string `json:"engine,omitempty"`
|
||||
// AEPDownloadURL is a presigned MinIO GET URL for the .aep template file
|
||||
// (or .zip bundle). Empty when the template has not been uploaded yet — triggers mock render.
|
||||
AEPDownloadURL string `json:"aep_download_url,omitempty"`
|
||||
|
||||
@@ -85,6 +85,10 @@ type Config struct {
|
||||
// WorkDir is the scratch directory for render temp files and AE project copies.
|
||||
WorkDir string
|
||||
|
||||
// RemotionProjectDir is the Remotion project root (package.json + src/index.ts)
|
||||
// used by the code-based render engine. Empty disables Remotion jobs on this node.
|
||||
RemotionProjectDir string
|
||||
|
||||
// HeartbeatIntervalSec is how often the agent sends a heartbeat to the orchestrator.
|
||||
HeartbeatIntervalSec int
|
||||
|
||||
@@ -115,6 +119,7 @@ func Load() (*Config, error) {
|
||||
AEPath: getEnv("AE_PATH", ""),
|
||||
AfterFxPath: getEnv("AFTERFX_PATH", ""),
|
||||
WorkDir: getEnv("WORK_DIR", os.TempDir()),
|
||||
RemotionProjectDir: getEnv("REMOTION_PROJECT_DIR", ""),
|
||||
AgentVersion: getEnv("AGENT_VERSION", "0.1.0"),
|
||||
AEVersion: getEnv("AE_VERSION", "2024"),
|
||||
HeartbeatIntervalSec: getInt("HEARTBEAT_INTERVAL_SEC", 5),
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
// Remotion render engine.
|
||||
//
|
||||
// FlatRender supports two template engines that both produce a web-playable MP4:
|
||||
//
|
||||
// - AfterEffects (EngineAfterEffects) — aerender.exe renders a .aep template,
|
||||
// bindings are written into the project first; see runner.go / binder.go.
|
||||
// - Remotion (EngineRemotion) — a code-based React/Remotion composition
|
||||
// is rendered with `npx remotion render`; bindings become --props; this file.
|
||||
//
|
||||
// The two engines are interchangeable from the job loop's point of view: Run()
|
||||
// dispatches on Job.Engine and each returns the path to an MP4 on disk.
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Engine identifiers. These mirror the values the orchestrator stores per
|
||||
// template (content.templates.render_engine) and sends on the claimed job.
|
||||
const (
|
||||
EngineAfterEffects = "AfterEffects"
|
||||
EngineRemotion = "Remotion"
|
||||
)
|
||||
|
||||
// Remotion prints "Rendered <done>/<total>" while drawing frames and
|
||||
// "Stitched <done>/<total>" while muxing them into the MP4. We parse both to
|
||||
// build a real percentage.
|
||||
var (
|
||||
reRemRendered = regexp.MustCompile(`Rendered\s+(\d+)/(\d+)`)
|
||||
reRemStitched = regexp.MustCompile(`Stitched\s+(\d+)/(\d+)`)
|
||||
)
|
||||
|
||||
// npxCmd returns the platform-appropriate npx launcher.
|
||||
func npxCmd() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "npx.cmd"
|
||||
}
|
||||
return "npx"
|
||||
}
|
||||
|
||||
// remotionProps maps the user's bindings into a Remotion props JSON object.
|
||||
// For code-based templates the binding Key is the composition's schema field
|
||||
// (logoText, accentColor, …) and Value is the user's edited string. Anything the
|
||||
// user didn't touch falls back to the composition's defaultProps.
|
||||
func remotionProps(job *Job) (string, error) {
|
||||
props := make(map[string]string, len(job.Bindings))
|
||||
for _, b := range job.Bindings {
|
||||
if b.Key == "" {
|
||||
continue
|
||||
}
|
||||
props[b.Key] = b.Value
|
||||
}
|
||||
data, err := json.Marshal(props)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// crlfSplit is a bufio.SplitFunc that breaks on either \n or \r so we capture
|
||||
// each progress-bar repaint (Remotion redraws the bar with \r, not \n).
|
||||
func crlfSplit(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
if atEOF && len(data) == 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
for i, b := range data {
|
||||
if b == '\n' || b == '\r' {
|
||||
return i + 1, data[:i], nil
|
||||
}
|
||||
}
|
||||
if atEOF {
|
||||
return len(data), data, nil
|
||||
}
|
||||
return 0, nil, nil // request more data
|
||||
}
|
||||
|
||||
// RunRemotion renders a code-based (Remotion) template to MP4.
|
||||
//
|
||||
// - remotionDir is the Remotion project root (has package.json + src/index.ts).
|
||||
// - job.CompName is the Remotion composition id (e.g. "KineticQuote").
|
||||
// - job.Bindings become --props.
|
||||
// - job.Resolution selects an output height tier (free=360p … 4k).
|
||||
//
|
||||
// Returns the path to the rendered MP4. Progress + periodic previews are streamed
|
||||
// through the same callbacks the AE engine uses, so the UI is engine-agnostic.
|
||||
func RunRemotion(ctx context.Context, remotionDir string, job *Job, outputPath string, onProgress ProgressFn, onPreview PreviewFn) (string, error) {
|
||||
if remotionDir == "" {
|
||||
return "", fmt.Errorf("remotion project dir not set (REMOTION_PROJECT_DIR)")
|
||||
}
|
||||
if job.CompName == "" {
|
||||
return "", fmt.Errorf("remotion render requires a composition id (CompName)")
|
||||
}
|
||||
if st, err := os.Stat(remotionDir); err != nil || !st.IsDir() {
|
||||
return "", fmt.Errorf("remotion project dir not found: %s", remotionDir)
|
||||
}
|
||||
|
||||
propsJSON, err := remotionProps(job)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build props: %w", err)
|
||||
}
|
||||
|
||||
// Render at the composition's native resolution, then downscale to the quality
|
||||
// tier with ffmpeg (scale=-2:h preserves aspect). Remotion's --height flag
|
||||
// overrides height but keeps the native width, which squishes non-matching
|
||||
// aspect ratios — so we deliberately scale in the same ffmpeg post-step the AE
|
||||
// engine uses. This also keeps one place to stamp the free-tier watermark later.
|
||||
nativePath := strings.TrimSuffix(outputPath, filepath.Ext(outputPath)) + ".native.mp4"
|
||||
entry := filepath.Join("src", "index.ts")
|
||||
args := []string{
|
||||
"remotion", "render", entry, job.CompName, nativePath,
|
||||
"--props=" + propsJSON,
|
||||
"--log=info",
|
||||
}
|
||||
|
||||
log.Printf("[remotion] job %s → comp %q, props %s (cwd=%s)", job.JobID, job.CompName, propsJSON, remotionDir)
|
||||
cmd := exec.CommandContext(ctx, npxCmd(), args...)
|
||||
cmd.Dir = remotionDir
|
||||
|
||||
// Merge stdout+stderr into one pipe — Remotion writes the progress bar to
|
||||
// stderr and structured logs to stdout; we want both.
|
||||
pr, pw := io.Pipe()
|
||||
cmd.Stdout = pw
|
||||
cmd.Stderr = pw
|
||||
|
||||
var curFrame, totalFrames, stitched, totalStitch int64
|
||||
var phase atomic.Int32 // 0=bundling 1=rendering 2=stitching
|
||||
go func() {
|
||||
sc := bufio.NewScanner(pr)
|
||||
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
sc.Split(crlfSplit)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
_, _ = io.WriteString(os.Stdout, "[remotion] "+line+"\n")
|
||||
if m := reRemRendered.FindStringSubmatch(line); m != nil {
|
||||
cur, _ := strconv.ParseInt(m[1], 10, 64)
|
||||
tot, _ := strconv.ParseInt(m[2], 10, 64)
|
||||
atomic.StoreInt64(&curFrame, cur)
|
||||
atomic.StoreInt64(&totalFrames, tot)
|
||||
phase.Store(1)
|
||||
}
|
||||
if m := reRemStitched.FindStringSubmatch(line); m != nil {
|
||||
cur, _ := strconv.ParseInt(m[1], 10, 64)
|
||||
tot, _ := strconv.ParseInt(m[2], 10, 64)
|
||||
atomic.StoreInt64(&stitched, cur)
|
||||
atomic.StoreInt64(&totalStitch, tot)
|
||||
phase.Store(2)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = pw.Close()
|
||||
return "", fmt.Errorf("start remotion: %w", err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
werr := cmd.Wait()
|
||||
_ = pw.Close() // unblock the scanner goroutine
|
||||
done <- werr
|
||||
}()
|
||||
|
||||
_ = onProgress(ctx, 4, "در حال آمادهسازی قالب…") // "Preparing template…"
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
lastPreview := time.Time{}
|
||||
|
||||
for {
|
||||
select {
|
||||
case werr := <-done:
|
||||
if werr != nil {
|
||||
return "", fmt.Errorf("remotion render exit: %w", werr)
|
||||
}
|
||||
if st, serr := os.Stat(nativePath); serr != nil || st.Size() == 0 {
|
||||
return "", fmt.Errorf("remotion finished but produced no output at %s", nativePath)
|
||||
}
|
||||
// Downscale to the quality tier (aspect-preserving). When ffmpeg is
|
||||
// missing or the tier is unknown, ship the native render unchanged.
|
||||
h := resolutionHeight(job.Resolution)
|
||||
if h > 0 && ffmpegPath() != "" {
|
||||
_ = onProgress(ctx, 96, "در حال بهینهسازی کیفیت…") // "Optimizing quality…"
|
||||
mp4, terr := transcodeToMP4(ctx, nativePath, outputPath, h)
|
||||
if terr != nil {
|
||||
log.Printf("[remotion] tier transcode failed (%v) — shipping native render", terr)
|
||||
_ = onProgress(ctx, 98, "اتمام رندر")
|
||||
return nativePath, nil
|
||||
}
|
||||
_ = os.Remove(nativePath)
|
||||
_ = onProgress(ctx, 98, "اتمام رندر")
|
||||
return mp4, nil
|
||||
}
|
||||
_ = onProgress(ctx, 98, "اتمام رندر")
|
||||
return nativePath, nil
|
||||
case <-ticker.C:
|
||||
pct, msg := remotionProgress(phase.Load(),
|
||||
atomic.LoadInt64(&curFrame), atomic.LoadInt64(&totalFrames),
|
||||
atomic.LoadInt64(&stitched), atomic.LoadInt64(&totalStitch))
|
||||
_ = onProgress(ctx, pct, msg)
|
||||
if onPreview != nil && time.Since(lastPreview) >= 8*time.Second {
|
||||
lastPreview = time.Now()
|
||||
if perr := onPreview(ctx, GeneratePreviewB64(pct, job.Quality, job.Resolution)); perr != nil {
|
||||
log.Printf("[remotion] preview push error: %v", perr)
|
||||
}
|
||||
}
|
||||
case <-ctx.Done():
|
||||
_ = cmd.Process.Kill()
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remotionProgress maps the render phase + frame counts to a 4–96 percentage
|
||||
// (leaving headroom for the orchestrator's upload step) plus a Persian message.
|
||||
func remotionProgress(phase int32, cur, total, stch, stchTotal int64) (int, string) {
|
||||
switch phase {
|
||||
case 2: // stitching → 70..96
|
||||
if stchTotal > 0 {
|
||||
frac := float64(stch) / float64(stchTotal)
|
||||
return 70 + int(frac*26), fmt.Sprintf("در حال ساخت ویدیو… %d از %d", stch, stchTotal)
|
||||
}
|
||||
return 70, "در حال ساخت ویدیو…"
|
||||
case 1: // rendering frames → 8..70
|
||||
if total > 0 {
|
||||
frac := float64(cur) / float64(total)
|
||||
return 8 + int(frac*62), fmt.Sprintf("در حال رندر… فریم %d از %d", cur, total)
|
||||
}
|
||||
return 8, "در حال رندر…"
|
||||
default: // bundling
|
||||
return 5, "در حال کامپایل قالب…"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// remotionProjectDir resolves the repo's services/remotion directory relative to
|
||||
// this test package (services/node-agent/internal/runner), or skips the test when
|
||||
// it (or npx) is unavailable — keeps the test green on CI nodes without the
|
||||
// Remotion project checked out.
|
||||
func remotionProjectDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
if v := os.Getenv("REMOTION_PROJECT_DIR"); v != "" {
|
||||
return v
|
||||
}
|
||||
dir, err := filepath.Abs(filepath.Join("..", "..", "..", "remotion"))
|
||||
if err != nil {
|
||||
t.Fatalf("abs: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "package.json")); err != nil {
|
||||
t.Skipf("remotion project not found at %s (skipping)", dir)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestRemotionProps(t *testing.T) {
|
||||
job := &Job{Bindings: []Binding{
|
||||
{Key: "logoText", Value: "HELLO"},
|
||||
{Key: "accentColor", Value: "#22d3ee"},
|
||||
{Key: "", Value: "ignored"}, // empty keys are dropped
|
||||
}}
|
||||
got, err := remotionProps(job)
|
||||
if err != nil {
|
||||
t.Fatalf("remotionProps: %v", err)
|
||||
}
|
||||
want := `{"accentColor":"#22d3ee","logoText":"HELLO"}`
|
||||
if got != want {
|
||||
t.Fatalf("props = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemotionProgress(t *testing.T) {
|
||||
cases := []struct {
|
||||
phase int32
|
||||
cur, total, stch, stchTot int64
|
||||
wantMin, wantMax int
|
||||
}{
|
||||
{0, 0, 0, 0, 0, 5, 5}, // bundling
|
||||
{1, 90, 180, 0, 0, 30, 45}, // half the frames rendered
|
||||
{2, 90, 180, 90, 180, 80, 90}, // half stitched
|
||||
}
|
||||
for _, c := range cases {
|
||||
pct, _ := remotionProgress(c.phase, c.cur, c.total, c.stch, c.stchTot)
|
||||
if pct < c.wantMin || pct > c.wantMax {
|
||||
t.Errorf("phase %d: pct %d not in [%d,%d]", c.phase, pct, c.wantMin, c.wantMax)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunRemotion_EndToEnd renders a real composition through the engine and
|
||||
// asserts an MP4 lands on disk. Slow (spawns Chrome) — run with `go test -run
|
||||
// RunRemotion -timeout 6m`. Skipped automatically without the project or npx.
|
||||
func TestRunRemotion_EndToEnd(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping end-to-end render in -short mode")
|
||||
}
|
||||
remDir := remotionProjectDir(t)
|
||||
if _, err := exec.LookPath(npxCmd()); err != nil {
|
||||
t.Skipf("%s not on PATH (skipping)", npxCmd())
|
||||
}
|
||||
|
||||
out := filepath.Join(t.TempDir(), "engine-out.mp4")
|
||||
job := &Job{
|
||||
JobID: "test-remotion-e2e",
|
||||
Engine: EngineRemotion,
|
||||
CompName: "KineticQuote",
|
||||
Quality: "free",
|
||||
Resolution: "360p", // exercises the height tier mapping
|
||||
Bindings: []Binding{
|
||||
{Key: "quote", Value: "Two engines, one output."},
|
||||
{Key: "author", Value: "Engine Test"},
|
||||
{Key: "accentColor", Value: "#22d3ee"},
|
||||
},
|
||||
}
|
||||
|
||||
var lastPct int
|
||||
onProgress := func(_ context.Context, pct int, _ string) error { lastPct = pct; return nil }
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
got, err := RunRemotion(ctx, remDir, job, out, onProgress, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RunRemotion: %v", err)
|
||||
}
|
||||
st, err := os.Stat(got)
|
||||
if err != nil {
|
||||
t.Fatalf("stat output: %v", err)
|
||||
}
|
||||
if st.Size() == 0 {
|
||||
t.Fatal("output file is empty")
|
||||
}
|
||||
if lastPct < 90 {
|
||||
t.Errorf("final progress only reached %d%%", lastPct)
|
||||
}
|
||||
t.Logf("rendered %s (%d bytes), final progress %d%%", got, st.Size(), lastPct)
|
||||
}
|
||||
|
||||
// TestRun_RemotionEngine exercises the real integration point the node-agent uses:
|
||||
// runner.Run() dispatching on Job.Engine. With Engine=Remotion and an empty AE path
|
||||
// (which would otherwise trigger the AE mock), it must route to the Remotion engine
|
||||
// and produce a real MP4.
|
||||
func TestRun_RemotionEngine(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping end-to-end render in -short mode")
|
||||
}
|
||||
remDir := remotionProjectDir(t)
|
||||
if _, err := exec.LookPath(npxCmd()); err != nil {
|
||||
t.Skipf("%s not on PATH (skipping)", npxCmd())
|
||||
}
|
||||
|
||||
job := &Job{
|
||||
JobID: "test-run-dispatch",
|
||||
Engine: EngineRemotion,
|
||||
RemotionDir: remDir,
|
||||
CompName: "KineticQuote",
|
||||
Quality: "free",
|
||||
Resolution: "360p",
|
||||
Bindings: []Binding{{Key: "author", Value: "Dispatch Test"}},
|
||||
}
|
||||
noop := func(context.Context, int, string) error { return nil }
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// aePath empty: an AE job would mock here; a Remotion job must still render for real.
|
||||
got, err := Run(ctx, "", t.TempDir(), job, noop, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Run (remotion engine): %v", err)
|
||||
}
|
||||
st, err := os.Stat(got)
|
||||
if err != nil || st.Size() == 0 {
|
||||
t.Fatalf("no output from Run: %v", err)
|
||||
}
|
||||
if string(mustRead(t, got)[:4]) == "mock" {
|
||||
t.Fatal("Run produced the AE mock output instead of a real Remotion render")
|
||||
}
|
||||
t.Logf("Run dispatched to Remotion → %s (%d bytes)", got, st.Size())
|
||||
}
|
||||
|
||||
func mustRead(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -46,6 +46,11 @@ type Job struct {
|
||||
FrameRate int
|
||||
HasMusic bool
|
||||
HasVoiceover bool
|
||||
// Engine selects the render engine: EngineAfterEffects (default, "" treated as
|
||||
// AE for backwards-compat) or EngineRemotion (code-based React templates).
|
||||
Engine string
|
||||
// RemotionDir is the Remotion project root, used only when Engine == EngineRemotion.
|
||||
RemotionDir string
|
||||
// AEPFilePath is the local path to the downloaded .aep project file.
|
||||
// In a full implementation the agent downloads this from MinIO before calling Run.
|
||||
AEPFilePath string
|
||||
@@ -75,6 +80,12 @@ func Run(ctx context.Context, aePath, workDir string, job *Job, onProgress Progr
|
||||
}
|
||||
outputPath := filepath.Join(outputDir, "output.mp4")
|
||||
|
||||
// Engine dispatch. Remotion is fully self-contained (Node + Chrome), so it
|
||||
// never touches the AE / mock paths below.
|
||||
if strings.EqualFold(job.Engine, EngineRemotion) {
|
||||
return RunRemotion(ctx, job.RemotionDir, job, outputPath, onProgress, onPreview)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user