@
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
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>
@
This commit is contained in:
@@ -68,6 +68,8 @@ func main() {
|
||||
exportH := handlers.NewExportHandler(store, mc, minioBucket)
|
||||
nodeH := handlers.NewNodeHandler(store)
|
||||
fontH := handlers.NewFontHandler(store)
|
||||
bundleH := handlers.NewTemplateBundleHandler(mc, minioTemplatesBucket)
|
||||
scanH := handlers.NewScanHandler(store, mc, minioTemplatesBucket)
|
||||
internalH := handlers.NewInternalHandler(store, notifyClient, mc, minioTemplatesBucket, minioBucket)
|
||||
|
||||
// ── Router ────────────────────────────────────────────────────────────────
|
||||
@@ -148,6 +150,14 @@ func main() {
|
||||
// ── Render queue (admin: all users' jobs) ─────────────────────────────────
|
||||
v1.GET("/admin-renders", auth, admin, renderH.AdminList)
|
||||
|
||||
// ── Template bundles (admin: store the canonical .aep/.zip per project) ────
|
||||
v1.POST("/template-bundles/:project_id", auth, admin, bundleH.Set)
|
||||
|
||||
// ── Template scans (admin: read scenes/colours/configs from the AEP) ───────
|
||||
v1.POST("/template-scans/:project_id/quick", auth, admin, scanH.QuickScan) // headless Go quick-scan
|
||||
v1.POST("/template-scans/:project_id/jobs", auth, admin, scanH.CreateJob) // queue an AE full scan
|
||||
v1.GET("/template-scan-jobs/:id", auth, admin, scanH.GetJob)
|
||||
|
||||
// ── Exports management (admin: all users' rendered videos) ────────────────
|
||||
adminExports := v1.Group("/admin-exports", auth, admin)
|
||||
{
|
||||
@@ -172,6 +182,11 @@ func main() {
|
||||
internal.POST("/render/jobs/:job_id/fail", internalH.Fail)
|
||||
internal.POST("/render/jobs/:job_id/crash", internalH.Crash)
|
||||
internal.POST("/render/jobs/:job_id/replica-ready", internalH.ReplicaReady)
|
||||
|
||||
// AE scan jobs (node claims, runs scan.jsx, posts the ScanResult back)
|
||||
internal.POST("/scan/claim", scanH.Claim)
|
||||
internal.POST("/scan/:id/result", scanH.Result)
|
||||
internal.POST("/scan/:id/fail", scanH.Fail)
|
||||
}
|
||||
|
||||
log.Printf("render-svc listening on :%s", port)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Package aep is a minimal, stdlib-only reader for After Effects project (.aep)
|
||||
// files. AEP is a RIFX container (big-endian RIFF). This reader does NOT fully
|
||||
// decode the project — it walks the chunk tree to extract composition names and
|
||||
// (best-effort) durations, which is enough for a headless "quick scan" that
|
||||
// scaffolds scenes without After Effects. Full fidelity (layers, colours, fonts)
|
||||
// requires the AE-JSX scanner running on a node.
|
||||
package aep
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Comp is a composition discovered in the project.
|
||||
type Comp struct {
|
||||
Name string
|
||||
DurationSec float64 // 0 when it could not be derived
|
||||
}
|
||||
|
||||
// ParseComps walks a RIFX (.aep) buffer and returns its compositions.
|
||||
//
|
||||
// A composition is an "Item" LIST that contains a "cdta" (composition data)
|
||||
// chunk; its name is the Item's "Utf8" chunk. Folders and footage items lack
|
||||
// "cdta" and are skipped. This rule is robust across AE versions because only
|
||||
// comps carry cdta.
|
||||
func ParseComps(data []byte) ([]Comp, error) {
|
||||
if len(data) < 12 || string(data[0:4]) != "RIFX" {
|
||||
return nil, errors.New("not a RIFX/.aep file")
|
||||
}
|
||||
// data[4:8] = file size (BE), data[8:12] = form type ("Egg!"). Body follows.
|
||||
var comps []Comp
|
||||
seen := map[string]bool{}
|
||||
walk(data[12:], &comps, seen)
|
||||
return comps, nil
|
||||
}
|
||||
|
||||
// walk iterates the chunks in buf (a LIST body or the file root), recursing into
|
||||
// every LIST so nested items inside folders are found.
|
||||
func walk(buf []byte, comps *[]Comp, seen map[string]bool) {
|
||||
off := 0
|
||||
for off+8 <= len(buf) {
|
||||
id := string(buf[off : off+4])
|
||||
size := int(binary.BigEndian.Uint32(buf[off+4 : off+8]))
|
||||
ds := off + 8
|
||||
de := ds + size
|
||||
if size < 0 || de > len(buf) {
|
||||
break
|
||||
}
|
||||
if id == "LIST" && size >= 4 {
|
||||
listType := string(buf[ds : ds+4])
|
||||
body := buf[ds+4 : de]
|
||||
if listType == "Item" {
|
||||
handleItem(body, comps, seen)
|
||||
}
|
||||
walk(body, comps, seen) // recurse (folders hold nested Item LISTs)
|
||||
}
|
||||
adv := 8 + size
|
||||
if size%2 == 1 {
|
||||
adv++ // chunks are word-aligned
|
||||
}
|
||||
off += adv
|
||||
}
|
||||
}
|
||||
|
||||
// handleItem inspects the DIRECT children of an "Item" LIST: a "cdta" marks it as
|
||||
// a composition, the first "Utf8" is its name.
|
||||
func handleItem(body []byte, comps *[]Comp, seen map[string]bool) {
|
||||
var name string
|
||||
var cdta []byte
|
||||
hasCdta := false
|
||||
|
||||
off := 0
|
||||
for off+8 <= len(body) {
|
||||
id := string(body[off : off+4])
|
||||
size := int(binary.BigEndian.Uint32(body[off+4 : off+8]))
|
||||
ds := off + 8
|
||||
de := ds + size
|
||||
if size < 0 || de > len(body) {
|
||||
break
|
||||
}
|
||||
switch id {
|
||||
case "cdta":
|
||||
hasCdta = true
|
||||
cdta = body[ds:de]
|
||||
case "Utf8":
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(strings.TrimRight(string(body[ds:de]), "\x00"))
|
||||
}
|
||||
}
|
||||
adv := 8 + size
|
||||
if size%2 == 1 {
|
||||
adv++
|
||||
}
|
||||
off += adv
|
||||
}
|
||||
|
||||
if hasCdta && name != "" && !seen[name] {
|
||||
seen[name] = true
|
||||
*comps = append(*comps, Comp{Name: name, DurationSec: durationFromCdta(cdta)})
|
||||
}
|
||||
}
|
||||
|
||||
// durationFromCdta makes a best-effort attempt to read the comp duration from the
|
||||
// cdta chunk. The cdta layout varies by AE version; we read the frame-duration /
|
||||
// time-scale pair at well-known offsets and fall back to 0 (unknown) on any doubt.
|
||||
// Returning 0 is safe — the importer treats it as "leave duration unset".
|
||||
func durationFromCdta(cdta []byte) float64 {
|
||||
// cdta encodes time as rational values. Two uint32 BE commonly hold the comp
|
||||
// duration (in frames at the comp's time scale) and the time scale (fps base).
|
||||
// Offsets 0x20 (duration) and 0x24 (scale) are the most consistent across
|
||||
// recent versions; guard heavily and bail to 0 if the numbers look invalid.
|
||||
if len(cdta) < 0x28 {
|
||||
return 0
|
||||
}
|
||||
durFrames := binary.BigEndian.Uint32(cdta[0x20:0x24])
|
||||
scale := binary.BigEndian.Uint32(cdta[0x24:0x28])
|
||||
if scale == 0 || durFrames == 0 || scale > 100000 || durFrames > 100000000 {
|
||||
return 0
|
||||
}
|
||||
sec := float64(durFrames) / float64(scale)
|
||||
if sec <= 0 || sec > 36000 { // > 10h is nonsense → treat as unknown
|
||||
return 0
|
||||
}
|
||||
// round to 2 dp
|
||||
return float64(int(sec*100+0.5)) / 100
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package aep
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// chunk builds a RIFX chunk: 4-byte id + BE32 size + data (+ pad to even).
|
||||
func chunk(id string, data []byte) []byte {
|
||||
b := new(bytes.Buffer)
|
||||
b.WriteString(id)
|
||||
_ = binary.Write(b, binary.BigEndian, uint32(len(data)))
|
||||
b.Write(data)
|
||||
if len(data)%2 == 1 {
|
||||
b.WriteByte(0)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// list builds a LIST chunk of the given form type wrapping the body chunks.
|
||||
func list(formType string, body []byte) []byte {
|
||||
return chunk("LIST", append([]byte(formType), body...))
|
||||
}
|
||||
|
||||
func cdtaWithDuration(durFrames, scale uint32) []byte {
|
||||
b := make([]byte, 0x28)
|
||||
binary.BigEndian.PutUint32(b[0x20:0x24], durFrames)
|
||||
binary.BigEndian.PutUint32(b[0x24:0x28], scale)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestParseComps(t *testing.T) {
|
||||
// One composition (has cdta), one footage item (no cdta), inside a folder.
|
||||
comp := list("Item", bytes.Join([][]byte{
|
||||
chunk("cdta", cdtaWithDuration(150, 30)), // 150 frames @ 30 → 5.0s
|
||||
chunk("Utf8", []byte("scene_intro")),
|
||||
}, nil))
|
||||
footage := list("Item", bytes.Join([][]byte{
|
||||
chunk("Utf8", []byte("clip.mp4")),
|
||||
chunk("sspc", make([]byte, 8)),
|
||||
}, nil))
|
||||
folder := list("Item", bytes.Join([][]byte{
|
||||
chunk("Utf8", []byte("My Folder")),
|
||||
comp,
|
||||
footage,
|
||||
}, nil))
|
||||
|
||||
body := bytes.Join([][]byte{folder}, nil)
|
||||
// RIFX header: "RIFX" + size + "Egg!" + body
|
||||
file := new(bytes.Buffer)
|
||||
file.WriteString("RIFX")
|
||||
_ = binary.Write(file, binary.BigEndian, uint32(len(body)+4))
|
||||
file.WriteString("Egg!")
|
||||
file.Write(body)
|
||||
|
||||
comps, err := ParseComps(file.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("ParseComps: %v", err)
|
||||
}
|
||||
if len(comps) != 1 {
|
||||
t.Fatalf("expected 1 comp, got %d: %+v", len(comps), comps)
|
||||
}
|
||||
if comps[0].Name != "scene_intro" {
|
||||
t.Errorf("name = %q, want scene_intro", comps[0].Name)
|
||||
}
|
||||
if comps[0].DurationSec != 5.0 {
|
||||
t.Errorf("duration = %v, want 5.0", comps[0].DurationSec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCompsRejectsNonRifx(t *testing.T) {
|
||||
if _, err := ParseComps([]byte("not an aep file at all")); err == nil {
|
||||
t.Error("expected error for non-RIFX input")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ScanJob is an async "scan this project's AE template" job.
|
||||
type ScanJob struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Status string `json:"status"` // queued | running | done | error
|
||||
Engine string `json:"engine"`
|
||||
Result json.RawMessage `json:"result,omitempty"` // ScanResult JSON, present when done
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ScanClaim is the minimal info a node needs to run a claimed scan.
|
||||
type ScanClaim struct {
|
||||
ID uuid.UUID
|
||||
ProjectID uuid.UUID
|
||||
}
|
||||
|
||||
func (s *Store) CreateScanJob(ctx context.Context, projectID uuid.UUID, engine string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`INSERT INTO render.scan_jobs (project_id, engine, status) VALUES ($1, $2, 'queued') RETURNING id`,
|
||||
projectID, engine).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// ClaimScanJob atomically grabs the oldest queued ae-jsx scan for a node.
|
||||
// Returns nil when the queue is empty.
|
||||
func (s *Store) ClaimScanJob(ctx context.Context, nodeID uuid.UUID) (*ScanClaim, error) {
|
||||
var c ScanClaim
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
UPDATE render.scan_jobs SET status = 'running', node_id = $1, updated_at = NOW()
|
||||
WHERE id = (
|
||||
SELECT id FROM render.scan_jobs
|
||||
WHERE status = 'queued' AND engine = 'ae-jsx'
|
||||
ORDER BY created_at
|
||||
LIMIT 1 FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING id, project_id`, nodeID).Scan(&c.ID, &c.ProjectID)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetScanResult(ctx context.Context, id uuid.UUID, resultJSON string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE render.scan_jobs SET status = 'done', result = $2::jsonb, error = NULL, updated_at = NOW() WHERE id = $1`,
|
||||
id, resultJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetScanError(ctx context.Context, id uuid.UUID, msg string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE render.scan_jobs SET status = 'error', error = $2, updated_at = NOW() WHERE id = $1`, id, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetScanJob(ctx context.Context, id uuid.UUID) (*ScanJob, error) {
|
||||
var j ScanJob
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, project_id, status, engine, result, error FROM render.scan_jobs WHERE id = $1`,
|
||||
id).Scan(&j.ID, &j.ProjectID, &j.Status, &j.Engine, &j.Result, &j.Error)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &j, nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/flatrender/render-svc/internal/db"
|
||||
@@ -254,18 +255,37 @@ func (h *InternalHandler) Claim(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate presigned AEP download URL. AEP files are stored at
|
||||
// templates/{original_project_id}/template.aep in the templates bucket.
|
||||
// Errors are non-fatal — node agent falls back to mock render when URL is empty.
|
||||
// Resolve the canonical template object. Each template is stored once, per
|
||||
// project id, at templates/{original_project_id}/<name> in the templates bucket
|
||||
// and reused by every render of that template. A .zip is a full AE project
|
||||
// bundle (.aep + footage/fonts) the node must extract before rendering.
|
||||
// Errors are non-fatal — the node agent falls back to mock render when URL is empty.
|
||||
aepURL := ""
|
||||
isBundle := false
|
||||
bundleMD5 := ""
|
||||
if h.minio != nil {
|
||||
objectKey := fmt.Sprintf("templates/%s/template.aep", job.OriginalProjectID)
|
||||
purl, perr := h.minio.PresignedGetObject(
|
||||
context.Background(), h.templatesBucket, objectKey,
|
||||
2*time.Hour, nil,
|
||||
)
|
||||
if perr == nil {
|
||||
candidates := []struct {
|
||||
name string
|
||||
bundle bool
|
||||
}{
|
||||
{"bundle.zip", true},
|
||||
{"template.aep", false},
|
||||
{"template.aepx", false},
|
||||
}
|
||||
for _, cand := range candidates {
|
||||
objectKey := fmt.Sprintf("templates/%s/%s", job.OriginalProjectID, cand.name)
|
||||
info, serr := h.minio.StatObject(context.Background(), h.templatesBucket, objectKey, minio.StatObjectOptions{})
|
||||
if serr != nil {
|
||||
continue // not this format
|
||||
}
|
||||
purl, perr := h.minio.PresignedGetObject(context.Background(), h.templatesBucket, objectKey, 2*time.Hour, nil)
|
||||
if perr != nil {
|
||||
continue
|
||||
}
|
||||
aepURL = purl.String()
|
||||
isBundle = cand.bundle
|
||||
bundleMD5 = strings.Trim(info.ETag, "\"")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +298,8 @@ func (h *InternalHandler) Claim(c *gin.Context) {
|
||||
HasMusic: job.HasMusic,
|
||||
HasVoiceover: job.HasVoiceover,
|
||||
AEPDownloadURL: aepURL,
|
||||
IsBundle: isBundle,
|
||||
BundleMD5: bundleMD5,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
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)
|
||||
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
|
||||
}
|
||||
id, err := h.store.CreateScanJob(c.Request.Context(), pid, "ae-jsx")
|
||||
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"})
|
||||
}
|
||||
|
||||
// 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)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"scan_job_id": claim.ID,
|
||||
"project_id": claim.ProjectID,
|
||||
"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)
|
||||
}
|
||||
|
||||
// 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, ""
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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
|
||||
}
|
||||
@@ -415,9 +415,15 @@ type ClaimedJob struct {
|
||||
FrameRate int `json:"frame_rate"`
|
||||
HasMusic bool `json:"has_music"`
|
||||
HasVoiceover bool `json:"has_voiceover"`
|
||||
// AEPDownloadURL is a presigned MinIO GET URL for the .aep project file.
|
||||
// Valid for 2 hours. Empty when the template is not yet uploaded.
|
||||
// AEPDownloadURL is a presigned MinIO GET URL for the .aep project file
|
||||
// (or .zip bundle). Valid for 2 hours. Empty when the template is not yet uploaded.
|
||||
AEPDownloadURL string `json:"aep_download_url,omitempty"`
|
||||
// IsBundle is true when AEPDownloadURL points to a .zip bundle (the .aep plus
|
||||
// footage/fonts) that the node agent must extract before rendering.
|
||||
IsBundle bool `json:"is_bundle,omitempty"`
|
||||
// BundleMD5 is the stored object's ETag/MD5 — the node uses it as a cache key so
|
||||
// repeated renders of the same template download + extract the bundle only once.
|
||||
BundleMD5 string `json:"bundle_md5,omitempty"`
|
||||
}
|
||||
|
||||
// OutputUploadURLResponse is returned by POST .../output-upload-url.
|
||||
|
||||
Reference in New Issue
Block a user