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
+87 -1
View File
@@ -140,11 +140,12 @@ func main() {
// Main loops
var wg sync.WaitGroup
wg.Add(4)
wg.Add(5)
go func() { defer wg.Done(); agent.heartbeatLoop(ctx) }()
go func() { defer wg.Done(); agent.pollLoop(ctx) }()
go func() { defer wg.Done(); agent.fontSyncLoop(ctx) }()
go func() { defer wg.Done(); agent.scanLoop(ctx) }()
go func() { defer wg.Done(); agent.snapshotLoop(ctx) }()
wg.Wait()
log.Printf("shutdown complete")
}
@@ -307,6 +308,91 @@ func (a *Agent) failScan(id, reason string) {
_ = a.orch.ReportScanFail(ctx, id, reason)
}
// ── Snapshot loop ─────────────────────────────────────────────────────────────
// Claims per-scene snapshot jobs, renders a single frame with AE, extracts a PNG
// still and uploads it. Requires the AE app — skipped without AE_PATH.
func (a *Agent) snapshotLoop(ctx context.Context) {
interval := time.Duration(a.cfg.PollIntervalSec) * time.Second
if interval < 5*time.Second {
interval = 5 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.pollSnapshotOnce(ctx)
}
}
}
func (a *Agent) pollSnapshotOnce(ctx context.Context) {
if a.cfg.AEPath == "" {
return // snapshots need the real AE app
}
if a.isBusy() {
return // don't contend with a render/scan for AE
}
claim, err := a.orch.ClaimSnapshot(ctx, a.cfg.NodeID, a.cfg.Region)
if err != nil {
log.Printf("snapshot claim error: %v", err)
return
}
if claim == nil {
return // nothing queued
}
a.setScanning(true) // reuse the scan-busy flag (same AE-exclusive lock)
defer a.setScanning(false)
log.Printf("[snapshot %s] claimed (scene %s)", claim.SnapshotJobID, claim.SceneKey)
prepCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
aepPath, perr := runner.PrepareTemplate(prepCtx, claim.AEPDownloadURL, claim.IsBundle, claim.BundleMD5, a.cfg.WorkDir, "snap-"+claim.SnapshotJobID)
cancel()
if perr != nil {
a.failSnapshot(claim.SnapshotJobID, "prepare template: "+perr.Error())
return
}
workDir := filepath.Join(a.cfg.WorkDir, "snapshots", claim.SnapshotJobID)
renderCtx, cancel2 := context.WithTimeout(ctx, 10*time.Minute)
defer cancel2()
pngPath, rerr := runner.RunSnapshot(renderCtx, a.cfg.AEPath, aepPath, claim.CompName, claim.Frame, workDir)
if rerr != nil {
a.failSnapshot(claim.SnapshotJobID, rerr.Error())
return
}
// Upload the still to the public bucket, then report its permanent URL.
upCtx, cancel3 := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel3()
up, uerr := a.orch.GetSnapshotUploadURL(upCtx, claim.SnapshotJobID)
if uerr != nil {
a.failSnapshot(claim.SnapshotJobID, "upload-url: "+uerr.Error())
return
}
if _, uperr := runner.UploadFile(upCtx, up.UploadURL, pngPath); uperr != nil {
a.failSnapshot(claim.SnapshotJobID, "upload: "+uperr.Error())
return
}
if rperr := a.orch.ReportSnapshotResult(upCtx, claim.SnapshotJobID, up.PublicURL); rperr != nil {
log.Printf("[snapshot %s] report result error: %v", claim.SnapshotJobID, rperr)
return
}
log.Printf("[snapshot %s] done → %s", claim.SnapshotJobID, up.PublicURL)
}
func (a *Agent) failSnapshot(id, reason string) {
log.Printf("[snapshot %s] failed: %s", id, reason)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = a.orch.ReportSnapshotFail(ctx, id, reason)
}
// ── Heartbeat loop ────────────────────────────────────────────────────────────
func (a *Agent) heartbeatLoop(ctx context.Context) {