Files
flatrender/services/node-agent/internal/runner/bundle.go
T
soroush.asadi 1ff6e494c0
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>
@
2026-06-04 10:39:45 +03:30

205 lines
5.9 KiB
Go

// 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)
}