package runner import ( "context" _ "embed" "encoding/json" "fmt" "io" "log" "net/http" "os" "os/exec" "path/filepath" "strings" "time" ) //go:embed bind.jsx var bindScript []byte func isMediaBinding(t string) bool { switch strings.ToLower(strings.TrimSpace(t)) { case "media", "image", "video": return true case "audio", "voiceover": return true } return false } func bindMediaType(t string) string { switch strings.ToLower(strings.TrimSpace(t)) { case "audio", "voiceover": return "audio" default: return "media" } } func mediaExt(url string) string { u := url if i := strings.IndexAny(u, "?#"); i >= 0 { u = u[:i] } ext := filepath.Ext(u) if ext == "" || len(ext) > 6 { return ".bin" } return ext } func downloadFile(ctx context.Context, url, dst string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("download %s: HTTP %d", url, resp.StatusCode) } f, err := os.Create(dst) if err != nil { return err } defer f.Close() _, err = io.Copy(f, resp.Body) return err } // ── bind-spec (matches bind.jsx) ────────────────────────────────────────────── type bindLayerSpec struct { Key string `json:"key"` Type string `json:"type"` // "text" | "media" | "audio" Value string `json:"value"` } type bindSpec struct { Comp string `json:"comp,omitempty"` Fps int `json:"fps,omitempty"` Layers []bindLayerSpec `json:"layers"` } // RunBinder writes the user's edited input values into the template (text layers' // source text, media footage replacement) via bind.jsx, saving a "bound" .aep that // aerender then renders. Returns the path to the bound .aep. Uses afterfx.exe. // // Media values are URLs; they are downloaded locally first (the JSX imports them by // path). Text values are passed through the JSON bind-spec (json-escaped, never eval'd // as code — bind.jsx eval()s valid JSON only). func RunBinder(ctx context.Context, job *Job, workDir string) (string, error) { if job.AEPFilePath == "" { return "", fmt.Errorf("binder: empty aep path") } if job.AfterFxPath == "" { return "", fmt.Errorf("binder: empty afterfx path") } dir := filepath.Join(workDir, "bind", job.JobID) if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } spec := bindSpec{Comp: job.CompName, Fps: job.FrameRate, Layers: []bindLayerSpec{}} for i, b := range job.Bindings { key := strings.TrimSpace(b.Key) if key == "" { continue } if isMediaBinding(b.Type) { if strings.TrimSpace(b.Value) == "" { continue } mp := filepath.Join(dir, fmt.Sprintf("m%d%s", i, mediaExt(b.Value))) if err := downloadFile(ctx, b.Value, mp); err != nil { log.Printf("[bind] media download failed for %s: %v", key, err) continue } spec.Layers = append(spec.Layers, bindLayerSpec{Key: key, Type: bindMediaType(b.Type), Value: mp}) } else { spec.Layers = append(spec.Layers, bindLayerSpec{Key: key, Type: "text", Value: b.Value}) } } specBytes, err := json.Marshal(spec) if err != nil { return "", err } specPath := filepath.Join(dir, "spec.json") if err := os.WriteFile(specPath, specBytes, 0o644); err != nil { return "", err } // Save the bound .aep next to the original so its relative footage/fonts resolve. savePath := filepath.Join(filepath.Dir(job.AEPFilePath), "bound.aep") donePath := savePath + ".done" errPath := savePath + ".error" _ = os.Remove(donePath) _ = os.Remove(errPath) _ = os.Remove(savePath) scriptPath := filepath.Join(workDir, "scripts", "bind.jsx") if err := os.MkdirAll(filepath.Dir(scriptPath), 0o755); err != nil { return "", err } if err := os.WriteFile(scriptPath, bindScript, 0o644); err != nil { return "", err } PrepareFreshAE() // kill stale AE + clear crash markers before launch bctx, cancel := context.WithTimeout(ctx, 12*time.Minute) defer cancel() // AE opens the project itself (bind.jsx app.open); launch with -r script. cmd := exec.CommandContext(bctx, job.AfterFxPath, "-r", scriptPath) cmd.Env = append(os.Environ(), "FR_BIND_AEP="+job.AEPFilePath, "FR_BIND_SPEC="+specPath, "FR_BIND_SAVE="+savePath, ) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { return "", fmt.Errorf("start afterfx: %w", err) } exited := make(chan error, 1) go func() { exited <- cmd.Wait() }() for { if eb, rerr := os.ReadFile(errPath); rerr == nil && len(eb) > 0 { _ = cmd.Process.Kill() return "", fmt.Errorf("binder script error: %s", string(eb)) } if st, serr := os.Stat(savePath); serr == nil && st.Size() > 0 { if _, derr := os.Stat(donePath); derr == nil { _ = cmd.Process.Kill() log.Printf("[bind] bound %d input(s) → %s", len(spec.Layers), savePath) return savePath, nil } } select { case <-exited: if st, serr := os.Stat(savePath); serr == nil && st.Size() > 0 { return savePath, nil } return "", fmt.Errorf("binder exited without producing %s", filepath.Base(savePath)) case <-time.After(2 * time.Second): case <-bctx.Done(): _ = cmd.Process.Kill() return "", bctx.Err() } } }