// Package aep is a minimal, stdlib-only reader for After Effects project (.aep) // files. AEP is a RIFX container (big-endian RIFF). This reader does NOT fully // decode the project — it walks the chunk tree to extract composition names and // (best-effort) durations, which is enough for a headless "quick scan" that // scaffolds scenes without After Effects. Full fidelity (layers, colours, fonts) // requires the AE-JSX scanner running on a node. package aep import ( "encoding/binary" "errors" "strings" ) // Comp is a composition discovered in the project. type Comp struct { Name string 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. // // A composition is an "Item" LIST that contains a "cdta" (composition data) // chunk; its name is the Item's "Utf8" chunk. Folders and footage items lack // "cdta" and are skipped. This rule is robust across AE versions because only // comps carry cdta. func ParseComps(data []byte) ([]Comp, error) { if len(data) < 12 || string(data[0:4]) != "RIFX" { return nil, errors.New("not a RIFX/.aep file") } // data[4:8] = file size (BE), data[8:12] = form type ("Egg!"). Body follows. var comps []Comp seen := map[string]bool{} walk(data[12:], &comps, seen) 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) { 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 == "LIST" && size >= 4 { listType := string(buf[ds : ds+4]) body := buf[ds+4 : de] if listType == "Item" { handleItem(body, comps, seen) } walk(body, comps, seen) // recurse (folders hold nested Item LISTs) } adv := 8 + size if size%2 == 1 { adv++ // chunks are word-aligned } off += adv } } // handleItem inspects the DIRECT children of an "Item" LIST: a "cdta" marks it as // a composition, the first "Utf8" is its name. 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) { 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 } switch { case id == "cdta": hasCdta = true cdta = body[ds:de] case id == "Utf8": if name == "" { 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 if size%2 == 1 { adv++ } off += adv } if hasCdta && name != "" && !seen[name] { seen[name] = true *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 reads the comp duration (in seconds) from the cdta chunk. // // cdta layout (verified empirically against AE 2024/2026 project files): // // off 4 u32 framerate divisor // off 8 u32 framerate dividend == comp time base (ticks/sec) // off 44 u32 duration numerator (in time-base ticks) // off 48 u32 duration divisor (== time base) // // duration_seconds = numerator / time_base. The duration's byte offset shifts by // AE version (44 on recent builds, 40 on older ones documented by the boltframe // reference parser), so rather than hard-code one offset we accept whichever // places the time base immediately *after* the numerator — that self-selects the // correct field and rejects garbage. Returns 0 (unknown) on any doubt; the // importer treats 0 as "leave duration unset". func durationFromCdta(cdta []byte) float64 { if len(cdta) < 52 { return 0 } frDivisor := binary.BigEndian.Uint32(cdta[4:8]) timeBase := binary.BigEndian.Uint32(cdta[8:12]) // framerate dividend if frDivisor == 0 || timeBase == 0 { return 0 } if fps := float64(timeBase) / float64(frDivisor); fps < 1 || fps > 240 { return 0 // not a comp cdta we recognise } for _, off := range []int{44, 40} { // recent layout first, then older num := binary.BigEndian.Uint32(cdta[off : off+4]) div := binary.BigEndian.Uint32(cdta[off+4 : off+8]) if num == 0 || div != timeBase { continue // divisor must equal the time base to be the duration field } sec := float64(num) / float64(timeBase) if sec <= 0 || sec > 36000 { // > 10h is nonsense continue } return float64(int(sec*100+0.5)) / 100 // round to 2 dp } return 0 }