Files
flatrender/services/render/internal/db/snapshotjobs.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

126 lines
4.1 KiB
Go

package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// SnapshotJob is an async "render one frame of a scene" job (status row).
type SnapshotJob struct {
ID uuid.UUID `json:"id"`
SceneID uuid.UUID `json:"scene_id"`
SceneKey string `json:"scene_key"`
Status string `json:"status"` // queued | running | done | error
ImageURL *string `json:"image_url,omitempty"`
Error *string `json:"error,omitempty"`
}
// SnapshotClaim is the minimal info a node needs to render a claimed snapshot.
type SnapshotClaim struct {
ID uuid.UUID
ProjectID uuid.UUID
SceneID uuid.UUID
SceneKey string
CompName string
Frame int
}
// EnqueueSceneSnapshots clears any prior snapshot jobs for the project and queues
// one fresh job per active scene (comp_name defaults to the scene key — a comp of
// that name; the node falls back to the render comp when it does not exist).
// Returns the number of jobs queued.
func (s *Store) EnqueueSceneSnapshots(ctx context.Context, projectID uuid.UUID) (int, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return 0, err
}
defer tx.Rollback(ctx)
if _, err = tx.Exec(ctx, `DELETE FROM render.snapshot_jobs WHERE project_id = $1`, projectID); err != nil {
return 0, err
}
tag, err := tx.Exec(ctx, `
INSERT INTO render.snapshot_jobs (project_id, scene_id, scene_key, comp_name, frame, status)
SELECT $1, sc.id, sc.key, sc.key, 0, 'queued'
FROM content.scenes sc
WHERE sc.project_id = $1 AND sc.deleted_at IS NULL AND sc.is_active = true`, projectID)
if err != nil {
return 0, err
}
if err = tx.Commit(ctx); err != nil {
return 0, err
}
return int(tag.RowsAffected()), nil
}
// ListSnapshotJobs returns the snapshot jobs for a project (for polling).
func (s *Store) ListSnapshotJobs(ctx context.Context, projectID uuid.UUID) ([]SnapshotJob, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, scene_id, scene_key, status, image_url, error
FROM render.snapshot_jobs WHERE project_id = $1 ORDER BY created_at`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SnapshotJob
for rows.Next() {
var j SnapshotJob
if err := rows.Scan(&j.ID, &j.SceneID, &j.SceneKey, &j.Status, &j.ImageURL, &j.Error); err != nil {
return nil, err
}
out = append(out, j)
}
return out, rows.Err()
}
// ClaimSnapshotJob atomically grabs the oldest queued snapshot for a node.
// Returns nil when the queue is empty.
func (s *Store) ClaimSnapshotJob(ctx context.Context, nodeID uuid.UUID) (*SnapshotClaim, error) {
var c SnapshotClaim
err := s.pool.QueryRow(ctx, `
UPDATE render.snapshot_jobs SET status = 'running', node_id = $1, updated_at = NOW()
WHERE id = (
SELECT id FROM render.snapshot_jobs
WHERE status = 'queued'
ORDER BY created_at
LIMIT 1 FOR UPDATE SKIP LOCKED
)
RETURNING id, project_id, scene_id, scene_key, comp_name, frame`,
nodeID).Scan(&c.ID, &c.ProjectID, &c.SceneID, &c.SceneKey, &c.CompName, &c.Frame)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, err
}
return &c, nil
}
// SetSnapshotResult marks a running snapshot done with its image URL and writes
// that URL onto the content scene (same DB, cross-schema) so the studio + admin
// render a real thumbnail.
func (s *Store) SetSnapshotResult(ctx context.Context, id uuid.UUID, imageURL string) error {
var sceneID uuid.UUID
err := s.pool.QueryRow(ctx,
`UPDATE render.snapshot_jobs SET status = 'done', image_url = $2, error = NULL, updated_at = NOW()
WHERE id = $1 AND status = 'running' RETURNING scene_id`, id, imageURL).Scan(&sceneID)
if err != nil {
if err == pgx.ErrNoRows {
return nil // job no longer running (cancelled/regenerated) — ignore late result
}
return err
}
_, err = s.pool.Exec(ctx,
`UPDATE content.scenes SET snapshot_url = $2, updated_at = NOW() WHERE id = $1`, sceneID, imageURL)
return err
}
func (s *Store) SetSnapshotError(ctx context.Context, id uuid.UUID, msg string) error {
_, err := s.pool.Exec(ctx,
`UPDATE render.snapshot_jobs SET status = 'error', error = $2, updated_at = NOW() WHERE id = $1 AND status = 'running'`,
id, msg)
return err
}