2026-06-05 22:10:05 +03:30
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 09:54:42 +03:30
|
|
|
|
// ── 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())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-05 22:10:05 +03:30
|
|
|
|
// 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())
|
|
|
|
|
|
}
|