Files
flatrender/services/render/internal/devworker/devworker.go
T
soroush.asadi 8488acb115
Build backend images / build content-svc (push) Failing after 31s
Build backend images / build file-svc (push) Failing after 30s
Build backend images / build gateway (push) Failing after 30s
Build backend images / build identity-svc (push) Failing after 30s
Build backend images / build notification-svc (push) Failing after 31s
Build backend images / build render-svc (push) Failing after 31s
Build backend images / build studio-svc (push) Failing after 31s
feat(snapshots): AE scene-snapshot pipeline + admin trigger (Epic C, C1)
Per-scene preview thumbnails for templates. Admin clicks "ساخت پیش‌نمایش
صحنه‌ها" → one single-frame AE render per scene → content.scenes.snapshot_url
→ shown as a thumbnail in the admin scene list (and available to the studio).

- migration 30_render_snapshot_jobs.sql: render.snapshot_jobs (queued|running|
  done|error, per scene, image_url).
- render-svc: db/snapshotjobs.go (EnqueueSceneSnapshots, List, Claim, SetResult
  -> writes content.scenes.snapshot_url cross-schema, SetError); handlers/
  snapshotjobs.go (admin POST/GET /v1/scene-snapshots/:project_id + node-facing
  internal claim/result/fail); main.go routes; gateway route.
- devworker: RunSnapshots — fulfils snapshot jobs with a generated placeholder
  PNG (data: URL, scene-key-tinted) so the flow is verifiable without an AE node.
  Gated by RENDER_DEV_SNAPSHOTS (default off; never hijacks real render jobs).
- admin UI: ProjectScenes "generate snapshots" button (enqueue + poll + reload)
  and a thumbnail (snapshot_url || image) per scene row.

Verified e2e via the dev mock: enqueue -> jobs run -> content.scenes.snapshot_url
populated -> scenes API returns it -> admin renders the thumbnail.

Remaining (C2): node-agent real-AE runner — claim snapshot, aerender -s0 -e0 ->
ffmpeg still -> upload to a PERMANENT URL (mirror file-svc, not the time-limited
export presign) -> post result. Needs a live AE node to build + verify.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 09:54:42 +03:30

188 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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())
}