diff --git a/docker-compose.v2.yml b/docker-compose.v2.yml index d4f2ec1..d916db3 100644 --- a/docker-compose.v2.yml +++ b/docker-compose.v2.yml @@ -196,6 +196,9 @@ services: IDENTITY_URL: "http://identity-svc:8080" SERVICE_TOKEN: "${SERVICE_TOKEN:-internal-service-secret}" 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: postgres: condition: service_healthy diff --git a/services/render/cmd/server/main.go b/services/render/cmd/server/main.go index 004a6c5..8e99075 100644 --- a/services/render/cmd/server/main.go +++ b/services/render/cmd/server/main.go @@ -7,6 +7,7 @@ import ( "os" "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/identityclient" "github.com/flatrender/render-svc/internal/middleware" @@ -39,6 +40,7 @@ func main() { identityURL := getEnv("IDENTITY_URL", "") serviceToken := getEnv("SERVICE_TOKEN", "internal-service-secret") port := getEnv("PORT", "8080") + devWorker := getEnv("RENDER_DEV_WORKER", "false") == "true" // ── Database ────────────────────────────────────────────────────────────── pool, err := pgxpool.New(context.Background(), dbURL) @@ -72,6 +74,13 @@ func main() { scanH := handlers.NewScanHandler(store, mc, minioTemplatesBucket) 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 ──────────────────────────────────────────────────────────────── r := gin.Default() diff --git a/services/render/internal/db/db.go b/services/render/internal/db/db.go index 5d7c95a..796456d 100644 --- a/services/render/internal/db/db.go +++ b/services/render/internal/db/db.go @@ -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, ` diff --git a/services/render/internal/devworker/devworker.go b/services/render/internal/devworker/devworker.go new file mode 100644 index 0000000..b8a1dca --- /dev/null +++ b/services/render/internal/devworker/devworker.go @@ -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()) +} diff --git a/src/components/studio/StudioToolbar.tsx b/src/components/studio/StudioToolbar.tsx index fe8f41b..a38e333 100644 --- a/src/components/studio/StudioToolbar.tsx +++ b/src/components/studio/StudioToolbar.tsx @@ -21,6 +21,7 @@ import { import type { ShapeKind } from "@/lib/studio-layer-props"; import type { AddLayerInput } from "@/lib/studio-types"; import { useStudioStore } from "@/lib/studio-store"; +import { ALLOW_ADD_LAYERS } from "@/lib/studio-config"; const SHAPE_OPTIONS: { kind: ShapeKind; @@ -94,6 +95,9 @@ export function StudioToolbar() { const videoInputRef = useRef(null); const [shapeOpen, setShapeOpen] = useState(false); + // Template projects have a predefined structure — adding new layers is disabled. + if (!ALLOW_ADD_LAYERS) return null; + const handleAddText = () => { addLayer({ type: "text", diff --git a/src/components/studio/sidebar/SceneEditSidebarContent.tsx b/src/components/studio/sidebar/SceneEditSidebarContent.tsx index 4e7b915..1ad41d8 100644 --- a/src/components/studio/sidebar/SceneEditSidebarContent.tsx +++ b/src/components/studio/sidebar/SceneEditSidebarContent.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "next-intl"; import { getTextProps, mergeLayerProps } from "@/lib/studio-layer-props"; import { useStudioStore } from "@/lib/studio-store"; +import { ALLOW_ADD_LAYERS } from "@/lib/studio-config"; export function SceneEditSidebarContent() { const t = useTranslations("auto.componentsStudioSidebarSceneEditSidebarContent"); @@ -137,17 +138,19 @@ export function SceneEditSidebarContent() { )} - {/* Footer — add text button */} -
- -
+ {/* Footer — add text button (hidden for predefined template projects) */} + {ALLOW_ADD_LAYERS && ( +
+ +
+ )} ); } diff --git a/src/lib/studio-config.ts b/src/lib/studio-config.ts new file mode 100644 index 0000000..e5e81e4 --- /dev/null +++ b/src/lib/studio-config.ts @@ -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;