feat(render): node-agent AE snapshot runner (Epic C2) + colour render-binding (Epic B)
Build backend images / build content-svc (push) Failing after 13s
Build backend images / build file-svc (push) Failing after 53s
Build backend images / build gateway (push) Failing after 1m22s
Build backend images / build identity-svc (push) Failing after 19s
Build backend images / build notification-svc (push) Failing after 21s
Build backend images / build render-svc (push) Failing after 20s
Build backend images / build studio-svc (push) Failing after 1m6s

C2 — real-AE scene snapshots on the node:
- node-agent: runner/snapshot.go RunSnapshot (aerender -comp <key> -s f -e f
  → findRenderedOutput → ffmpeg -frames:v 1 PNG); client ClaimSnapshot /
  GetSnapshotUploadURL / ReportSnapshotResult / ReportSnapshotFail; snapshotLoop +
  pollSnapshotOnce mirroring the scan loop (reuses the AE-exclusive lock).
- render-svc: GetSnapshotJobMeta + UploadURL handler presigns a PUT to the
  public-read user-uploads bucket at snapshots/{project}/{scene}.png and returns a
  permanent public_url (not the time-limited export presign); MINIO_UPLOAD_BUCKET +
  MINIO_PUBLIC_URL config + compose env + /snapshot/:id/upload-url route.

Epic B — bind edited colours into the render:
- render-svc GetRenderBindings UNIONs studio.saved_shared_colors +
  saved_scene_colors (type 'color') so the node writes them before render.
- node-agent binder.go routes type:"color" bindings into the bind-spec colors[]
  array that bind.jsx already applies to the frshare colour layers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-11 18:08:43 +03:30
