// 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 /" while drawing frames and // "Stitched /" 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) { // FlexStory (scene engine) passthrough: the orchestrator already built the full // props object (scenes:[{blockId,durationSec,props}] + theme colours) as a single // "__flexprops__" binding. Use it verbatim — it's a complete JSON object, not a // flat key/value map. for _, b := range job.Bindings { if b.Key == "__flexprops__" { return b.Value, nil } } 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, "در حال کامپایل قالب…" } }