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:
@@ -196,6 +196,9 @@ services:
|
|||||||
IDENTITY_URL: "http://identity-svc:8080"
|
IDENTITY_URL: "http://identity-svc:8080"
|
||||||
SERVICE_TOKEN: "${SERVICE_TOKEN:-internal-service-secret}"
|
SERVICE_TOKEN: "${SERVICE_TOKEN:-internal-service-secret}"
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
# Dev: process Queued jobs in-process (progress + preview → Done) without a
|
||||||
|
# Windows AE node. Set "false" in production where real render nodes claim jobs.
|
||||||
|
RENDER_DEV_WORKER: "${RENDER_DEV_WORKER:-true}"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/flatrender/render-svc/internal/db"
|
"github.com/flatrender/render-svc/internal/db"
|
||||||
|
"github.com/flatrender/render-svc/internal/devworker"
|
||||||
"github.com/flatrender/render-svc/internal/handlers"
|
"github.com/flatrender/render-svc/internal/handlers"
|
||||||
"github.com/flatrender/render-svc/internal/identityclient"
|
"github.com/flatrender/render-svc/internal/identityclient"
|
||||||
"github.com/flatrender/render-svc/internal/middleware"
|
"github.com/flatrender/render-svc/internal/middleware"
|
||||||
@@ -39,6 +40,7 @@ func main() {
|
|||||||
identityURL := getEnv("IDENTITY_URL", "")
|
identityURL := getEnv("IDENTITY_URL", "")
|
||||||
serviceToken := getEnv("SERVICE_TOKEN", "internal-service-secret")
|
serviceToken := getEnv("SERVICE_TOKEN", "internal-service-secret")
|
||||||
port := getEnv("PORT", "8080")
|
port := getEnv("PORT", "8080")
|
||||||
|
devWorker := getEnv("RENDER_DEV_WORKER", "false") == "true"
|
||||||
|
|
||||||
// ── Database ──────────────────────────────────────────────────────────────
|
// ── Database ──────────────────────────────────────────────────────────────
|
||||||
pool, err := pgxpool.New(context.Background(), dbURL)
|
pool, err := pgxpool.New(context.Background(), dbURL)
|
||||||
@@ -72,6 +74,13 @@ func main() {
|
|||||||
scanH := handlers.NewScanHandler(store, mc, minioTemplatesBucket)
|
scanH := handlers.NewScanHandler(store, mc, minioTemplatesBucket)
|
||||||
internalH := handlers.NewInternalHandler(store, notifyClient, mc, minioTemplatesBucket, minioBucket)
|
internalH := handlers.NewInternalHandler(store, notifyClient, mc, minioTemplatesBucket, minioBucket)
|
||||||
|
|
||||||
|
// ── Dev mock worker (no AE node needed) ────────────────────────────────────
|
||||||
|
// Drives Queued jobs to Done with progress + preview frames so the render flow
|
||||||
|
// is exercisable in development. Production keeps this OFF and uses real nodes.
|
||||||
|
if devWorker {
|
||||||
|
go devworker.New(store).Run(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
// ── Router ────────────────────────────────────────────────────────────────
|
// ── Router ────────────────────────────────────────────────────────────────
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
|
|||||||
@@ -464,6 +464,61 @@ func (s *Store) CompleteJob(ctx context.Context, jobID uuid.UUID, exportID *uuid
|
|||||||
return s.getJobByIDInternal(ctx, jobID)
|
return s.getJobByIDInternal(ctx, jobID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DevClaimNextQueued atomically picks the oldest Queued job and moves it to
|
||||||
|
// Preparing (started_at set). Returns (nil, nil) when the queue is empty.
|
||||||
|
//
|
||||||
|
// This is the dev/mock path: it claims WITHOUT assigning a render node, so the
|
||||||
|
// in-process dev worker can simulate a render end-to-end with no Windows AE node.
|
||||||
|
// Never enabled in production (gated by RENDER_DEV_WORKER).
|
||||||
|
func (s *Store) DevClaimNextQueued(ctx context.Context) (uuid.UUID, error) {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
var jobID uuid.UUID
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
SELECT id FROM render.render_jobs
|
||||||
|
WHERE step = 'Queued'::render_step
|
||||||
|
ORDER BY priority_score DESC, queued_at ASC
|
||||||
|
LIMIT 1 FOR UPDATE SKIP LOCKED`).Scan(&jobID)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "no rows in result set" {
|
||||||
|
return uuid.Nil, nil
|
||||||
|
}
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
UPDATE render.render_jobs SET
|
||||||
|
step = 'Preparing'::render_step,
|
||||||
|
started_at = COALESCE(started_at, NOW()),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
return jobID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateJobStepProgress sets the step + progress for a job (dev worker + future
|
||||||
|
// fine-grained progress). No-op on terminal jobs.
|
||||||
|
func (s *Store) UpdateJobStepProgress(ctx context.Context, jobID uuid.UUID, step string, progress int) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE render.render_jobs SET
|
||||||
|
step = $1::render_step,
|
||||||
|
render_progress = $2,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $3
|
||||||
|
AND step NOT IN ('Done'::render_step, 'Failed'::render_step, 'Cancelled'::render_step)`,
|
||||||
|
step, progress, jobID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// FailJob marks a render job as Failed. Returns the updated job.
|
// FailJob marks a render job as Failed. Returns the updated job.
|
||||||
func (s *Store) FailJob(ctx context.Context, jobID uuid.UUID, reason, atStep string) (*models.RenderJob, error) {
|
func (s *Store) FailJob(ctx context.Context, jobID uuid.UUID, reason, atStep string) (*models.RenderJob, error) {
|
||||||
_, err := s.pool.Exec(ctx, `
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import type { ShapeKind } from "@/lib/studio-layer-props";
|
import type { ShapeKind } from "@/lib/studio-layer-props";
|
||||||
import type { AddLayerInput } from "@/lib/studio-types";
|
import type { AddLayerInput } from "@/lib/studio-types";
|
||||||
import { useStudioStore } from "@/lib/studio-store";
|
import { useStudioStore } from "@/lib/studio-store";
|
||||||
|
import { ALLOW_ADD_LAYERS } from "@/lib/studio-config";
|
||||||
|
|
||||||
const SHAPE_OPTIONS: {
|
const SHAPE_OPTIONS: {
|
||||||
kind: ShapeKind;
|
kind: ShapeKind;
|
||||||
@@ -94,6 +95,9 @@ export function StudioToolbar() {
|
|||||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [shapeOpen, setShapeOpen] = useState(false);
|
const [shapeOpen, setShapeOpen] = useState(false);
|
||||||
|
|
||||||
|
// Template projects have a predefined structure — adding new layers is disabled.
|
||||||
|
if (!ALLOW_ADD_LAYERS) return null;
|
||||||
|
|
||||||
const handleAddText = () => {
|
const handleAddText = () => {
|
||||||
addLayer({
|
addLayer({
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
|
|||||||
|
|
||||||
import { getTextProps, mergeLayerProps } from "@/lib/studio-layer-props";
|
import { getTextProps, mergeLayerProps } from "@/lib/studio-layer-props";
|
||||||
import { useStudioStore } from "@/lib/studio-store";
|
import { useStudioStore } from "@/lib/studio-store";
|
||||||
|
import { ALLOW_ADD_LAYERS } from "@/lib/studio-config";
|
||||||
|
|
||||||
export function SceneEditSidebarContent() {
|
export function SceneEditSidebarContent() {
|
||||||
const t = useTranslations("auto.componentsStudioSidebarSceneEditSidebarContent");
|
const t = useTranslations("auto.componentsStudioSidebarSceneEditSidebarContent");
|
||||||
@@ -137,17 +138,19 @@ export function SceneEditSidebarContent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer — add text button */}
|
{/* Footer — add text button (hidden for predefined template projects) */}
|
||||||
<div className="shrink-0 border-t border-gray-200 p-3">
|
{ALLOW_ADD_LAYERS && (
|
||||||
<button
|
<div className="shrink-0 border-t border-gray-200 p-3">
|
||||||
type="button"
|
<button
|
||||||
onClick={handleAddText}
|
type="button"
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-blue-300 bg-blue-50 px-3 py-2 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
onClick={handleAddText}
|
||||||
>
|
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-blue-300 bg-blue-50 px-3 py-2 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||||
<Plus className="h-3.5 w-3.5" aria-hidden />
|
>
|
||||||
{t("addTextLayer")}
|
<Plus className="h-3.5 w-3.5" aria-hidden />
|
||||||
</button>
|
{t("addTextLayer")}
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Studio behaviour flags.
|
||||||
|
*
|
||||||
|
* FlatRender projects are always created FROM a template — the Studio service has
|
||||||
|
* no "blank project" concept and copies the template's scene graph into the editable
|
||||||
|
* project. The structure (scenes + layers) is therefore PREDEFINED: users customise
|
||||||
|
* existing layers (text, colours, images) but do not add or remove layers/scenes.
|
||||||
|
*
|
||||||
|
* Flip ALLOW_ADD_LAYERS to true only if a free-form (non-template) editor is ever
|
||||||
|
* introduced.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Whether the user may add brand-new layers to the canvas. */
|
||||||
|
export const ALLOW_ADD_LAYERS = false;
|
||||||
|
|
||||||
|
/** Whether the user may add / delete / duplicate whole scenes. */
|
||||||
|
export const ALLOW_EDIT_SCENES = false;
|
||||||
Reference in New Issue
Block a user