@
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:
soroush.asadi
2026-06-04 10:39:45 +03:30
parent 264fccf21f
commit 1ff6e494c0
26 changed files with 2691 additions and 27 deletions
+110 -9
View File
@@ -46,6 +46,7 @@ type Agent struct {
orch *client.Client
mu sync.Mutex
currentJob *client.ClaimedJob
scanning bool // true while running an AE scan job (shares the AE app)
status string // "Ready" | "Busy"
}
@@ -75,9 +76,25 @@ func (a *Agent) getStatus() (string, *string) {
jobID := a.currentJob.JobID
return a.status, &jobID
}
if a.scanning {
return "Busy", nil
}
return a.status, nil
}
func (a *Agent) setScanning(v bool) {
a.mu.Lock()
a.scanning = v
a.mu.Unlock()
}
// isBusy reports whether the AE app is in use (rendering or scanning).
func (a *Agent) isBusy() bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.currentJob != nil || a.scanning
}
// ── Main ──────────────────────────────────────────────────────────────────────
func main() {
@@ -121,10 +138,11 @@ func main() {
// Main loops
var wg sync.WaitGroup
wg.Add(3)
wg.Add(4)
go func() { defer wg.Done(); agent.heartbeatLoop(ctx) }()
go func() { defer wg.Done(); agent.pollLoop(ctx) }()
go func() { defer wg.Done(); agent.fontSyncLoop(ctx) }()
go func() { defer wg.Done(); agent.scanLoop(ctx) }()
wg.Wait()
log.Printf("shutdown complete")
}
@@ -183,6 +201,83 @@ func (a *Agent) syncFonts(ctx context.Context) {
}
}
// ── Scan loop ──────────────────────────────────────────────────────────────────
// Claims AE scan jobs and runs the template scanner (scan.jsx) via afterfx.exe,
// posting the resulting ScanResult JSON back to the orchestrator. Requires the AE
// app — skipped entirely in mock/dev (no AE_PATH).
func (a *Agent) scanLoop(ctx context.Context) {
interval := time.Duration(a.cfg.PollIntervalSec) * time.Second
if interval < 5*time.Second {
interval = 5 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.pollScanOnce(ctx)
}
}
}
func (a *Agent) pollScanOnce(ctx context.Context) {
if a.cfg.AfterFxPath == "" || a.cfg.AEPath == "" {
return // scanning needs the real AE app
}
if a.isBusy() {
return // don't contend with a render (or another scan) for the AE app
}
claim, err := a.orch.ClaimScan(ctx, a.cfg.NodeID, a.cfg.Region)
if err != nil {
log.Printf("scan claim error: %v", err)
return
}
if claim == nil {
return // nothing queued
}
a.setScanning(true)
defer a.setScanning(false)
log.Printf("[scan %s] claimed (project %s)", claim.ScanJobID, claim.ProjectID)
// Reuse the template prepare/cache pipeline (download + extract bundle by md5).
prepCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
aepPath, perr := runner.PrepareTemplate(prepCtx, claim.AEPDownloadURL, claim.IsBundle, claim.BundleMD5, a.cfg.WorkDir, "scan-"+claim.ScanJobID)
cancel()
if perr != nil {
a.failScan(claim.ScanJobID, "prepare template: "+perr.Error())
return
}
outPath := filepath.Join(a.cfg.WorkDir, "scans", claim.ScanJobID, "scan.json")
scanCtx, cancel2 := context.WithTimeout(ctx, 10*time.Minute)
result, serr := runner.RunScan(scanCtx, a.cfg.AfterFxPath, aepPath, a.cfg.WorkDir, outPath)
cancel2()
if serr != nil {
a.failScan(claim.ScanJobID, serr.Error())
return
}
rptCtx, cancel3 := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel3()
if err := a.orch.ReportScanResult(rptCtx, claim.ScanJobID, result); err != nil {
log.Printf("[scan %s] report result error: %v", claim.ScanJobID, err)
return
}
log.Printf("[scan %s] done (%d bytes)", claim.ScanJobID, len(result))
}
func (a *Agent) failScan(id, reason string) {
log.Printf("[scan %s] failed: %s", id, reason)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = a.orch.ReportScanFail(ctx, id, reason)
}
// ── Heartbeat loop ────────────────────────────────────────────────────────────
func (a *Agent) heartbeatLoop(ctx context.Context) {
@@ -267,18 +362,24 @@ func (a *Agent) tryClaimAndRun(ctx context.Context) {
func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) {
log.Printf("[job %s] starting render", job.JobID)
// ── Step 1: Download .aep template ───────────────────────────────────────
// ── Step 1: Fetch + prepare the .aep template ────────────────────────────
// PrepareTemplate downloads the project file; if it's a .zip bundle it is
// extracted and the .aep inside located. Prepared templates are cached by md5
// so repeated renders of the same template skip the download + extraction.
aepPath := ""
if job.AEPDownloadURL != "" && a.cfg.AEPath != "" {
localAEP := filepath.Join(a.cfg.WorkDir, "templates", job.JobID, "template.aep")
dlCtx, dlCancel := context.WithTimeout(ctx, 10*time.Minute)
n, dlErr := runner.DownloadFile(dlCtx, job.AEPDownloadURL, localAEP)
dlCtx, dlCancel := context.WithTimeout(ctx, 15*time.Minute)
p, prepErr := runner.PrepareTemplate(dlCtx, job.AEPDownloadURL, job.IsBundle, job.BundleMD5, a.cfg.WorkDir, job.JobID)
dlCancel()
if dlErr != nil {
log.Printf("[job %s] AEP download failed (%v) — falling back to mock", job.JobID, dlErr)
if prepErr != nil {
log.Printf("[job %s] template prepare failed (%v) — falling back to mock", job.JobID, prepErr)
} else {
log.Printf("[job %s] AEP downloaded (%d bytes) → %s", job.JobID, n, localAEP)
aepPath = localAEP
kind := "aep"
if job.IsBundle {
kind = "bundle"
}
log.Printf("[job %s] template ready (%s) → %s", job.JobID, kind, p)
aepPath = p
}
}