feat(render B2): render binder writes user edits into AE before render
Build backend images / build content-svc (push) Failing after 52s
Build backend images / build file-svc (push) Failing after 56s
Build backend images / build gateway (push) Failing after 53s
Build backend images / build identity-svc (push) Failing after 1m29s
Build backend images / build notification-svc (push) Failing after 1m38s
Build backend images / build render-svc (push) Failing after 1m53s
Build backend images / build studio-svc (push) Failing after 56s

Edits previously never reached the MP4 (the node rendered template defaults). Now:
- render-svc claim includes the saved input values as bindings (GetRenderBindings →
  saved_scene_contents with non-empty value).
- node-agent: new binder.go emits a JSON bind-spec + downloads media locally, runs the
  pre-existing data-driven bind.jsx via afterfx (sets text layers' Source Text, replaces
  media footage), saves a bound.aep next to the template, then aerender renders THAT.
- 12-min timeout + fresh-AE + done-marker polling (mirrors scan). Non-fatal: on bind
  failure the job still renders template defaults.

Verified binding data flows (edited frl_c1t1/frl_c1t2 → claim bindings). Live MP4
verification needs the updated node-agent.exe re-run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-07 01:22:20 +03:30
parent a69bc62724
commit 47a4ced973
8 changed files with 280 additions and 0 deletions
@@ -0,0 +1,196 @@
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()
}
}
}