@
Build backend images / build content-svc (push) Failing after 19s
Build backend images / build file-svc (push) Failing after 1m53s
Build backend images / build gateway (push) Failing after 16s
Build backend images / build identity-svc (push) Failing after 7m1s
Build backend images / build notification-svc (push) Failing after 7m24s
Build backend images / build render-svc (push) Failing after 3m12s
Build backend images / build studio-svc (push) Failing after 43s

feat: AE template scanner + scene editor + AEP bundle pipeline

Scene editor (admin): per-project Scenes / Shared Colors / Color Presets
manager (ProjectScenes) reachable from each project.

AEP bundle pipeline: upload .aep or .zip → stored once per template at
templates/{project_id}/(bundle.zip|template.aep); render claim probes and
returns is_bundle+md5; node-agent extracts the bundle, locates the .aep
(zip-slip guarded), and caches by md5 so repeated renders extract once.

AE template scanner ("read scenes/colours/configs from the AEP"):
- content-svc importer: POST /v1/projects/{id}/scan/{preview,apply} —
  review-diff-then-merge into scenes/elements/colours (manual edits kept).
- render-svc Go quick-scan: stdlib RIFX parser extracts comp names+durations
  (no AE) → POST /v1/template-scans/{id}/quick.
- render-svc AE scan jobs + node-agent runner: queue → node runs scan.jsx
  (reverse of legacy JSXGenerator conventions: frfinal/frshare/frl_/frd_) →
  posts ScanResult back. Migration 26_render_scan_jobs.
- admin UI: "اسکن از افترافکت" with quick/full engines + diff-review modal.

