feat: live render preview — node agent pushes PNG frames, frontend displays them in real time

render-svc:
- db.UpdateJobPreview(): writes base64 PNG to render_jobs.image_preview_b64
  (only on active jobs; Done/Failed/Cancelled rows ignored)
- POST /v1/internal/render/jobs/:job_id/preview — node agent endpoint
- Route registered under /v1/internal (nodeAuth)

node-agent:
- runner.PreviewFn callback type alongside ProgressFn
- runner.preview.go: GeneratePreviewB64(percent, quality, resolution)
  — pure stdlib (image/png + encoding/base64), no external deps
  — 320×180 dark frame with animated progress bar + colored indicator dots
- mock render: pushes a preview frame at every step (5→95%)
- real AE render: pushes a preview frame every 30s
- client.UpdatePreview(): POST /v1/internal/render/jobs/:job_id/preview
- main.go: onPreview callback wires client.UpdatePreview() into runner.Run()

frontend:
- render-jobs.ts: RenderJobRow.preview_b64 field; read from progress endpoint
- status/route.ts: previewB64 included in JSON response
- RenderModal: aspect-ratio preview pane during polling — shows spinner until
  first frame arrives, then live-updates with each poll (every 3s);
  step label overlaid as badge bottom-right

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-01 09:42:03 +03:30
parent ee421ccc68
commit d7743a6fbe
10 changed files with 204 additions and 15 deletions
+1
View File
@@ -137,6 +137,7 @@ func main() {
internal.POST("/nodes/:node_id/online", internalH.Online)
internal.POST("/nodes/:node_id/cache-update", internalH.CacheUpdate)
internal.POST("/render/jobs/claim", internalH.Claim)
internal.POST("/render/jobs/:job_id/preview", internalH.Preview)
internal.POST("/render/jobs/:job_id/frames", internalH.FrameProgress)
internal.POST("/render/jobs/:job_id/complete", internalH.Complete)
internal.POST("/render/jobs/:job_id/fail", internalH.Fail)
+12
View File
@@ -519,6 +519,18 @@ func (s *Store) ClaimJob(ctx context.Context, nodeID uuid.UUID, region string) (
return s.getJobByIDInternal(ctx, jobID)
}
// UpdateJobPreview stores a base64-encoded preview frame for a running job.
// Called by the node agent every N frames to power the live preview UI.
func (s *Store) UpdateJobPreview(ctx context.Context, jobID uuid.UUID, imageB64 string) error {
_, err := s.pool.Exec(ctx, `
UPDATE render.render_jobs
SET image_preview_b64 = $1, updated_at = NOW()
WHERE id = $2
AND step NOT IN ('Done'::render_step, 'Failed'::render_step, 'Cancelled'::render_step)`,
imageB64, jobID)
return err
}
func (s *Store) CancelJob(ctx context.Context, id, userID uuid.UUID) (bool, error) {
tag, err := s.pool.Exec(ctx, `
UPDATE render.render_jobs
@@ -198,6 +198,29 @@ func (h *InternalHandler) ReplicaReady(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// POST /v1/internal/render/jobs/:job_id/preview
// Node agent pushes a base64-encoded frame image so the frontend can show
// a live preview while the job is rendering.
func (h *InternalHandler) Preview(c *gin.Context) {
jobID, err := uuid.Parse(c.Param("job_id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid job_id"})
return
}
var req struct {
ImageB64 string `json:"image_b64" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()})
return
}
if err := h.store.UpdateJobPreview(c.Request.Context(), jobID, req.ImageB64); err != nil {
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
return
}
c.Status(http.StatusNoContent)
}
// POST /v1/internal/render/jobs/claim
// Node agent calls this to atomically claim the next queued job.
// Returns 204 when there is nothing queued (agent should back off and retry).