Files
flatrender/services/node-agent/internal/runner/remotion.go
T
soroush.asadi f8ea9af3b6 feat(render): Phase 2 — FlexStory render passthrough + journey template seed
Closes the render boundary so a user's scene list (order, per-scene content,
per-scene duration, theme) actually drives the FlexStory engine — the one gap the
scene-engine mapping found.

- render-svc GetFlexStoryProps (db.go): structured per-scene query that groups
  saved_scene_contents BY scene (the flat GetRenderBindings union collides when
  scenes share keys like "title"), recovers blockId from the scene key
  ("<BlockId>__<n>"), and emits the FlexStory props object
  {scenes:[{blockId,durationSec,props}], accentColor, …}.
- render-svc Claim (internal.go): when the template is Remotion + comp starts with
  "FlexStory", send that object as a single "__flexprops__" binding (no protocol
  struct change).
- node-agent remotionProps (remotion.go): if "__flexprops__" is present, pass it
  to `remotion render --props` verbatim (it's the complete props object).
- scripts/seed_flexstory.py: seeds the CharacterJourney template (7 scenes, theme
  colours, FLEXIBLE) with blockId-encoded scene keys, so the studio's existing
  CopyTemplateGraphAsync copies them into saved_scenes with zero studio-svc change.

Both Go services compile; template is live in the catalog (detail 200, per-aspect
previews). End-to-end render verification needs a live Remotion render node.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:45:04 +03:30

258 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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) {
// 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 496 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, "در حال کامپایل قالب…"
}
}