Files
flatrender/services/render/internal/handlers/template_bundles.go
T
soroush.asadi 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
fix(scan): Fix-mode scanner + dialog suppression + cancel/timer + importer revive
- 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>
2026-06-04 19:06:08 +03:30

169 lines
5.7 KiB
Go

package handlers
import (
"context"
"fmt"
"net/http"
"net/url"
"path"
"strings"
"time"
"github.com/flatrender/render-svc/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/minio/minio-go/v7"
)
// TemplateBundleHandler stores the canonical After Effects template object for a
// project. Each project has ONE template at a deterministic per-project key, and
// every render of that project reuses it. The source may be a plain .aep/.aepx or
// a .zip bundle (the .aep plus its footage/fonts) — the node agent extracts zips.
type TemplateBundleHandler struct {
minio *minio.Client
templatesBucket string
}
func NewTemplateBundleHandler(mc *minio.Client, templatesBucket string) *TemplateBundleHandler {
return &TemplateBundleHandler{minio: mc, templatesBucket: templatesBucket}
}
type setBundleRequest struct {
// SourceURL is the public/path-style MinIO URL of the freshly uploaded file,
// e.g. http://host:9000/user-uploads/uploads/<uid>/<uuid>.zip
SourceURL string `json:"source_url" binding:"required"`
}
type setBundleResponse struct {
Bucket string `json:"bucket"`
Key string `json:"key"`
MD5 string `json:"md5"`
Size int64 `json:"size_bytes"`
IsBundle bool `json:"is_bundle"`
}
// POST /v1/template-bundles/:project_id
// Copies an uploaded .aep/.aepx/.zip into templates/{project_id}/(bundle.zip|
// template.aep|template.aepx) via a server-side MinIO copy (same backend, no
// proxy through this service), then returns the stored key + md5 so the caller
// can record it on the project. Replacing a template overwrites the same key, so
// in-flight and future renders pick up the new bundle (md5 changes → nodes refetch).
func (h *TemplateBundleHandler) Set(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 setBundleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()})
return
}
if h.minio == nil {
c.JSON(http.StatusServiceUnavailable, models.APIError{Code: "no_storage", Message: "object storage not configured"})
return
}
srcBucket, srcKey, err := parseObjectURL(req.SourceURL)
if err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_source", Message: err.Error()})
return
}
ext := strings.ToLower(path.Ext(srcKey))
isBundle := ext == ".zip"
destName := "template.aep"
switch ext {
case ".zip":
destName = "bundle.zip"
case ".aepx":
destName = "template.aepx"
default: // .aep or unknown — treat as a raw project file
destName = "template.aep"
}
destKey := fmt.Sprintf("templates/%s/%s", pid, destName)
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Minute)
defer cancel()
// The templates bucket may not exist on a fresh deployment — create it (private;
// the node downloads via presigned URL, so no public policy is needed).
if exists, berr := h.minio.BucketExists(ctx, h.templatesBucket); berr == nil && !exists {
if merr := h.minio.MakeBucket(ctx, h.templatesBucket, minio.MakeBucketOptions{}); merr != nil {
// A concurrent request may have created it — only fail if it's still missing.
if ex2, _ := h.minio.BucketExists(ctx, h.templatesBucket); !ex2 {
c.JSON(http.StatusBadGateway, models.APIError{Code: "bucket_error", Message: merr.Error()})
return
}
}
}
// Drop any stale sibling in the other format so the claim probe (which checks
// bundle.zip → template.aep → template.aepx in order) can't return the old file.
for _, n := range []string{"bundle.zip", "template.aep", "template.aepx"} {
if n != destName {
_ = h.minio.RemoveObject(ctx, h.templatesBucket, fmt.Sprintf("templates/%s/%s", pid, n), minio.RemoveObjectOptions{})
}
}
src := minio.CopySrcOptions{Bucket: srcBucket, Object: srcKey}
dst := minio.CopyDestOptions{Bucket: h.templatesBucket, Object: destKey}
if _, err := h.minio.CopyObject(ctx, dst, src); err != nil {
c.JSON(http.StatusBadGateway, models.APIError{Code: "copy_failed", Message: err.Error()})
return
}
info, err := h.minio.StatObject(ctx, h.templatesBucket, destKey, minio.StatObjectOptions{})
if err != nil {
c.JSON(http.StatusBadGateway, models.APIError{Code: "stat_failed", Message: err.Error()})
return
}
c.JSON(http.StatusOK, setBundleResponse{
Bucket: h.templatesBucket,
Key: destKey,
MD5: strings.Trim(info.ETag, "\""),
Size: info.Size,
IsBundle: isBundle,
})
}
// parseObjectURL extracts the bucket and object key from a storage URL. Handles
// two forms:
// - minio://<bucket>/<key...> (file-svc FileAddress — bucket is the HOST)
// - http(s)://host[:port]/<bucket>/<key> (public path-style — bucket is the 1st path seg)
// Query/fragment are ignored.
func parseObjectURL(raw string) (bucket, key string, err error) {
u, perr := url.Parse(strings.TrimSpace(raw))
if perr != nil {
return "", "", fmt.Errorf("parse url: %w", perr)
}
// minio://<bucket>/<key> — the bucket lives in the host component.
if u.Scheme == "minio" {
k := strings.TrimPrefix(u.Path, "/")
if u.Host == "" || k == "" {
return "", "", fmt.Errorf("cannot derive bucket/key from %q", raw)
}
if dec, derr := url.PathUnescape(k); derr == nil {
k = dec
}
return u.Host, k, nil
}
// http(s)://host[:port]/<bucket>/<key...>
p := strings.TrimPrefix(u.Path, "/")
if p == "" {
return "", "", fmt.Errorf("url has no path: %q", raw)
}
parts := strings.SplitN(p, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("cannot derive bucket/key from %q", raw)
}
k, uerr := url.PathUnescape(parts[1])
if uerr != nil {
k = parts[1]
}
return parts[0], k, nil
}