Verified: importer preview/apply, Go quick-scan end-to-end (synthetic .aep →
scene imported), bundle extract unit tests, RIFX parser unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
soroush.asadi
2026-06-04 10:39:45 +03:30
parent 264fccf21f
commit 1ff6e494c0
26 changed files with 2691 additions and 27 deletions
+110 -9
View File
@@ -46,6 +46,7 @@ type Agent struct {
orch *client.Client
mu sync.Mutex
currentJob *client.ClaimedJob
scanning bool // true while running an AE scan job (shares the AE app)
status string // "Ready" | "Busy"
}
@@ -75,9 +76,25 @@ func (a *Agent) getStatus() (string, *string) {
jobID := a.currentJob.JobID
return a.status, &jobID
}
if a.scanning {
return "Busy", nil
}
return a.status, nil
}
func (a *Agent) setScanning(v bool) {
a.mu.Lock()
a.scanning = v
a.mu.Unlock()
}
// isBusy reports whether the AE app is in use (rendering or scanning).
func (a *Agent) isBusy() bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.currentJob != nil || a.scanning
}
// ── Main ──────────────────────────────────────────────────────────────────────
func main() {
@@ -121,10 +138,11 @@ func main() {
// Main loops
var wg sync.WaitGroup
wg.Add(3)
wg.Add(4)
go func() { defer wg.Done(); agent.heartbeatLoop(ctx) }()
go func() { defer wg.Done(); agent.pollLoop(ctx) }()
go func() { defer wg.Done(); agent.fontSyncLoop(ctx) }()
go func() { defer wg.Done(); agent.scanLoop(ctx) }()
wg.Wait()
log.Printf("shutdown complete")
}
@@ -183,6 +201,83 @@ func (a *Agent) syncFonts(ctx context.Context) {
}
}
// ── Scan loop ──────────────────────────────────────────────────────────────────
// Claims AE scan jobs and runs the template scanner (scan.jsx) via afterfx.exe,
// posting the resulting ScanResult JSON back to the orchestrator. Requires the AE
// app — skipped entirely in mock/dev (no AE_PATH).
func (a *Agent) scanLoop(ctx context.Context) {
interval := time.Duration(a.cfg.PollIntervalSec) * time.Second
if interval < 5*time.Second {
interval = 5 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.pollScanOnce(ctx)
}
}
}
func (a *Agent) pollScanOnce(ctx context.Context) {
if a.cfg.AfterFxPath == "" || a.cfg.AEPath == "" {
return // scanning needs the real AE app
}
if a.isBusy() {
return // don't contend with a render (or another scan) for the AE app
}
claim, err := a.orch.ClaimScan(ctx, a.cfg.NodeID, a.cfg.Region)
if err != nil {
log.Printf("scan claim error: %v", err)
return
}
if claim == nil {
return // nothing queued
}
a.setScanning(true)
defer a.setScanning(false)
log.Printf("[scan %s] claimed (project %s)", claim.ScanJobID, claim.ProjectID)
// Reuse the template prepare/cache pipeline (download + extract bundle by md5).
prepCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
aepPath, perr := runner.PrepareTemplate(prepCtx, claim.AEPDownloadURL, claim.IsBundle, claim.BundleMD5, a.cfg.WorkDir, "scan-"+claim.ScanJobID)
cancel()
if perr != nil {
a.failScan(claim.ScanJobID, "prepare template: "+perr.Error())
return
}
outPath := filepath.Join(a.cfg.WorkDir, "scans", claim.ScanJobID, "scan.json")
scanCtx, cancel2 := context.WithTimeout(ctx, 10*time.Minute)
result, serr := runner.RunScan(scanCtx, a.cfg.AfterFxPath, aepPath, a.cfg.WorkDir, outPath)
cancel2()
if serr != nil {
a.failScan(claim.ScanJobID, serr.Error())
return
}
rptCtx, cancel3 := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel3()
if err := a.orch.ReportScanResult(rptCtx, claim.ScanJobID, result); err != nil {
log.Printf("[scan %s] report result error: %v", claim.ScanJobID, err)
return
}
log.Printf("[scan %s] done (%d bytes)", claim.ScanJobID, len(result))
}
func (a *Agent) failScan(id, reason string) {
log.Printf("[scan %s] failed: %s", id, reason)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = a.orch.ReportScanFail(ctx, id, reason)
}
// ── Heartbeat loop ────────────────────────────────────────────────────────────
func (a *Agent) heartbeatLoop(ctx context.Context) {
@@ -267,18 +362,24 @@ func (a *Agent) tryClaimAndRun(ctx context.Context) {
func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) {
log.Printf("[job %s] starting render", job.JobID)
// ── Step 1: Download .aep template ───────────────────────────────────────
// ── Step 1: Fetch + prepare the .aep template ────────────────────────────
// PrepareTemplate downloads the project file; if it's a .zip bundle it is
// extracted and the .aep inside located. Prepared templates are cached by md5
// so repeated renders of the same template skip the download + extraction.
aepPath := ""
if job.AEPDownloadURL != "" && a.cfg.AEPath != "" {
localAEP := filepath.Join(a.cfg.WorkDir, "templates", job.JobID, "template.aep")
dlCtx, dlCancel := context.WithTimeout(ctx, 10*time.Minute)
n, dlErr := runner.DownloadFile(dlCtx, job.AEPDownloadURL, localAEP)
dlCtx, dlCancel := context.WithTimeout(ctx, 15*time.Minute)
p, prepErr := runner.PrepareTemplate(dlCtx, job.AEPDownloadURL, job.IsBundle, job.BundleMD5, a.cfg.WorkDir, job.JobID)
dlCancel()
if dlErr != nil {
log.Printf("[job %s] AEP download failed (%v) — falling back to mock", job.JobID, dlErr)
if prepErr != nil {
log.Printf("[job %s] template prepare failed (%v) — falling back to mock", job.JobID, prepErr)
} else {
log.Printf("[job %s] AEP downloaded (%d bytes) → %s", job.JobID, n, localAEP)
aepPath = localAEP
kind := "aep"
if job.IsBundle {
kind = "bundle"
}
log.Printf("[job %s] template ready (%s) → %s", job.JobID, kind, p)
aepPath = p
}
}
+68 -2
View File
@@ -148,9 +148,15 @@ type ClaimedJob struct {
FrameRate int `json:"frame_rate"`
HasMusic bool `json:"has_music"`
HasVoiceover bool `json:"has_voiceover"`
// AEPDownloadURL is a presigned MinIO GET URL for the .aep template file.
// Empty when the template has not been uploaded yet — triggers mock render.
// AEPDownloadURL is a presigned MinIO GET URL for the .aep template file
// (or .zip bundle). Empty when the template has not been uploaded yet — triggers mock render.
AEPDownloadURL string `json:"aep_download_url,omitempty"`
// IsBundle is true when AEPDownloadURL points to a .zip bundle (.aep + footage/fonts)
// that must be extracted before rendering.
IsBundle bool `json:"is_bundle,omitempty"`
// BundleMD5 identifies the bundle content; used as a local cache key so repeated
// renders of the same template download + extract it only once.
BundleMD5 string `json:"bundle_md5,omitempty"`
}
// OutputUploadURLResponse is returned by GetOutputUploadURL.
@@ -240,6 +246,66 @@ func (c *Client) ClaimJob(ctx context.Context, nodeID, region string) (*ClaimedJ
return &job, nil
}
// ScanClaim is returned when an AE scan job is claimed.
type ScanClaim struct {
ScanJobID string `json:"scan_job_id"`
ProjectID string `json:"project_id"`
AEPDownloadURL string `json:"aep_download_url"`
IsBundle bool `json:"is_bundle"`
BundleMD5 string `json:"bundle_md5"`
}
// ClaimScan atomically claims the next queued AE scan job.
// Returns (nil, nil) when nothing is queued (204 No Content).
func (c *Client) ClaimScan(ctx context.Context, nodeID, region string) (*ScanClaim, error) {
resp, err := c.do(ctx, http.MethodPost, "/v1/internal/scan/claim",
ClaimJobRequest{NodeID: nodeID, Region: region})
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent {
return nil, nil
}
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("scan claim: HTTP %d", resp.StatusCode)
}
var sc ScanClaim
if err := json.NewDecoder(resp.Body).Decode(&sc); err != nil {
return nil, fmt.Errorf("scan claim decode: %w", err)
}
return &sc, nil
}
// ReportScanResult posts the raw ScanResult JSON produced by scan.jsx.
func (c *Client) ReportScanResult(ctx context.Context, scanJobID string, resultJSON []byte) error {
// json.RawMessage marshals to its raw bytes, so the body is the JSON verbatim.
resp, err := c.do(ctx, http.MethodPost,
fmt.Sprintf("/v1/internal/scan/%s/result", scanJobID), json.RawMessage(resultJSON))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("scan result: HTTP %d", resp.StatusCode)
}
return nil
}
// ReportScanFail marks a scan job as failed.
func (c *Client) ReportScanFail(ctx context.Context, scanJobID, reason string) error {
resp, err := c.do(ctx, http.MethodPost,
fmt.Sprintf("/v1/internal/scan/%s/fail", scanJobID), FailRequest{Reason: reason})
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("scan fail: HTTP %d", resp.StatusCode)
}
return nil
}
// UpdatePreview sends a base64-encoded preview frame to the orchestrator.
// Errors are non-fatal — the UI simply won't update the preview image.
func (c *Client) UpdatePreview(ctx context.Context, jobID, imageB64 string) error {
@@ -4,6 +4,7 @@ package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
)
@@ -30,6 +31,11 @@ type Config struct {
// Leave empty to use mock rendering (for development / testing without AE).
AEPath string
// AfterFxPath is the full path to afterfx.exe (the AE app, used to run the
// template scanner script). Defaults to afterfx.exe alongside aerender.exe.
// Leave AEPath empty too to disable scanning (dev/mock).
AfterFxPath string
// WorkDir is the scratch directory for render temp files and AE project copies.
WorkDir string
@@ -59,6 +65,7 @@ func Load() (*Config, error) {
NodeHMACSecret: getEnv("NODE_HMAC_SECRET", "node-secret-change-me"),
Region: getEnv("NODE_REGION", ""),
AEPath: getEnv("AE_PATH", ""),
AfterFxPath: getEnv("AFTERFX_PATH", ""),
WorkDir: getEnv("WORK_DIR", os.TempDir()),
AgentVersion: getEnv("AGENT_VERSION", "0.1.0"),
AEVersion: getEnv("AE_VERSION", "2024"),
@@ -69,6 +76,10 @@ func Load() (*Config, error) {
if c.NodeID == "" {
return nil, fmt.Errorf("NODE_ID environment variable is required")
}
// Derive afterfx.exe next to aerender.exe when not explicitly set.
if c.AfterFxPath == "" && c.AEPath != "" {
c.AfterFxPath = filepath.Join(filepath.Dir(c.AEPath), "afterfx.exe")
}
return c, nil
}
@@ -0,0 +1,204 @@
// bundle.go prepares an After Effects template for rendering. A template may be a
// plain .aep/.aepx file, or a .zip bundle containing the .aep plus its footage and
// fonts. Bundles are extracted so aerender can resolve relative footage paths.
//
// Prepared templates are cached on disk keyed by the bundle's MD5, so when many
// renders use the same template the node downloads + extracts it only once.
package runner
import (
"archive/zip"
"context"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
// PrepareTemplate ensures the template referenced by url is available locally and
// returns the absolute path to the .aep/.aepx file aerender should open.
//
// - isBundle=false: url is a raw project file; it is downloaded as-is.
// - isBundle=true: url is a .zip; it is downloaded and extracted, then the .aep
// inside is located.
//
// md5 (when non-empty) is the cache key: a prepared template with the same md5 is
// reused without re-downloading. baseDir is the agent work dir; jobID is the
// fallback cache key when md5 is empty.
func PrepareTemplate(ctx context.Context, url string, isBundle bool, md5, baseDir, jobID string) (string, error) {
cacheKey := md5
if cacheKey == "" {
cacheKey = jobID // no md5 → per-job, no reuse
}
cacheDir := filepath.Join(baseDir, "templates", "cache", sanitize(cacheKey))
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return "", fmt.Errorf("mkdir cache: %w", err)
}
if !isBundle {
aepPath := filepath.Join(cacheDir, "template.aep")
if fileExists(aepPath) {
return aepPath, nil // cache hit
}
if _, err := DownloadFile(ctx, url, aepPath); err != nil {
return "", fmt.Errorf("download aep: %w", err)
}
return aepPath, nil
}
// Bundle: the resolved .aep path is remembered in a marker so cache hits skip
// both download and extraction.
marker := filepath.Join(cacheDir, "aep_path.txt")
if b, err := os.ReadFile(marker); err == nil {
if p := strings.TrimSpace(string(b)); p != "" && fileExists(p) {
return p, nil // cache hit
}
}
zipPath := filepath.Join(cacheDir, "bundle.zip")
if _, err := DownloadFile(ctx, url, zipPath); err != nil {
return "", fmt.Errorf("download bundle: %w", err)
}
extractDir := filepath.Join(cacheDir, "extracted")
if err := os.RemoveAll(extractDir); err != nil {
return "", fmt.Errorf("clean extract dir: %w", err)
}
if err := ExtractZip(zipPath, extractDir); err != nil {
return "", fmt.Errorf("extract bundle: %w", err)
}
aepPath, err := FindAEP(extractDir)
if err != nil {
return "", err
}
_ = os.WriteFile(marker, []byte(aepPath), 0o644)
_ = os.Remove(zipPath) // free space — the extracted tree is what we render from
return aepPath, nil
}
// ExtractZip unpacks the zip at zipPath into destDir, guarding against path
// traversal ("zip slip"). Directories and parents are created as needed.
func ExtractZip(zipPath, destDir string) error {
zr, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("open zip: %w", err)
}
defer zr.Close()
if err := os.MkdirAll(destDir, 0o755); err != nil {
return err
}
destAbs, err := filepath.Abs(destDir)
if err != nil {
return err
}
for _, f := range zr.File {
// Clean and confine the target path to destDir.
target := filepath.Join(destDir, f.Name) // #nosec G305 — validated below
targetAbs, aerr := filepath.Abs(target)
if aerr != nil {
return aerr
}
if targetAbs != destAbs && !strings.HasPrefix(targetAbs, destAbs+string(os.PathSeparator)) {
return fmt.Errorf("illegal path in zip: %q", f.Name)
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(targetAbs, 0o755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(targetAbs), 0o755); err != nil {
return err
}
if err := extractOne(f, targetAbs); err != nil {
return err
}
}
return nil
}
func extractOne(f *zip.File, target string) error {
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer out.Close()
// #nosec G110 — template bundles are admin-uploaded, not untrusted user input.
if _, err := io.Copy(out, rc); err != nil {
return err
}
return nil
}
// FindAEP locates the After Effects project file within an extracted bundle. It
// prefers the shallowest file (fewest path segments), .aep over .aepx, and ignores
// macOS resource-fork siblings ("._name") and __MACOSX entries.
func FindAEP(root string) (string, error) {
var matches []string
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if info.Name() == "__MACOSX" {
return filepath.SkipDir
}
return nil
}
name := info.Name()
if strings.HasPrefix(name, "._") {
return nil
}
ext := strings.ToLower(filepath.Ext(name))
if ext == ".aep" || ext == ".aepx" {
matches = append(matches, p)
}
return nil
})
if err != nil {
return "", fmt.Errorf("walk bundle: %w", err)
}
if len(matches) == 0 {
return "", fmt.Errorf("no .aep file found in bundle")
}
sort.Slice(matches, func(i, j int) bool {
di, dj := strings.Count(matches[i], string(os.PathSeparator)), strings.Count(matches[j], string(os.PathSeparator))
if di != dj {
return di < dj // shallower first
}
ei, ej := strings.ToLower(filepath.Ext(matches[i])), strings.ToLower(filepath.Ext(matches[j]))
if ei != ej {
return ei == ".aep" // .aep before .aepx
}
return matches[i] < matches[j]
})
return matches[0], nil
}
func fileExists(p string) bool {
info, err := os.Stat(p)
return err == nil && !info.IsDir()
}
// sanitize keeps a cache key safe for use as a directory name.
func sanitize(s string) string {
return strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
return r
default:
return '_'
}
}, s)
}
@@ -0,0 +1,117 @@
package runner
import (
"archive/zip"
"os"
"path/filepath"
"strings"
"testing"
)
// writeZip builds a zip at path from the given name→content entries (dirs implied
// by trailing slash). Returns the path.
func writeZip(t *testing.T, path string, entries map[string]string) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatalf("create zip: %v", err)
}
defer f.Close()
zw := zip.NewWriter(f)
for name, content := range entries {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create entry %s: %v", name, err)
}
if _, err := w.Write([]byte(content)); err != nil {
t.Fatalf("zip write %s: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
}
func TestExtractAndFindAEP(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "bundle.zip")
// Realistic bundle: project + footage, plus a macOS resource-fork decoy that
// must NOT be chosen as the project file.
writeZip(t, zipPath, map[string]string{
"MyTemplate/template.aep": "AEP-DATA",
"MyTemplate/footage/clip.mp4": "VIDEO",
"MyTemplate/fonts/Vazir.ttf": "FONT",
"__MACOSX/MyTemplate/._template.aep": "RESOURCE-FORK",
})
dest := filepath.Join(dir, "extracted")
if err := ExtractZip(zipPath, dest); err != nil {
t.Fatalf("ExtractZip: %v", err)
}
// footage + font extracted
if _, err := os.Stat(filepath.Join(dest, "MyTemplate", "footage", "clip.mp4")); err != nil {
t.Errorf("footage not extracted: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "MyTemplate", "fonts", "Vazir.ttf")); err != nil {
t.Errorf("font not extracted: %v", err)
}
aep, err := FindAEP(dest)
if err != nil {
t.Fatalf("FindAEP: %v", err)
}
if filepath.Base(aep) != "template.aep" {
t.Errorf("expected template.aep, got %s", aep)
}
if strings.Contains(aep, "__MACOSX") || strings.Contains(filepath.Base(aep), "._") {
t.Errorf("FindAEP picked a macOS resource-fork decoy: %s", aep)
}
}
func TestFindAEPPrefersShallowAndAepOverAepx(t *testing.T) {
dir := t.TempDir()
// deep .aepx + shallow .aep — shallow .aep must win.
mustWrite(t, filepath.Join(dir, "a", "b", "deep.aepx"), "x")
mustWrite(t, filepath.Join(dir, "root.aep"), "x")
aep, err := FindAEP(dir)
if err != nil {
t.Fatalf("FindAEP: %v", err)
}
if filepath.Base(aep) != "root.aep" {
t.Errorf("expected root.aep (shallow, .aep), got %s", aep)
}
}
func TestFindAEPNoneFound(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "readme.txt"), "no project here")
if _, err := FindAEP(dir); err == nil {
t.Error("expected error when no .aep present, got nil")
}
}
func TestExtractZipRejectsZipSlip(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "evil.zip")
writeZip(t, zipPath, map[string]string{
"../escape.txt": "pwned",
})
dest := filepath.Join(dir, "extracted")
if err := ExtractZip(zipPath, dest); err == nil {
t.Error("expected zip-slip to be rejected, got nil error")
}
if _, err := os.Stat(filepath.Join(dir, "escape.txt")); err == nil {
t.Error("zip-slip wrote a file outside the destination dir")
}
}
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
@@ -111,6 +111,9 @@ func aeRender(ctx context.Context, aePath string, job *Job, outputPath string, o
log.Printf("[ae] running: %s %v", aePath, args)
cmd := exec.CommandContext(ctx, aePath, args...)
// Run from the project's folder so a .zip bundle's relative footage/font paths
// resolve correctly (the .aep sits alongside its assets after extraction).
cmd.Dir = filepath.Dir(job.AEPFilePath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -0,0 +1,67 @@
package runner
import (
"context"
_ "embed"
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
)
//go:embed scan.jsx
var scanScript []byte
// WriteScanScript writes the embedded scanner JSX into workDir and returns its path.
func WriteScanScript(workDir string) (string, error) {
dir := filepath.Join(workDir, "scripts")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
p := filepath.Join(dir, "scan.jsx")
if err := os.WriteFile(p, scanScript, 0o644); err != nil {
return "", err
}
return p, nil
}
// RunScan runs the After Effects template scanner against aepPath and returns the
// JSON the script writes to outPath. afterfxPath must be afterfx.exe (the AE app,
// not aerender.exe — only the app can run ScriptUI/ExtendScript).
//
// afterfx -r runs the script and the script calls app.quit(); we still poll for the
// output file because afterfx can return before the file is flushed.
func RunScan(ctx context.Context, afterfxPath, aepPath, workDir, outPath string) ([]byte, error) {
scriptPath, err := WriteScanScript(workDir)
if err != nil {
return nil, fmt.Errorf("write scan script: %w", err)
}
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return nil, err
}
_ = os.Remove(outPath)
_ = os.Remove(outPath + ".error")
cmd := exec.CommandContext(ctx, afterfxPath, "-r", scriptPath)
cmd.Env = append(os.Environ(), "FR_SCAN_AEP="+aepPath, "FR_SCAN_OUT="+outPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// afterfx may exit non-zero on app.quit() — don't treat the exit code as fatal;
// success is determined by the presence of the output JSON file.
_ = cmd.Run()
for {
if eb, rerr := os.ReadFile(outPath + ".error"); rerr == nil && len(eb) > 0 {
return nil, fmt.Errorf("scan script error: %s", string(eb))
}
if b, rerr := os.ReadFile(outPath); rerr == nil && len(b) > 0 {
return b, nil
}
select {
case <-ctx.Done():
return nil, fmt.Errorf("scan timed out waiting for %s", outPath)
case <-time.After(2 * time.Second):
}
}
}
@@ -0,0 +1,186 @@
/*
* FlatRender — After Effects template SCANNER (read-only / ExtendScript).
*
* Walks an opened .aep project and emits its structure as JSON for the content
* service to import (scenes, frl_/frd_ elements, frd_/frshare colours).
*
* Launch (headless), with the node agent setting env vars first:
* SET FR_SCAN_AEP=C:\work\templates\cache\<md5>\extracted\proj\template.aep
* SET FR_SCAN_OUT=C:\work\scans\<job>\scan.json
* "...\Adobe After Effects <ver>\Support Files\afterfx.exe" -r "...\scan.jsx"
*
* Conventions (mirrors the legacy NewBrain/JSX binder, in reverse):
* comp "frfinal" → final render comp (recorded, not a scene)
* comp "frshare" → shared colours: each layer name = key, its "Source Text" = colour value
* layer frl_* → editable element: TextLayer→Text (font/size/justify/text), else Media/Audio
* layer frd_* → data/direction layer; if its text looks like a colour → per-scene colour zone
* any comp containing frl_/frd_ layers → a scene (key = comp name, duration = comp.duration)
*
* Output shape == content-svc ScanResult:
* { source, render_comp, scenes:[{key,title,scene_type,default_duration_sec,sort,
* elements:[{key,title,type,default_value,font_face,font_size,justify,width,height,video_support,is_hidden,sort}],
* colors:[{element_key,title,attr_value,default_color,sort}] }],
* shared_colors:[{element_key,title,attr_value,default_color,sort}] }
*/
(function () {
function getenv(name) { try { return $.getenv(name); } catch (e) { return null; } }
var aepPath = getenv("FR_SCAN_AEP");
var outPath = getenv("FR_SCAN_OUT") || (Folder.temp.fsName + "/fr_scan.json");
// ── minimal JSON serializer (older AE has no JSON.stringify) ──────────────
function esc(s) {
s = String(s); var out = "", i, c;
for (i = 0; i < s.length; i++) {
c = s.charAt(i);
if (c === '"') out += '\\"';
else if (c === '\\') out += '\\\\';
else if (c === '\n') out += '\\n';
else if (c === '\r') out += '\\r';
else if (c === '\t') out += '\\t';
else if (c < ' ') out += '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4);
else out += c;
}
return out;
}
function jstr(v) {
if (v === null || v === undefined) return "null";
var t = typeof v;
if (t === "number") return isFinite(v) ? String(v) : "null";
if (t === "boolean") return v ? "true" : "false";
if (t === "string") return '"' + esc(v) + '"';
if (v instanceof Array) {
var a = [], i; for (i = 0; i < v.length; i++) a.push(jstr(v[i]));
return "[" + a.join(",") + "]";
}
var props = [], k;
for (k in v) { if (v.hasOwnProperty(k)) props.push('"' + esc(k) + '":' + jstr(v[k])); }
return "{" + props.join(",") + "}";
}
// ── colour helpers ────────────────────────────────────────────────────────
function trim(s) { return String(s).replace(/^\s+|\s+$/g, ""); }
function isColor(s) {
if (!s) return false; s = trim(s);
return /^#?[0-9a-fA-F]{6}$/.test(s) || /^\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}$/.test(s);
}
function normColor(s) {
s = trim(s);
if (/^[0-9a-fA-F]{6}$/.test(s)) return "#" + s; // bare hex → add #
var m = s.match(/^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})$/); // r,g,b → #hex
if (m) {
function h(n) { n = Math.max(0, Math.min(255, parseInt(n, 10))); var x = n.toString(16); return x.length === 1 ? "0" + x : x; }
return "#" + h(m[1]) + h(m[2]) + h(m[3]);
}
return s;
}
function justifyName(j) {
try {
if (j === ParagraphJustification.LEFT_JUSTIFY) return "LEFT_JUSTIFY";
if (j === ParagraphJustification.RIGHT_JUSTIFY) return "RIGHT_JUSTIFY";
if (j === ParagraphJustification.FULL_JUSTIFY) return "FULL_JUSTIFY";
} catch (e) {}
return "CENTER_JUSTIFY";
}
function readText(layer) {
try {
var st = layer.property("Source Text");
if (!st) return null;
var td = st.value; // TextDocument
return { text: td.text, font: td.font, fontSize: Math.round(td.fontSize), justify: justifyName(td.justification) };
} catch (e) { return null; }
}
function pre3(name) { return String(name).substring(0, 3); }
function scanScene(comp) {
var elements = [], colors = [];
for (var i = 1; i <= comp.numLayers; i++) {
var layer = comp.layer(i), name = layer.name, p = pre3(name);
if (p === "frl") {
var el = { key: name, title: name, sort: i };
var txt = readText(layer);
if (txt) {
el.type = "Text"; el.default_value = txt.text;
el.font_face = txt.font; el.font_size = txt.fontSize; el.justify = txt.justify;
} else {
var src = null; try { src = layer.source; } catch (e) {}
if (src && src.hasVideo === false && src.hasAudio === true) {
el.type = "Audio";
} else {
el.type = "Media";
if (src) { try { el.width = src.width; el.height = src.height; } catch (e) {} }
try { el.video_support = !!(src && src.hasVideo); } catch (e) {}
}
}
elements.push(el);
} else if (p === "frd") {
var t2 = readText(layer);
if (t2 && isColor(t2.text)) {
colors.push({ element_key: name, title: name, attr_value: "fill", default_color: normColor(t2.text), sort: i });
} else {
var del = { key: name, title: name, type: "Text", is_hidden: true, sort: i };
if (t2) { del.default_value = t2.text; del.font_face = t2.font; del.font_size = t2.fontSize; }
elements.push(del);
}
}
}
return { elements: elements, colors: colors };
}
function compHasEditable(comp) {
for (var k = 1; k <= comp.numLayers; k++) {
var pp = pre3(comp.layer(k).name);
if (pp === "frl" || pp === "frd") return true;
}
return false;
}
function run() {
if (aepPath) app.open(new File(aepPath));
var proj = app.project;
var result = { source: "ae-jsx", render_comp: null, scenes: [], shared_colors: [] };
for (var i = 1; i <= proj.numItems; i++) {
var item = proj.item(i);
if (!(item instanceof CompItem)) continue;
var nm = item.name;
if (nm === "frfinal") { result.render_comp = "frfinal"; continue; }
if (nm === "frshare") {
for (var j = 1; j <= item.numLayers; j++) {
var cl = item.layer(j), ct = readText(cl);
result.shared_colors.push({
element_key: cl.name, title: cl.name, attr_value: "fill",
default_color: (ct && isColor(ct.text)) ? normColor(ct.text) : "#000000", sort: j
});
}
continue;
}
if (!compHasEditable(item)) continue;
var s = scanScene(item);
result.scenes.push({
key: nm, title: nm, scene_type: "Normal",
default_duration_sec: Math.round(item.duration * 100) / 100,
sort: result.scenes.length, elements: s.elements, colors: s.colors
});
}
var out = new File(outPath);
out.encoding = "UTF-8";
out.open("w");
out.write(jstr(result));
out.close();
}
try {
run();
} catch (e) {
try { var ef = new File(outPath + ".error"); ef.open("w"); ef.write("scan error: " + e.toString()); ef.close(); } catch (e2) {}
}
// Quit AE so the headless afterfx process returns (set FR_SCAN_QUIT=0 to keep open while debugging).
try { if (getenv("FR_SCAN_QUIT") !== "0") app.quit(); } catch (e) {}
})();