Files
flatrender/services/render/internal/handlers/scan.go
T
soroush.asadi 718564bce4
Build backend images / build content-svc (push) Failing after 15s
Build backend images / build file-svc (push) Failing after 1m51s
Build backend images / build gateway (push) Failing after 51s
Build backend images / build identity-svc (push) Failing after 57s
Build backend images / build notification-svc (push) Failing after 52s
Build backend images / build render-svc (push) Failing after 56s
Build backend images / build studio-svc (push) Failing after 57s
feat(scan): binary FIX scan reads frl_/frd_ names from .aep (no AE, never hangs)
Root cause of 'stuck on AE': heavy expression-driven projects take >10min for AE
to open, exceeding the scan timeout → job dies → admin UI stuck 'scanning'.

Fix: extend the stdlib .aep RIFX parser to collect every Utf8 name (ParseNames),
since FIX media placeholders are renamed footage ITEMS (frl_c1m1), not layers, and
text are layer names (frl_c1t1) — both are Utf8 chunks. QuickScan now branches on
?mode= (or auto-detects frl_ names) and scaffolds FIX scenes/elements + frd_*color
slots directly from the binary. Verified on the real final.aep that timed out in AE:
1 scene, 6 elements, 4 colors in 0.5s vs 10-min AE timeout.

Admin 'Quick scan (no AE)' is now the recommended path and passes the project mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:48:53 +03:30

431 lines
14 KiB
Go

package handlers
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"path"
"regexp"
"sort"
"strconv"
"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
}
// FIX / MusicVisualizer projects encode scenes in layer/item NAMES
// (frl_c{scene}{t|m}{idx}) rather than one-comp-per-scene. Detect them by the
// ?mode= query (or auto: any frl_c name present) and parse names instead of comps.
mode := strings.ToLower(c.Query("mode"))
names, nerr := aep.ParseNames(data)
if nerr == nil && (mode == "fix" || mode == "musicvisualizer" || (mode == "" && hasFrlNames(names))) {
c.JSON(http.StatusOK, buildFixResult(names))
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)
}
var frlRe = regexp.MustCompile(`^frl_c(\d+)([tm])(\d+)$`)
func hasFrlNames(names []string) bool {
for _, n := range names {
if frlRe.MatchString(n) {
return true
}
}
return false
}
// buildFixResult turns the flat list of project names into FIX-mode scenes.
// frl_c{scene}{t|m}{idx} → element grouped by scene (t=Text, m=Media); frd_*color
// → shared colour slot (value left empty for the binder/admin to fill, since the
// binary parser cannot read the RGBA value).
func buildFixResult(names []string) scanResult {
res := scanResult{Source: "go-parser", RenderComp: "frfinal", Scenes: []scanScene{}, SharedColors: []scanColor{}}
byScene := map[int]*scanScene{}
order := []int{}
seen := map[string]bool{}
for _, n := range names {
m := frlRe.FindStringSubmatch(n)
if m == nil || seen[n] {
continue
}
seen[n] = true
sceneNum, _ := strconv.Atoi(m[1])
idx, _ := strconv.Atoi(m[3])
sc := byScene[sceneNum]
if sc == nil {
sc = &scanScene{
Key: fmt.Sprintf("c%d", sceneNum), Title: fmt.Sprintf("Scene %d", sceneNum),
SceneType: "Normal", Elements: []interface{}{}, Colors: []scanColor{},
}
byScene[sceneNum] = sc
order = append(order, sceneNum)
}
elemType := "Text"
if m[2] == "m" {
elemType = "Media"
}
sc.Elements = append(sc.Elements, map[string]interface{}{
"key": n, "title": n, "type": elemType, "sort": idx,
})
}
sort.Ints(order)
for i, sn := range order {
sc := byScene[sn]
sc.Sort = i
res.Scenes = append(res.Scenes, *sc)
}
// shared colours — frd_*color names (controls like frd_bgstyle are skipped;
// they are render-time switches, not scene colours).
seenColor := map[string]bool{}
for _, n := range names {
if !strings.HasPrefix(n, "frd_") || !strings.Contains(strings.ToLower(n), "color") || seenColor[n] {
continue
}
seenColor[n] = true
res.SharedColors = append(res.SharedColors, scanColor{
ElementKey: n, Title: n, AttrValue: n, Sort: len(res.SharedColors),
})
}
return 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, ""
}