feat(snapshots): AE scene-snapshot pipeline + admin trigger (Epic C, C1)
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
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
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>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/flatrender/render-svc/internal/db"
|
||||
"github.com/flatrender/render-svc/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/minio/minio-go/v7"
|
||||
)
|
||||
|
||||
// SnapshotJobHandler queues per-scene single-frame AE snapshot jobs and exposes
|
||||
// the node-facing claim/result/fail lifecycle. On result it writes the image URL
|
||||
// onto content.scenes.snapshot_url (in the store, cross-schema).
|
||||
type SnapshotJobHandler struct {
|
||||
store *db.Store
|
||||
minio *minio.Client
|
||||
templatesBucket string
|
||||
}
|
||||
|
||||
func NewSnapshotJobHandler(store *db.Store, mc *minio.Client, templatesBucket string) *SnapshotJobHandler {
|
||||
return &SnapshotJobHandler{store: store, minio: mc, templatesBucket: templatesBucket}
|
||||
}
|
||||
|
||||
// POST /v1/scene-snapshots/:project_id (admin) → queue one job per active scene.
|
||||
func (h *SnapshotJobHandler) Enqueue(c *gin.Context) {
|
||||
pid, err := uuid.Parse(c.Param("project_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid project_id"})
|
||||
return
|
||||
}
|
||||
n, err := h.store.EnqueueSceneSnapshots(c.Request.Context(), pid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"enqueued": n})
|
||||
}
|
||||
|
||||
// GET /v1/scene-snapshots/:project_id (admin) → job statuses for polling.
|
||||
func (h *SnapshotJobHandler) List(c *gin.Context) {
|
||||
pid, err := uuid.Parse(c.Param("project_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid project_id"})
|
||||
return
|
||||
}
|
||||
jobs, err := h.store.ListSnapshotJobs(c.Request.Context(), pid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if jobs == nil {
|
||||
jobs = []db.SnapshotJob{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"jobs": jobs})
|
||||
}
|
||||
|
||||
// POST /v1/internal/snapshot/claim (node, HMAC)
|
||||
func (h *SnapshotJobHandler) Claim(c *gin.Context) {
|
||||
var req models.ClaimJobRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
claim, err := h.store.ClaimSnapshotJob(c.Request.Context(), req.NodeID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if claim == nil {
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
url, isBundle, md5 := resolveTemplateObject(h.minio, h.templatesBucket, claim.ProjectID)
|
||||
if url == "" {
|
||||
_ = h.store.SetSnapshotError(c.Request.Context(), claim.ID,
|
||||
"no template stored for this project — upload the .aep from «فایلها» first")
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"snapshot_job_id": claim.ID,
|
||||
"project_id": claim.ProjectID,
|
||||
"scene_id": claim.SceneID,
|
||||
"scene_key": claim.SceneKey,
|
||||
"comp_name": claim.CompName,
|
||||
"frame": claim.Frame,
|
||||
"aep_download_url": url,
|
||||
"is_bundle": isBundle,
|
||||
"bundle_md5": md5,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /v1/internal/snapshot/:id/result (node, HMAC) body {image_url}
|
||||
func (h *SnapshotJobHandler) Result(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ImageURL string `json:"image_url"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.ImageURL == "" {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "image_url required"})
|
||||
return
|
||||
}
|
||||
if err := h.store.SetSnapshotResult(c.Request.Context(), id, req.ImageURL); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /v1/internal/snapshot/:id/fail (node, HMAC) body {reason}
|
||||
func (h *SnapshotJobHandler) Fail(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if req.Reason == "" {
|
||||
req.Reason = "snapshot failed"
|
||||
}
|
||||
if err := h.store.SetSnapshotError(c.Request.Context(), id, req.Reason); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
Reference in New Issue
Block a user