4f04f6bf75
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>
249 lines
8.3 KiB
Go
249 lines
8.3 KiB
Go
// 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, "در حال کامپایل قالب…"
|
||
}
|
||
}
|