feat(render+templates): Remotion engine, 16 branded templates (incl. 3D), seconds pricing, coming-soon
CI/CD / CI · Web (tsc) (push) Successful in 1m21s
CI/CD / Deploy · full stack (push) Failing after 20s

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:
soroush.asadi
2026-06-21 15:52:52 +03:30
parent b9b91397b0
commit 4f04f6bf75
137 changed files with 8942 additions and 135 deletions
@@ -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 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, "در حال کامپایل قالب…"
}
}