Files
flatrender/services/node-agent/internal/runner/remotion_test.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

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
}