// 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) } // ── Snapshot mock ───────────────────────────────────────────────────────────── // devSnapshotNode is the synthetic node id the mock records on claimed snapshots. var devSnapshotNode = uuid.MustParse("00000000-0000-0000-0000-0000000000aa") // RunSnapshots fulfils queued scene-snapshot jobs with a generated placeholder // image (no AE) so the snapshot flow is exercisable in development. Gated by its // own flag so it never touches real render jobs. Production uses real nodes. func (w *Worker) RunSnapshots(ctx context.Context) { log.Printf("[devworker] snapshot mock started (poll %s) — NOT for production", w.interval) ticker := time.NewTicker(w.interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: w.snapTick(ctx) } } } func (w *Worker) snapTick(ctx context.Context) { claim, err := w.store.ClaimSnapshotJob(ctx, devSnapshotNode) if err != nil { log.Printf("[devworker] snapshot claim error: %v", err) return } if claim == nil { return // queue empty } if err := w.store.SetSnapshotResult(ctx, claim.ID, snapshotPlaceholder(claim.SceneKey)); err != nil { log.Printf("[devworker] snapshot %s result failed: %v", claim.ID, err) _ = w.store.SetSnapshotError(ctx, claim.ID, err.Error()) return } log.Printf("[devworker] snapshot %s (scene %s) done", claim.ID, claim.SceneKey) } // snapshotPlaceholder builds a 480×270 PNG card tinted by the scene key with a // little "play" block, returned as a data: URL so the dev path needs no storage. func snapshotPlaceholder(sceneKey string) string { const w, h = 480, 270 var sum uint32 for _, r := range sceneKey { sum = sum*31 + uint32(r) } base := color.RGBA{uint8(40 + sum%120), uint8(40 + (sum/120)%120), uint8(80 + (sum/7)%150), 255} img := image.NewRGBA(image.Rect(0, 0, w, h)) draw.Draw(img, img.Bounds(), &image.Uniform{base}, image.Point{}, draw.Src) draw.Draw(img, image.Rect(0, h/2-30, w, h/2+30), &image.Uniform{color.RGBA{0, 0, 0, 60}}, image.Point{}, draw.Over) draw.Draw(img, image.Rect(w/2-18, h/2-18, w/2+18, h/2+18), &image.Uniform{color.RGBA{255, 255, 255, 230}}, image.Point{}, draw.Over) var buf bytes.Buffer _ = png.Encode(&buf, img) return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) } // 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()) }