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>
164 lines
4.8 KiB
Go
164 lines
4.8 KiB
Go
package runner
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// remotionProjectDir resolves the repo's services/remotion directory relative to
|
|
// this test package (services/node-agent/internal/runner), or skips the test when
|
|
// it (or npx) is unavailable — keeps the test green on CI nodes without the
|
|
// Remotion project checked out.
|
|
func remotionProjectDir(t *testing.T) string {
|
|
t.Helper()
|
|
if v := os.Getenv("REMOTION_PROJECT_DIR"); v != "" {
|
|
return v
|
|
}
|
|
dir, err := filepath.Abs(filepath.Join("..", "..", "..", "remotion"))
|
|
if err != nil {
|
|
t.Fatalf("abs: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "package.json")); err != nil {
|
|
t.Skipf("remotion project not found at %s (skipping)", dir)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func TestRemotionProps(t *testing.T) {
|
|
job := &Job{Bindings: []Binding{
|
|
{Key: "logoText", Value: "HELLO"},
|
|
{Key: "accentColor", Value: "#22d3ee"},
|
|
{Key: "", Value: "ignored"}, // empty keys are dropped
|
|
}}
|
|
got, err := remotionProps(job)
|
|
if err != nil {
|
|
t.Fatalf("remotionProps: %v", err)
|
|
}
|
|
want := `{"accentColor":"#22d3ee","logoText":"HELLO"}`
|
|
if got != want {
|
|
t.Fatalf("props = %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRemotionProgress(t *testing.T) {
|
|
cases := []struct {
|
|
phase int32
|
|
cur, total, stch, stchTot int64
|
|
wantMin, wantMax int
|
|
}{
|
|
{0, 0, 0, 0, 0, 5, 5}, // bundling
|
|
{1, 90, 180, 0, 0, 30, 45}, // half the frames rendered
|
|
{2, 90, 180, 90, 180, 80, 90}, // half stitched
|
|
}
|
|
for _, c := range cases {
|
|
pct, _ := remotionProgress(c.phase, c.cur, c.total, c.stch, c.stchTot)
|
|
if pct < c.wantMin || pct > c.wantMax {
|
|
t.Errorf("phase %d: pct %d not in [%d,%d]", c.phase, pct, c.wantMin, c.wantMax)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRunRemotion_EndToEnd renders a real composition through the engine and
|
|
// asserts an MP4 lands on disk. Slow (spawns Chrome) — run with `go test -run
|
|
// RunRemotion -timeout 6m`. Skipped automatically without the project or npx.
|
|
func TestRunRemotion_EndToEnd(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping end-to-end render in -short mode")
|
|
}
|
|
remDir := remotionProjectDir(t)
|
|
if _, err := exec.LookPath(npxCmd()); err != nil {
|
|
t.Skipf("%s not on PATH (skipping)", npxCmd())
|
|
}
|
|
|
|
out := filepath.Join(t.TempDir(), "engine-out.mp4")
|
|
job := &Job{
|
|
JobID: "test-remotion-e2e",
|
|
Engine: EngineRemotion,
|
|
CompName: "KineticQuote",
|
|
Quality: "free",
|
|
Resolution: "360p", // exercises the height tier mapping
|
|
Bindings: []Binding{
|
|
{Key: "quote", Value: "Two engines, one output."},
|
|
{Key: "author", Value: "Engine Test"},
|
|
{Key: "accentColor", Value: "#22d3ee"},
|
|
},
|
|
}
|
|
|
|
var lastPct int
|
|
onProgress := func(_ context.Context, pct int, _ string) error { lastPct = pct; return nil }
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
|
|
defer cancel()
|
|
|
|
got, err := RunRemotion(ctx, remDir, job, out, onProgress, nil)
|
|
if err != nil {
|
|
t.Fatalf("RunRemotion: %v", err)
|
|
}
|
|
st, err := os.Stat(got)
|
|
if err != nil {
|
|
t.Fatalf("stat output: %v", err)
|
|
}
|
|
if st.Size() == 0 {
|
|
t.Fatal("output file is empty")
|
|
}
|
|
if lastPct < 90 {
|
|
t.Errorf("final progress only reached %d%%", lastPct)
|
|
}
|
|
t.Logf("rendered %s (%d bytes), final progress %d%%", got, st.Size(), lastPct)
|
|
}
|
|
|
|
// TestRun_RemotionEngine exercises the real integration point the node-agent uses:
|
|
// runner.Run() dispatching on Job.Engine. With Engine=Remotion and an empty AE path
|
|
// (which would otherwise trigger the AE mock), it must route to the Remotion engine
|
|
// and produce a real MP4.
|
|
func TestRun_RemotionEngine(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping end-to-end render in -short mode")
|
|
}
|
|
remDir := remotionProjectDir(t)
|
|
if _, err := exec.LookPath(npxCmd()); err != nil {
|
|
t.Skipf("%s not on PATH (skipping)", npxCmd())
|
|
}
|
|
|
|
job := &Job{
|
|
JobID: "test-run-dispatch",
|
|
Engine: EngineRemotion,
|
|
RemotionDir: remDir,
|
|
CompName: "KineticQuote",
|
|
Quality: "free",
|
|
Resolution: "360p",
|
|
Bindings: []Binding{{Key: "author", Value: "Dispatch Test"}},
|
|
}
|
|
noop := func(context.Context, int, string) error { return nil }
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
|
|
defer cancel()
|
|
|
|
// aePath empty: an AE job would mock here; a Remotion job must still render for real.
|
|
got, err := Run(ctx, "", t.TempDir(), job, noop, nil)
|
|
if err != nil {
|
|
t.Fatalf("Run (remotion engine): %v", err)
|
|
}
|
|
st, err := os.Stat(got)
|
|
if err != nil || st.Size() == 0 {
|
|
t.Fatalf("no output from Run: %v", err)
|
|
}
|
|
if string(mustRead(t, got)[:4]) == "mock" {
|
|
t.Fatal("Run produced the AE mock output instead of a real Remotion render")
|
|
}
|
|
t.Logf("Run dispatched to Remotion → %s (%d bytes)", got, st.Size())
|
|
}
|
|
|
|
func mustRead(t *testing.T, path string) []byte {
|
|
t.Helper()
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", path, err)
|
|
}
|
|
return b
|
|
}
|