6661f53734
Build backend images / build content-svc (push) Failing after 1m25s
Build backend images / build file-svc (push) Failing after 1m10s
Build backend images / build gateway (push) Failing after 56s
Build backend images / build identity-svc (push) Failing after 53s
Build backend images / build notification-svc (push) Failing after 57s
Build backend images / build render-svc (push) Failing after 48s
Build backend images / build studio-svc (push) Failing after 1m5s
- scan.jsx: app.beginSuppressDialogs() + clean quit (no AE hang on font/footage dialogs); FIX-mode branch parses frl_c(x)t/m(y) layer names → scenes by c(x); flexible/mockup keep comp-based walk; FR_SCAN_MODE selects. - render-svc: scan job carries project mode; cancel endpoint + node watchdog that kills AE on cancel; parseObjectURL handles minio:// (bucket in host); scan with no template fails cleanly; status guards so late results can't un-cancel. - content importer: revive soft-deleted scenes instead of duplicate-inserting (fixes scenes_project_id_key unique violation); orphan diff ignores deleted. - admin: scan dialog gets project-type picker + elapsed timer + Cancel button. - node-agent: AE-2026 wiring (host port 5010, host-reachable presign endpoint), FR_SCAN_MODE plumbing. docs/aep-template-convention.md: per-type naming + bundles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
349 lines
11 KiB
Go
349 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/flatrender/render-svc/internal/aep"
|
|
"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"
|
|
)
|
|
|
|
// ScanHandler exposes the headless Go "quick scan" and the async AE scan-job
|
|
// lifecycle. Both produce the same ScanResult shape the content importer consumes.
|
|
type ScanHandler struct {
|
|
store *db.Store
|
|
minio *minio.Client
|
|
templatesBucket string
|
|
}
|
|
|
|
func NewScanHandler(store *db.Store, mc *minio.Client, templatesBucket string) *ScanHandler {
|
|
return &ScanHandler{store: store, minio: mc, templatesBucket: templatesBucket}
|
|
}
|
|
|
|
// ── ScanResult shape (matches content-svc) ────────────────────────────────────
|
|
type scanColor struct {
|
|
ElementKey string `json:"element_key"`
|
|
Title string `json:"title"`
|
|
AttrValue string `json:"attr_value"`
|
|
DefaultColor string `json:"default_color"`
|
|
Sort int `json:"sort"`
|
|
}
|
|
type scanScene struct {
|
|
Key string `json:"key"`
|
|
Title string `json:"title"`
|
|
SceneType string `json:"scene_type"`
|
|
DefaultDurationSec *float64 `json:"default_duration_sec,omitempty"`
|
|
Sort int `json:"sort"`
|
|
Elements []interface{} `json:"elements"`
|
|
Colors []scanColor `json:"colors"`
|
|
}
|
|
type scanResult struct {
|
|
Source string `json:"source"`
|
|
RenderComp string `json:"render_comp,omitempty"`
|
|
Scenes []scanScene `json:"scenes"`
|
|
SharedColors []scanColor `json:"shared_colors"`
|
|
}
|
|
|
|
// reserved comp names that are not scenes.
|
|
func isReservedComp(name string) bool {
|
|
switch strings.ToLower(name) {
|
|
case "frfinal", "frshare", "all":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ── Quick scan (headless, no AE) ──────────────────────────────────────────────
|
|
// POST /v1/template-scans/:project_id/quick
|
|
func (h *ScanHandler) QuickScan(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
|
|
}
|
|
if h.minio == nil {
|
|
c.JSON(http.StatusServiceUnavailable, models.APIError{Code: "no_storage", Message: "object storage not configured"})
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
data, err := h.loadAep(ctx, pid)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, models.APIError{Code: "no_template", Message: err.Error()})
|
|
return
|
|
}
|
|
comps, err := aep.ParseComps(data)
|
|
if err != nil {
|
|
c.JSON(http.StatusUnprocessableEntity, models.APIError{Code: "parse_failed", Message: err.Error()})
|
|
return
|
|
}
|
|
|
|
res := scanResult{Source: "go-parser", Scenes: []scanScene{}, SharedColors: []scanColor{}}
|
|
for _, comp := range comps {
|
|
if strings.EqualFold(comp.Name, "frfinal") {
|
|
res.RenderComp = "frfinal"
|
|
}
|
|
if isReservedComp(comp.Name) {
|
|
continue
|
|
}
|
|
sc := scanScene{
|
|
Key: comp.Name, Title: comp.Name, SceneType: "Normal",
|
|
Sort: len(res.Scenes), Elements: []interface{}{}, Colors: []scanColor{},
|
|
}
|
|
if comp.DurationSec > 0 {
|
|
d := comp.DurationSec
|
|
sc.DefaultDurationSec = &d
|
|
}
|
|
res.Scenes = append(res.Scenes, sc)
|
|
}
|
|
c.JSON(http.StatusOK, res)
|
|
}
|
|
|
|
// loadAep fetches the project's template .aep bytes from MinIO — directly when a
|
|
// raw .aep was uploaded, or by extracting the .aep from the .zip bundle.
|
|
func (h *ScanHandler) loadAep(ctx context.Context, pid uuid.UUID) ([]byte, error) {
|
|
tryGet := func(key string) ([]byte, bool) {
|
|
if _, err := h.minio.StatObject(ctx, h.templatesBucket, key, minio.StatObjectOptions{}); err != nil {
|
|
return nil, false
|
|
}
|
|
obj, err := h.minio.GetObject(ctx, h.templatesBucket, key, minio.GetObjectOptions{})
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
defer obj.Close()
|
|
b, rerr := io.ReadAll(obj)
|
|
if rerr != nil || len(b) == 0 {
|
|
return nil, false
|
|
}
|
|
return b, true
|
|
}
|
|
|
|
if b, ok := tryGet(fmt.Sprintf("templates/%s/template.aep", pid)); ok {
|
|
return b, nil
|
|
}
|
|
if zb, ok := tryGet(fmt.Sprintf("templates/%s/bundle.zip", pid)); ok {
|
|
return extractAepFromZip(zb)
|
|
}
|
|
return nil, errors.New("no .aep template uploaded for this project (a raw .aepx can only be read by the AE scan)")
|
|
}
|
|
|
|
func extractAepFromZip(zb []byte) ([]byte, error) {
|
|
zr, err := zip.NewReader(bytes.NewReader(zb), int64(len(zb)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open bundle: %w", err)
|
|
}
|
|
var best *zip.File
|
|
bestDepth := 1 << 30
|
|
for _, f := range zr.File {
|
|
base := path.Base(f.Name)
|
|
if strings.HasPrefix(base, "._") || strings.Contains(f.Name, "__MACOSX") {
|
|
continue
|
|
}
|
|
if strings.ToLower(path.Ext(f.Name)) == ".aep" {
|
|
depth := strings.Count(f.Name, "/")
|
|
if depth < bestDepth {
|
|
bestDepth = depth
|
|
best = f
|
|
}
|
|
}
|
|
}
|
|
if best == nil {
|
|
return nil, errors.New("no .aep file found inside the bundle")
|
|
}
|
|
rc, err := best.Open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rc.Close()
|
|
return io.ReadAll(rc)
|
|
}
|
|
|
|
// ── AE scan jobs (async, full fidelity) ───────────────────────────────────────
|
|
// POST /v1/template-scans/:project_id/jobs (admin) body: {"mode":"fix|flexible|mockup|musicvisualizer"}
|
|
func (h *ScanHandler) CreateJob(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
|
|
}
|
|
var req struct {
|
|
Mode string `json:"mode"`
|
|
}
|
|
_ = c.ShouldBindJSON(&req)
|
|
mode := strings.ToLower(strings.TrimSpace(req.Mode))
|
|
if mode == "" {
|
|
mode = "flexible"
|
|
}
|
|
id, err := h.store.CreateScanJob(c.Request.Context(), pid, "ae-jsx", mode)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"id": id, "status": "queued", "mode": mode})
|
|
}
|
|
|
|
// GET /v1/template-scan-jobs/:id (admin)
|
|
func (h *ScanHandler) GetJob(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
|
|
}
|
|
j, err := h.store.GetScanJob(c.Request.Context(), id)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
|
return
|
|
}
|
|
if j == nil {
|
|
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: "scan job not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, j)
|
|
}
|
|
|
|
// ── internal (node agents, HMAC) ──────────────────────────────────────────────
|
|
// POST /v1/internal/scan/claim
|
|
func (h *ScanHandler) Claim(c *gin.Context) {
|
|
var req models.ClaimJobRequest
|
|
_ = c.ShouldBindJSON(&req)
|
|
|
|
claim, err := h.store.ClaimScanJob(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 == "" {
|
|
// No template object stored for this project — fail the job with a clear
|
|
// message instead of handing the node an empty URL.
|
|
_ = h.store.SetScanError(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{
|
|
"scan_job_id": claim.ID,
|
|
"project_id": claim.ProjectID,
|
|
"mode": claim.Mode,
|
|
"aep_download_url": url,
|
|
"is_bundle": isBundle,
|
|
"bundle_md5": md5,
|
|
})
|
|
}
|
|
|
|
// POST /v1/internal/scan/:id/result body = ScanResult JSON
|
|
func (h *ScanHandler) 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
|
|
}
|
|
body, err := io.ReadAll(c.Request.Body)
|
|
if err != nil || !json.Valid(body) {
|
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "result must be valid JSON"})
|
|
return
|
|
}
|
|
if err := h.store.SetScanResult(c.Request.Context(), id, string(body)); err != nil {
|
|
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// POST /v1/internal/scan/:id/fail body {reason}
|
|
func (h *ScanHandler) 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 = "scan failed"
|
|
}
|
|
if err := h.store.SetScanError(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)
|
|
}
|
|
|
|
// POST /v1/template-scan-jobs/:id/cancel (admin)
|
|
func (h *ScanHandler) Cancel(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
|
|
}
|
|
if err := h.store.CancelScanJob(c.Request.Context(), id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
|
|
}
|
|
|
|
// GET /v1/internal/scan/:id/status (node watchdog, HMAC) → {"status": "..."}
|
|
func (h *ScanHandler) Status(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
|
|
}
|
|
st, err := h.store.GetScanStatus(c.Request.Context(), id)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": st})
|
|
}
|
|
|
|
// resolveTemplateObject presigns the canonical template object for a project,
|
|
// probing bundle.zip → template.aep → template.aepx (same order as render claim).
|
|
func resolveTemplateObject(mc *minio.Client, bucket string, projectID uuid.UUID) (url string, isBundle bool, md5 string) {
|
|
if mc == nil {
|
|
return "", false, ""
|
|
}
|
|
candidates := []struct {
|
|
name string
|
|
bundle bool
|
|
}{
|
|
{"bundle.zip", true},
|
|
{"template.aep", false},
|
|
{"template.aepx", false},
|
|
}
|
|
for _, cand := range candidates {
|
|
key := fmt.Sprintf("templates/%s/%s", projectID, cand.name)
|
|
info, serr := mc.StatObject(context.Background(), bucket, key, minio.StatObjectOptions{})
|
|
if serr != nil {
|
|
continue
|
|
}
|
|
purl, perr := mc.PresignedGetObject(context.Background(), bucket, key, 2*time.Hour, nil)
|
|
if perr != nil {
|
|
continue
|
|
}
|
|
return purl.String(), cand.bundle, strings.Trim(info.ETag, "\"")
|
|
}
|
|
return "", false, ""
|
|
}
|