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