@
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
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:
@@ -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) {}
|
||||
})();
|
||||
Reference in New Issue
Block a user