1ff6e494c0
Build backend images / build content-svc (push) Failing after 19s
Build backend images / build file-svc (push) Failing after 1m53s
Build backend images / build gateway (push) Failing after 16s
Build backend images / build identity-svc (push) Failing after 7m1s
Build backend images / build notification-svc (push) Failing after 7m24s
Build backend images / build render-svc (push) Failing after 3m12s
Build backend images / build studio-svc (push) Failing after 43s
feat: AE template scanner + scene editor + AEP bundle pipeline
Scene editor (admin): per-project Scenes / Shared Colors / Color Presets
manager (ProjectScenes) reachable from each project.
AEP bundle pipeline: upload .aep or .zip → stored once per template at
templates/{project_id}/(bundle.zip|template.aep); render claim probes and
returns is_bundle+md5; node-agent extracts the bundle, locates the .aep
(zip-slip guarded), and caches by md5 so repeated renders extract once.
AE template scanner ("read scenes/colours/configs from the AEP"):
- content-svc importer: POST /v1/projects/{id}/scan/{preview,apply} —
review-diff-then-merge into scenes/elements/colours (manual edits kept).
- render-svc Go quick-scan: stdlib RIFX parser extracts comp names+durations
(no AE) → POST /v1/template-scans/{id}/quick.
- render-svc AE scan jobs + node-agent runner: queue → node runs scan.jsx
(reverse of legacy JSXGenerator conventions: frfinal/frshare/frl_/frd_) →
posts ScanResult back. Migration 26_render_scan_jobs.
- admin UI: "اسکن از افترافکت" with quick/full engines + diff-review modal.
Verified: importer preview/apply, Go quick-scan end-to-end (synthetic .aep →
scene imported), bundle extract unit tests, RIFX parser unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
152 lines
5.2 KiB
Go
152 lines
5.2 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 path-style storage URL
|
|
// such as http://host:9000/<bucket>/<key...>. 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)
|
|
}
|
|
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
|
|
}
|