parent 8488acb115
commit 6cf8716d7e
9 changed files with 329 additions and 5 deletions
@@ -341,6 +341,95 @@ func (c *Client) ScanStatus(ctx context.Context, scanJobID string) (string, erro
return out.Status, nil
}
// ── Scene snapshots ─────────────────────────────────────────────────────────
// SnapshotClaim is returned when a per-scene snapshot job is claimed.
type SnapshotClaim struct {
SnapshotJobID string `json:"snapshot_job_id"`
ProjectID string `json:"project_id"`
SceneID string `json:"scene_id"`
SceneKey string `json:"scene_key"`
CompName string `json:"comp_name"`
Frame int `json:"frame"`
AEPDownloadURL string `json:"aep_download_url"`
IsBundle bool `json:"is_bundle"`
BundleMD5 string `json:"bundle_md5"`
}
// SnapshotUploadURLResponse carries the presigned PUT + the permanent public URL.
type SnapshotUploadURLResponse struct {
UploadURL string `json:"upload_url"`
ObjectKey string `json:"object_key"`
PublicURL string `json:"public_url"`
}
// ClaimSnapshot atomically claims the next queued snapshot job (204 → nil,nil).
func (c *Client) ClaimSnapshot(ctx context.Context, nodeID, region string) (*SnapshotClaim, error) {
resp, err := c.do(ctx, http.MethodPost, "/v1/internal/snapshot/claim",
ClaimJobRequest{NodeID: nodeID, Region: region})
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent {
return nil, nil
}
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("snapshot claim: HTTP %d", resp.StatusCode)
}
var sc SnapshotClaim
if err := json.NewDecoder(resp.Body).Decode(&sc); err != nil {
return nil, fmt.Errorf("snapshot claim decode: %w", err)
}
return &sc, nil
}
// GetSnapshotUploadURL asks the orchestrator for a presigned PUT + public URL.
func (c *Client) GetSnapshotUploadURL(ctx context.Context, jobID string) (*SnapshotUploadURLResponse, error) {
resp, err := c.do(ctx, http.MethodPost,
fmt.Sprintf("/v1/internal/snapshot/%s/upload-url", jobID), nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("snapshot upload-url: HTTP %d", resp.StatusCode)
}
var out SnapshotUploadURLResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return &out, nil
}
// ReportSnapshotResult posts the uploaded still's public URL.
func (c *Client) ReportSnapshotResult(ctx context.Context, jobID, imageURL string) error {
resp, err := c.do(ctx, http.MethodPost,
fmt.Sprintf("/v1/internal/snapshot/%s/result", jobID), map[string]string{"image_url": imageURL})
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("snapshot result: HTTP %d", resp.StatusCode)
}
return nil
}
// ReportSnapshotFail marks a snapshot job as failed.
func (c *Client) ReportSnapshotFail(ctx context.Context, jobID, reason string) error {
resp, err := c.do(ctx, http.MethodPost,
fmt.Sprintf("/v1/internal/snapshot/%s/fail", jobID), FailRequest{Reason: reason})
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("snapshot fail: HTTP %d", resp.StatusCode)
}
return nil
}
// UpdatePreview sends a base64-encoded preview frame to the orchestrator.
// Errors are non-fatal — the UI simply won't update the preview image.
func (c *Client) UpdatePreview(ctx context.Context, jobID, imageB64 string) error {
@@ -77,9 +77,14 @@ type bindLayerSpec struct {
Type string `json:"type"` // "text" | "media" | "audio"
Value string `json:"value"`
}
type bindColorSpec struct {
Key string `json:"key"`
Value string `json:"value"`
}
type bindSpec struct {
Comp string `json:"comp,omitempty"`
Fps int `json:"fps,omitempty"`
Colors []bindColorSpec `json:"colors,omitempty"`
Layers []bindLayerSpec `json:"layers"`
}
@@ -109,6 +114,15 @@ func RunBinder(ctx context.Context, job *Job, workDir string) (string, error) {
if key == "" {
continue
}
// Colours bind to frshare/frd_* text layers (hex source text) — bind.jsx
// propagates them via template expressions, not as visible layer content.
if strings.EqualFold(strings.TrimSpace(b.Type), "color") {
if strings.TrimSpace(b.Value) == "" {
continue
}
spec.Colors = append(spec.Colors, bindColorSpec{Key: key, Value: b.Value})
continue
}
if isMediaBinding(b.Type) {
if strings.TrimSpace(b.Value) == "" {
continue
@@ -0,0 +1,69 @@
package runner
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strconv"
)
// RunSnapshot renders a single frame of compName from aepPath and writes a PNG
// still, returning its path. It reuses the render pipeline shape: aerender emits
// the comp's output module (lossless AVI/MOV) for one frame, then ffmpeg extracts
// a single PNG. Requires aerender (aePath) and ffmpeg on the node.
func RunSnapshot(ctx context.Context, aePath, aepPath, compName string, frame int, workDir string) (string, error) {
if aePath == "" {
return "", fmt.Errorf("AE path required for snapshot render")
}
if compName == "" {
return "", fmt.Errorf("comp name required for snapshot render")
}
if err := os.MkdirAll(workDir, 0o755); err != nil {
return "", fmt.Errorf("workdir: %w", err)
}
out := filepath.Join(workDir, "snap.avi")
_ = os.Remove(out)
// -s/-e bound the render to a single frame; aerender writes via the comp's
// output module (cmd.Dir = project folder so relative footage resolves).
args := []string{
"-project", aepPath, "-comp", compName,
"-s", strconv.Itoa(frame), "-e", strconv.Itoa(frame),
"-output", out,
}
log.Printf("[snapshot] aerender %v", args)
cmd := exec.CommandContext(ctx, aePath, args...)
cmd.Dir = filepath.Dir(aepPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("aerender: %w", err)
}
actual := findRenderedOutput(out)
if actual == "" {
return "", fmt.Errorf("aerender produced no output for comp %q", compName)
}
ff := ffmpegPath()
if ff == "" {
return "", fmt.Errorf("ffmpeg not found (set FFMPEG_PATH or place ffmpeg.exe next to the agent)")
}
png := filepath.Join(workDir, "snap.png")
_ = os.Remove(png)
ffArgs := []string{"-y", "-i", actual, "-frames:v", "1", png}
log.Printf("[snapshot] ffmpeg %v", ffArgs)
fc := exec.CommandContext(ctx, ff, ffArgs...)
fc.Stdout = os.Stdout
fc.Stderr = os.Stderr
if err := fc.Run(); err != nil {
return "", fmt.Errorf("ffmpeg still: %w", err)
}
_ = os.Remove(actual) // drop the intermediate render
if st, err := os.Stat(png); err != nil || st.Size() == 0 {
return "", fmt.Errorf("no snapshot image produced")
}
return png, nil
}