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
+55
View File
@@ -464,6 +464,61 @@ func (s *Store) CompleteJob(ctx context.Context, jobID uuid.UUID, exportID *uuid
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.
func (s *Store) FailJob(ctx context.Context, jobID uuid.UUID, reason, atStep string) (*models.RenderJob, error) {
_, err := s.pool.Exec(ctx, `