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