// 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 } // 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 } // 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 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 id { case "cdta": hasCdta = true cdta = body[ds:de] case "Utf8": if name == "" { name = strings.TrimSpace(strings.TrimRight(string(body[ds:de]), "\x00")) } } 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)}) } } // 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. // Returning 0 is safe — the importer treats it as "leave duration unset". func durationFromCdta(cdta []byte) float64 { // cdta encodes time as rational values. Two uint32 BE commonly hold the comp // duration (in frames at the comp's time scale) and the time scale (fps base). // Offsets 0x20 (duration) and 0x24 (scale) are the most consistent across // recent versions; guard heavily and bail to 0 if the numbers look invalid. if len(cdta) < 0x28 { return 0 } durFrames := binary.BigEndian.Uint32(cdta[0x20:0x24]) scale := binary.BigEndian.Uint32(cdta[0x24:0x28]) if scale == 0 || durFrames == 0 || scale > 100000 || durFrames > 100000000 { return 0 } sec := float64(durFrames) / float64(scale) if sec <= 0 || sec > 36000 { // > 10h is nonsense → treat as unknown return 0 } // round to 2 dp return float64(int(sec*100+0.5)) / 100 }