fix(render+studio): dev mock worker (unstick the queue) + lock predefined layers

Render — "stuck in Queued" fix:
- Jobs were created Queued and only a Windows AE node could claim them, so in the
  dev stack (no node) they queued forever.
- New devworker package: in-process mock worker drives Queued jobs through the steps
  with progress + live preview frames → Done. Enabled via RENDER_DEV_WORKER (default
  true in compose; set false in prod where real nodes claim jobs).
- db: DevClaimNextQueued (atomic oldest-queued → Preparing) + UpdateJobStepProgress
- Verified live: a stuck job advanced Preparing→Done in ~10s with frontend polling.

Studio — predefined template structure:
- Projects are always copied from a template; structure is fixed. Users customise
  existing layers, they don't add new ones.
- New studio-config flag ALLOW_ADD_LAYERS (false): StudioToolbar (add text/image/
  video/shape) returns null; SceneEditSidebar "add text layer" button hidden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-05 22:10:05 +03:30
parent 81912cac66
commit 43d0e10543
7 changed files with 233 additions and 11 deletions
@@ -0,0 +1,131 @@
// Package devworker runs an in-process mock render worker so the full render
// flow works in development without a Windows After Effects node.
//
// When enabled (RENDER_DEV_WORKER=true) it polls for Queued jobs, claims the
// oldest, and drives it through the render steps — emitting progress and live
// preview frames exactly like a real node agent would — then marks it Done.
//
// It is NEVER started in production; real render nodes claim jobs over the
// internal API instead.
package devworker
import (
"bytes"
"context"
"encoding/base64"
"image"
"image/color"
"image/draw"
"image/png"
"log"
"time"
"github.com/flatrender/render-svc/internal/db"
"github.com/google/uuid"
)
type Worker struct {
store *db.Store
interval time.Duration
}
func New(store *db.Store) *Worker {
return &Worker{store: store, interval: 2 * time.Second}
}
// Run blocks until ctx is cancelled, processing one job at a time.
func (w *Worker) Run(ctx context.Context) {
log.Printf("[devworker] mock render worker started (poll %s) — NOT for production", w.interval)
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Printf("[devworker] stopped")
return
case <-ticker.C:
w.tick(ctx)
}
}
}
func (w *Worker) tick(ctx context.Context) {
jobID, err := w.store.DevClaimNextQueued(ctx)
if err != nil {
log.Printf("[devworker] claim error: %v", err)
return
}
if jobID == uuid.Nil {
return // queue empty
}
log.Printf("[devworker] claimed job %s — simulating render", jobID)
w.simulate(ctx, jobID)
}
// simulate drives a single job through the render steps with progress + preview.
func (w *Worker) simulate(ctx context.Context, jobID uuid.UUID) {
steps := []struct {
step string
pct int
}{
{"Preparing", 5},
{"TemplateCache", 15},
{"JsxGen", 25},
{"Rendering", 40},
{"Rendering", 60},
{"Rendering", 80},
{"Optimisation", 88},
{"Video", 92},
{"Uploading", 97},
}
for _, s := range steps {
select {
case <-ctx.Done():
return
case <-time.After(1200 * time.Millisecond):
}
if err := w.store.UpdateJobStepProgress(ctx, jobID, s.step, s.pct); err != nil {
// Job was cancelled / deleted mid-flight — stop quietly.
log.Printf("[devworker] job %s no longer updatable (%v) — abandoning", jobID, err)
return
}
_ = w.store.UpdateJobPreview(ctx, jobID, previewB64(s.pct))
log.Printf("[devworker] job %s — %s %d%%", jobID, s.step, s.pct)
}
// Complete (no export — dev renders produce no downloadable artifact).
if _, err := w.store.CompleteJob(ctx, jobID, nil); err != nil {
log.Printf("[devworker] complete job %s failed: %v", jobID, err)
return
}
log.Printf("[devworker] job %s done", jobID)
}
// previewB64 builds a 320×180 PNG with a progress bar — same idea as the node
// agent's GeneratePreviewB64, kept local so render-svc has no node-agent dep.
func previewB64(pct int) string {
const w, h = 320, 180
img := image.NewRGBA(image.Rect(0, 0, w, h))
draw.Draw(img, img.Bounds(), &image.Uniform{color.RGBA{15, 17, 30, 255}}, image.Point{}, draw.Src)
barY, barH := h/2-6, 12
draw.Draw(img, image.Rect(20, barY, w-20, barY+barH),
&image.Uniform{color.RGBA{30, 34, 56, 255}}, image.Point{}, draw.Src)
if pct > 0 {
fillW := int(float64(w-40) * float64(pct) / 100.0)
if fillW < 2 {
fillW = 2
}
r := uint8(76 - int(float64(pct)*0.3))
g := uint8(110 + int(float64(pct)*0.8))
b := uint8(245 - int(float64(pct)*1.3))
draw.Draw(img, image.Rect(20, barY, 20+fillW, barY+barH),
&image.Uniform{color.RGBA{r, g, b, 255}}, image.Point{}, draw.Src)
}
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return base64.StdEncoding.EncodeToString(buf.Bytes())
}