package handlers import ( "fmt" "net/http" "time" "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 uploadBucket string // public-read bucket snapshots land in (e.g. user-uploads) publicBase string // browser-reachable base, e.g. http://172.28.144.1:9000 } func NewSnapshotJobHandler(store *db.Store, mc *minio.Client, templatesBucket, uploadBucket, publicBase string) *SnapshotJobHandler { return &SnapshotJobHandler{store: store, minio: mc, templatesBucket: templatesBucket, uploadBucket: uploadBucket, publicBase: publicBase} } // 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/upload-url (node, HMAC) // Presigns a PUT to the public-read uploads bucket and returns the permanent // public URL the node should report back once the still is uploaded. func (h *SnapshotJobHandler) UploadURL(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 } pid, sceneKey, merr := h.store.GetSnapshotJobMeta(c.Request.Context(), id) if merr != nil { c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: "snapshot job not found"}) return } objectKey := fmt.Sprintf("snapshots/%s/%s.png", pid, sceneKey) put, perr := h.minio.PresignedPutObject(c.Request.Context(), h.uploadBucket, objectKey, 15*time.Minute) if perr != nil { c.JSON(http.StatusInternalServerError, models.APIError{Code: "presign_failed", Message: perr.Error()}) return } c.JSON(http.StatusOK, gin.H{ "upload_url": put.String(), "object_key": objectKey, "public_url": fmt.Sprintf("%s/%s/%s", h.publicBase, h.uploadBucket, objectKey), }) } // 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) }