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
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:
Binary file not shown.
@@ -424,6 +424,11 @@ func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) {
|
||||
}
|
||||
}
|
||||
|
||||
binds := make([]runner.Binding, 0, len(job.Bindings))
|
||||
for _, b := range job.Bindings {
|
||||
binds = append(binds, runner.Binding{Key: b.Key, Type: b.Type, Value: b.Value})
|
||||
}
|
||||
|
||||
rJob := &runner.Job{
|
||||
JobID: job.JobID,
|
||||
SavedProjectID: job.SavedProjectID,
|
||||
@@ -434,6 +439,8 @@ func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) {
|
||||
HasVoiceover: job.HasVoiceover,
|
||||
AEPFilePath: aepPath,
|
||||
CompName: job.CompName,
|
||||
AfterFxPath: a.cfg.AfterFxPath,
|
||||
Bindings: binds,
|
||||
}
|
||||
|
||||
onProgress := func(ctx context.Context, pct int, msg string) error {
|
||||
|
||||
@@ -164,6 +164,16 @@ type ClaimedJob struct {
|
||||
// CompName is the AE composition to render (-comp), e.g. "frfinal". Empty → the
|
||||
// node falls back to the project's render queue (-rqindex 1).
|
||||
CompName string `json:"comp_name,omitempty"`
|
||||
// Bindings are the user's edited input values to write into the AE project before
|
||||
// rendering (render binder). Key = AE layer/footage name (frl_c{n}{t|m}{i}).
|
||||
Bindings []RenderBinding `json:"bindings,omitempty"`
|
||||
}
|
||||
|
||||
// RenderBinding is one input value to write into the AE project before render.
|
||||
type RenderBinding struct {
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// OutputUploadURLResponse is returned by GetOutputUploadURL.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,18 @@ type Job struct {
|
||||
// CompName is the composition to render (-comp), e.g. "frfinal". When empty the
|
||||
// node renders the project's render queue (-rqindex 1) instead.
|
||||
CompName string
|
||||
// AfterFxPath is afterfx.exe (the AE app, scriptable) — used by the binder to write
|
||||
// input values into the project before rendering. Render itself uses aerender.exe.
|
||||
AfterFxPath string
|
||||
// Bindings are the user's edited input values to write into the .aep before render.
|
||||
Bindings []Binding
|
||||
}
|
||||
|
||||
// Binding is one input value written into the AE project before render.
|
||||
type Binding struct {
|
||||
Key string // AE layer/footage name, e.g. frl_c1t1 / frl_c1m1
|
||||
Type string // content element type (Text, Media, …)
|
||||
Value string // text content, or a media URL
|
||||
}
|
||||
|
||||
// Run executes the render job, calling onProgress and onPreview as it advances.
|
||||
@@ -58,6 +70,20 @@ func Run(ctx context.Context, aePath, workDir string, job *Job, onProgress Progr
|
||||
}
|
||||
return mockRender(ctx, job, outputPath, onProgress, onPreview)
|
||||
}
|
||||
|
||||
// Render binder: write the user's edited input values into the project before
|
||||
// rendering so the MP4 reflects their text/media. Non-fatal — on failure we render
|
||||
// the template defaults rather than failing the job.
|
||||
if len(job.Bindings) > 0 && job.AfterFxPath != "" {
|
||||
_ = onProgress(ctx, 6, "Applying your edits…")
|
||||
bound, berr := RunBinder(ctx, job, workDir)
|
||||
if berr != nil {
|
||||
log.Printf("[job %s] binder failed (%v) — rendering template defaults", job.JobID, berr)
|
||||
} else {
|
||||
job.AEPFilePath = bound // render the bound project with the user's values
|
||||
}
|
||||
}
|
||||
|
||||
return aeRender(ctx, aePath, job, outputPath, onProgress, onPreview)
|
||||
}
|
||||
|
||||
|
||||
@@ -646,6 +646,31 @@ func (s *Store) GetTemplateCompName(ctx context.Context, originalProjectID uuid.
|
||||
return *comp, nil
|
||||
}
|
||||
|
||||
// GetRenderBindings returns the user's edited input values for a saved project so the
|
||||
// node can write them into the AE project before rendering (the render binder). Only
|
||||
// inputs with a non-empty value are returned (defaults are already in the template).
|
||||
func (s *Store) GetRenderBindings(ctx context.Context, savedProjectID uuid.UUID) ([]models.RenderBinding, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT c.key, c.type, COALESCE(c.value, '')
|
||||
FROM studio.saved_scene_contents c
|
||||
JOIN studio.saved_scenes s ON s.id = c.saved_scene_id
|
||||
WHERE s.saved_project_id = $1 AND c.value IS NOT NULL AND c.value <> ''`,
|
||||
savedProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.RenderBinding
|
||||
for rows.Next() {
|
||||
var b models.RenderBinding
|
||||
if err := rows.Scan(&b.Key, &b.Type, &b.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateExportForJob(ctx context.Context, jobID uuid.UUID) (*models.Export, error) {
|
||||
// Look up the job to get tenant/user/project context
|
||||
job, err := s.getJobByIDInternal(ctx, jobID)
|
||||
|
||||
@@ -292,6 +292,10 @@ func (h *InternalHandler) Claim(c *gin.Context) {
|
||||
// Composition to render (-comp). Non-fatal: empty → node uses the render queue.
|
||||
compName, _ := h.store.GetTemplateCompName(c.Request.Context(), job.OriginalProjectID)
|
||||
|
||||
// User's edited input values → the node writes them into the AE project before
|
||||
// rendering (render binder). Non-fatal: empty → renders template defaults.
|
||||
bindings, _ := h.store.GetRenderBindings(c.Request.Context(), job.SavedProjectID)
|
||||
|
||||
c.JSON(http.StatusOK, models.ClaimedJob{
|
||||
JobID: job.ID,
|
||||
SavedProjectID: job.SavedProjectID,
|
||||
@@ -304,6 +308,7 @@ func (h *InternalHandler) Claim(c *gin.Context) {
|
||||
IsBundle: isBundle,
|
||||
BundleMD5: bundleMD5,
|
||||
CompName: compName,
|
||||
Bindings: bindings,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -432,6 +432,17 @@ type ClaimedJob struct {
|
||||
// template's render_aep_comp (e.g. "frfinal"). Empty → node falls back to the
|
||||
// project's render queue.
|
||||
CompName string `json:"comp_name,omitempty"`
|
||||
// Bindings are the user's edited input values to write into the AE project before
|
||||
// rendering (the render binder). Key = AE layer/footage name (frl_c{n}{t|m}{i}).
|
||||
Bindings []RenderBinding `json:"bindings,omitempty"`
|
||||
}
|
||||
|
||||
// RenderBinding is one input value the node writes into the AE project before render:
|
||||
// a Text layer's source text, or a Media footage replacement.
|
||||
type RenderBinding struct {
|
||||
Key string `json:"key"` // AE layer/footage name, e.g. frl_c1t1 / frl_c1m1
|
||||
Type string `json:"type"` // content element type (Text, Media, …)
|
||||
Value string `json:"value"` // text content, or a media URL
|
||||
}
|
||||
|
||||
// OutputUploadURLResponse is returned by POST .../output-upload-url.
|
||||
|
||||
Reference in New Issue
Block a user