Files
flatrender/services/node-agent/internal/runner/remotion.go
T
soroush.asadi 4f04f6bf75
CI/CD / CI · Web (tsc) (push) Successful in 1m21s
CI/CD / Deploy · full stack (push) Failing after 20s
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>
2026-06-21 15:52:52 +03:30

249 lines
8.3 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) {
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, "در حال کامپایل قالب…"
}
}