diff --git a/node-agent.exe~ b/node-agent.exe~ deleted file mode 100644 index 227c001..0000000 Binary files a/node-agent.exe~ and /dev/null differ diff --git a/services/node-agent/cmd/agent/main.go b/services/node-agent/cmd/agent/main.go index 3f3b304..3c22638 100644 --- a/services/node-agent/cmd/agent/main.go +++ b/services/node-agent/cmd/agent/main.go @@ -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 { diff --git a/services/node-agent/internal/client/client.go b/services/node-agent/internal/client/client.go index e934c52..0400c98 100644 --- a/services/node-agent/internal/client/client.go +++ b/services/node-agent/internal/client/client.go @@ -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. diff --git a/services/node-agent/internal/runner/binder.go b/services/node-agent/internal/runner/binder.go new file mode 100644 index 0000000..966b78c --- /dev/null +++ b/services/node-agent/internal/runner/binder.go @@ -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() + } + } +} diff --git a/services/node-agent/internal/runner/runner.go b/services/node-agent/internal/runner/runner.go index efdc26c..2e2f792 100644 --- a/services/node-agent/internal/runner/runner.go +++ b/services/node-agent/internal/runner/runner.go @@ -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) } diff --git a/services/render/internal/db/db.go b/services/render/internal/db/db.go index d41b373..9907090 100644 --- a/services/render/internal/db/db.go +++ b/services/render/internal/db/db.go @@ -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) diff --git a/services/render/internal/handlers/internal.go b/services/render/internal/handlers/internal.go index 0761e4e..21cbb36 100644 --- a/services/render/internal/handlers/internal.go +++ b/services/render/internal/handlers/internal.go @@ -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, }) } diff --git a/services/render/internal/models/models.go b/services/render/internal/models/models.go index 3e8c15f..46761af 100644 --- a/services/render/internal/models/models.go +++ b/services/render/internal/models/models.go @@ -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.