feat(scan): binary FIX scan reads frl_/frd_ names from .aep (no AE, never hangs)
Build backend images / build content-svc (push) Failing after 15s
Build backend images / build file-svc (push) Failing after 1m51s
Build backend images / build gateway (push) Failing after 51s
Build backend images / build identity-svc (push) Failing after 57s
Build backend images / build notification-svc (push) Failing after 52s
Build backend images / build render-svc (push) Failing after 56s
Build backend images / build studio-svc (push) Failing after 57s
Build backend images / build content-svc (push) Failing after 15s
Build backend images / build file-svc (push) Failing after 1m51s
Build backend images / build gateway (push) Failing after 51s
Build backend images / build identity-svc (push) Failing after 57s
Build backend images / build notification-svc (push) Failing after 52s
Build backend images / build render-svc (push) Failing after 56s
Build backend images / build studio-svc (push) Failing after 57s
Root cause of 'stuck on AE': heavy expression-driven projects take >10min for AE to open, exceeding the scan timeout → job dies → admin UI stuck 'scanning'. Fix: extend the stdlib .aep RIFX parser to collect every Utf8 name (ParseNames), since FIX media placeholders are renamed footage ITEMS (frl_c1m1), not layers, and text are layer names (frl_c1t1) — both are Utf8 chunks. QuickScan now branches on ?mode= (or auto-detects frl_ names) and scaffolds FIX scenes/elements + frd_*color slots directly from the binary. Verified on the real final.aep that timed out in AE: 1 scene, 6 elements, 4 colors in 0.5s vs 10-min AE timeout. Admin 'Quick scan (no AE)' is now the recommended path and passes the project mode. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,8 @@ import (
|
||||
// Comp is a composition discovered in the project.
|
||||
type Comp struct {
|
||||
Name string
|
||||
DurationSec float64 // 0 when it could not be derived
|
||||
DurationSec float64 // 0 when it could not be derived
|
||||
Layers []string // layer names in file order (used by FIX-mode scanning)
|
||||
}
|
||||
|
||||
// ParseComps walks a RIFX (.aep) buffer and returns its compositions.
|
||||
@@ -35,6 +36,48 @@ func ParseComps(data []byte) ([]Comp, error) {
|
||||
return comps, nil
|
||||
}
|
||||
|
||||
// ParseNames returns every non-empty "Utf8" string in the project tree — layer
|
||||
// names, item/footage names, comp names (and incidentally some text/expression
|
||||
// strings). FIX-mode scanning filters this for the frl_/frd_ naming convention,
|
||||
// which is reliable because those names are globally unique and distinctive.
|
||||
//
|
||||
// This is needed because FIX media placeholders are renamed *footage items*
|
||||
// (e.g. frl_c1m1), not layers, so a layers-only walk misses them. Order is file
|
||||
// order; duplicates are preserved (callers dedup).
|
||||
func ParseNames(data []byte) ([]string, error) {
|
||||
if len(data) < 12 || string(data[0:4]) != "RIFX" {
|
||||
return nil, errors.New("not a RIFX/.aep file")
|
||||
}
|
||||
var names []string
|
||||
collectUtf8(data[12:], &names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func collectUtf8(buf []byte, out *[]string) {
|
||||
off := 0
|
||||
for off+8 <= len(buf) {
|
||||
id := string(buf[off : off+4])
|
||||
size := int(binary.BigEndian.Uint32(buf[off+4 : off+8]))
|
||||
ds := off + 8
|
||||
de := ds + size
|
||||
if size < 0 || de > len(buf) {
|
||||
break
|
||||
}
|
||||
if id == "Utf8" {
|
||||
if s := clean(buf[ds:de]); s != "" {
|
||||
*out = append(*out, s)
|
||||
}
|
||||
} else if id == "LIST" && size >= 4 {
|
||||
collectUtf8(buf[ds+4:de], out)
|
||||
}
|
||||
adv := 8 + size
|
||||
if size%2 == 1 {
|
||||
adv++
|
||||
}
|
||||
off += adv
|
||||
}
|
||||
}
|
||||
|
||||
// walk iterates the chunks in buf (a LIST body or the file root), recursing into
|
||||
// every LIST so nested items inside folders are found.
|
||||
func walk(buf []byte, comps *[]Comp, seen map[string]bool) {
|
||||
@@ -69,6 +112,7 @@ func handleItem(body []byte, comps *[]Comp, seen map[string]bool) {
|
||||
var name string
|
||||
var cdta []byte
|
||||
hasCdta := false
|
||||
var layers []string
|
||||
|
||||
off := 0
|
||||
for off+8 <= len(body) {
|
||||
@@ -79,13 +123,20 @@ func handleItem(body []byte, comps *[]Comp, seen map[string]bool) {
|
||||
if size < 0 || de > len(body) {
|
||||
break
|
||||
}
|
||||
switch id {
|
||||
case "cdta":
|
||||
switch {
|
||||
case id == "cdta":
|
||||
hasCdta = true
|
||||
cdta = body[ds:de]
|
||||
case "Utf8":
|
||||
case id == "Utf8":
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(strings.TrimRight(string(body[ds:de]), "\x00"))
|
||||
name = clean(body[ds:de])
|
||||
}
|
||||
case id == "LIST" && size >= 4 && string(body[ds:ds+4]) == "Layr":
|
||||
// A composition's layers are nested "Layr" LISTs; the layer's display
|
||||
// name is the first "Utf8" inside it (FIX templates encode scenes in
|
||||
// these names, e.g. frl_c1t1). Empty/unnamed layers are skipped.
|
||||
if ln := layerName(body[ds+4 : de]); ln != "" {
|
||||
layers = append(layers, ln)
|
||||
}
|
||||
}
|
||||
adv := 8 + size
|
||||
@@ -97,10 +148,39 @@ func handleItem(body []byte, comps *[]Comp, seen map[string]bool) {
|
||||
|
||||
if hasCdta && name != "" && !seen[name] {
|
||||
seen[name] = true
|
||||
*comps = append(*comps, Comp{Name: name, DurationSec: durationFromCdta(cdta)})
|
||||
*comps = append(*comps, Comp{Name: name, DurationSec: durationFromCdta(cdta), Layers: layers})
|
||||
}
|
||||
}
|
||||
|
||||
// layerName returns the first "Utf8" string among the DIRECT children of a "Layr"
|
||||
// LIST body — that is the layer's display name. It does not recurse, so nested
|
||||
// property/effect names (also stored as Utf8) cannot be mistaken for the name.
|
||||
func layerName(body []byte) string {
|
||||
off := 0
|
||||
for off+8 <= len(body) {
|
||||
id := string(body[off : off+4])
|
||||
size := int(binary.BigEndian.Uint32(body[off+4 : off+8]))
|
||||
ds := off + 8
|
||||
de := ds + size
|
||||
if size < 0 || de > len(body) {
|
||||
break
|
||||
}
|
||||
if id == "Utf8" {
|
||||
return clean(body[ds:de])
|
||||
}
|
||||
adv := 8 + size
|
||||
if size%2 == 1 {
|
||||
adv++
|
||||
}
|
||||
off += adv
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func clean(b []byte) string {
|
||||
return strings.TrimSpace(strings.TrimRight(string(b), "\x00"))
|
||||
}
|
||||
|
||||
// durationFromCdta makes a best-effort attempt to read the comp duration from the
|
||||
// cdta chunk. The cdta layout varies by AE version; we read the frame-duration /
|
||||
// time-scale pair at well-known offsets and fall back to 0 (unknown) on any doubt.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package aep
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDumpRealAEP is a manual harness: set FR_TEST_AEP to a .aep path to print the
|
||||
// comps + layer names the parser extracts. Skipped in normal CI runs.
|
||||
func TestDumpRealAEP(t *testing.T) {
|
||||
path := os.Getenv("FR_TEST_AEP")
|
||||
if path == "" {
|
||||
t.Skip("set FR_TEST_AEP to a .aep path")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
comps, err := ParseComps(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names, _ := ParseNames(data)
|
||||
t.Logf("parsed %d comps, %d names from %s (%d bytes)", len(comps), len(names), path, len(data))
|
||||
for _, c := range comps {
|
||||
t.Logf("COMP %q dur=%.2fs layers=%d", c.Name, c.DurationSec, len(c.Layers))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user