@
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:
@@ -0,0 +1,23 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- RENDER SCHEMA — AEP scan jobs
|
||||||
|
-- Async "scan a project template with After Effects" jobs. A node claims a
|
||||||
|
-- queued scan, runs scan.jsx against the template, and posts back the result
|
||||||
|
-- (the same ScanResult JSON the content importer consumes).
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
SET search_path TO render, public;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scan_jobs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued', -- queued | running | done | error
|
||||||
|
engine TEXT NOT NULL DEFAULT 'ae-jsx', -- ae-jsx | go-parser
|
||||||
|
result JSONB,
|
||||||
|
error TEXT,
|
||||||
|
node_id UUID,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scan_jobs_status ON scan_jobs(status, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scan_jobs_project ON scan_jobs(project_id, created_at DESC);
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
using FlatRender.ContentSvc.Domain.Entities;
|
||||||
|
using FlatRender.ContentSvc.Domain.Enums;
|
||||||
|
using FlatRender.ContentSvc.Infrastructure.Data;
|
||||||
|
using FlatRender.ContentSvc.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FlatRender.ContentSvc.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imports a scanned After Effects project structure (scenes / frl_ frd_ elements /
|
||||||
|
/// frd_ & frshare colours) into a project. Supports a dry-run <see cref="PreviewAsync"/>
|
||||||
|
/// that reports the diff without writing, and an <see cref="ApplyAsync"/> that merges:
|
||||||
|
/// matched items (by key) are refreshed, new items added, manual edits preserved.
|
||||||
|
/// </summary>
|
||||||
|
public class AepImportService(ContentDbContext db)
|
||||||
|
{
|
||||||
|
public Task<ImportDiff> PreviewAsync(Guid projectId, ScanResult scan) => RunAsync(projectId, scan, null);
|
||||||
|
|
||||||
|
public Task<ImportDiff> ApplyAsync(Guid projectId, ScanResult scan, ScanApplyOptions options) =>
|
||||||
|
RunAsync(projectId, scan, options);
|
||||||
|
|
||||||
|
private async Task<ImportDiff> RunAsync(Guid projectId, ScanResult scan, ScanApplyOptions? apply)
|
||||||
|
{
|
||||||
|
var existing = await db.Scenes
|
||||||
|
.Where(s => s.ProjectId == projectId && s.DeletedAt == null)
|
||||||
|
.Include(s => s.ContentElements)
|
||||||
|
.Include(s => s.ColorElements)
|
||||||
|
.ToListAsync();
|
||||||
|
var existingShared = await db.SharedColors.Where(c => c.ProjectId == projectId).ToListAsync();
|
||||||
|
|
||||||
|
var scanScenes = scan.Scenes ?? new List<ScanScene>();
|
||||||
|
var byKey = existing.ToDictionary(s => s.Key, StringComparer.Ordinal);
|
||||||
|
var scanKeys = new HashSet<string>(scanScenes.Select(s => s.Key), StringComparer.Ordinal);
|
||||||
|
|
||||||
|
int sAdded = 0, sChanged = 0, sUnchanged = 0;
|
||||||
|
var diffs = new List<SceneDiff>();
|
||||||
|
int sortBase = existing.Count;
|
||||||
|
|
||||||
|
foreach (var ss in scanScenes)
|
||||||
|
{
|
||||||
|
byKey.TryGetValue(ss.Key, out var scene);
|
||||||
|
bool isNew = scene is null;
|
||||||
|
|
||||||
|
var exElems = scene?.ContentElements.ToList() ?? new List<SceneContentElement>();
|
||||||
|
var exColors = scene?.ColorElements.ToList() ?? new List<SceneColorElement>();
|
||||||
|
var scElems = ss.Elements ?? new List<ScanElement>();
|
||||||
|
var scColors = ss.Colors ?? new List<ScanColor>();
|
||||||
|
|
||||||
|
var exElemByKey = exElems.ToDictionary(e => e.Key, StringComparer.Ordinal);
|
||||||
|
var exColorByKey = exColors.ToDictionary(c => c.ElementKey, StringComparer.Ordinal);
|
||||||
|
var scElemKeys = new HashSet<string>(scElems.Select(e => e.Key), StringComparer.Ordinal);
|
||||||
|
var scColorKeys = new HashSet<string>(scColors.Select(c => c.ElementKey), StringComparer.Ordinal);
|
||||||
|
|
||||||
|
int eAdded = scElems.Count(e => !exElemByKey.ContainsKey(e.Key));
|
||||||
|
int eChanged = scElems.Count(e => exElemByKey.TryGetValue(e.Key, out var ex) && ElementDiffers(e, ex));
|
||||||
|
int eRemoved = exElems.Count(e => !scElemKeys.Contains(e.Key));
|
||||||
|
int cAdded = scColors.Count(c => !exColorByKey.ContainsKey(c.ElementKey));
|
||||||
|
int cChanged = scColors.Count(c => exColorByKey.TryGetValue(c.ElementKey, out var ex) && ColorDiffers(c, ex));
|
||||||
|
int cRemoved = exColors.Count(c => !scColorKeys.Contains(c.ElementKey));
|
||||||
|
|
||||||
|
string status;
|
||||||
|
if (isNew) { status = "added"; sAdded++; }
|
||||||
|
else if (eAdded + eChanged + eRemoved + cAdded + cChanged + cRemoved > 0 || SceneHeaderDiffers(ss, scene!))
|
||||||
|
{ status = "changed"; sChanged++; }
|
||||||
|
else { status = "unchanged"; sUnchanged++; }
|
||||||
|
|
||||||
|
diffs.Add(new SceneDiff(ss.Key, ss.Title ?? ss.Key, status,
|
||||||
|
eAdded, eChanged, eRemoved, cAdded, cChanged, cRemoved));
|
||||||
|
|
||||||
|
if (apply is not null)
|
||||||
|
{
|
||||||
|
if (isNew)
|
||||||
|
{
|
||||||
|
scene = new Scene { ProjectId = projectId, Key = ss.Key, Sort = ss.Sort ?? sortBase++ };
|
||||||
|
db.Scenes.Add(scene);
|
||||||
|
}
|
||||||
|
ApplySceneHeader(scene!, ss, isNew, apply);
|
||||||
|
ApplyElements(scene!, scElems, exElemByKey, scElemKeys, apply);
|
||||||
|
ApplyColors(scene!, scColors, exColorByKey, scColorKeys, apply);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var orphans = existing.Where(s => !scanKeys.Contains(s.Key)).ToList();
|
||||||
|
if (apply is not null && apply.RemoveOrphanScenes)
|
||||||
|
foreach (var s in orphans) s.DeletedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var (shAdded, shChanged, shRemoved) = MergeShared(projectId, scan.SharedColors ?? new List<ScanColor>(), existingShared, apply);
|
||||||
|
|
||||||
|
if (apply is not null) await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return new ImportDiff(
|
||||||
|
apply is not null,
|
||||||
|
sAdded, sChanged, sUnchanged, orphans.Count,
|
||||||
|
shAdded, shChanged, shRemoved,
|
||||||
|
diffs, orphans.Select(o => o.Key).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── scene header ─────────────────────────────────────────────────────────
|
||||||
|
private static bool SceneHeaderDiffers(ScanScene ss, Scene e) =>
|
||||||
|
(ss.SceneType is not null && ParseScene(ss.SceneType) != e.SceneType)
|
||||||
|
|| (ss.DefaultDurationSec is not null && ss.DefaultDurationSec != e.DefaultDurationSec)
|
||||||
|
|| (ss.Title is not null && ss.Title != e.Title);
|
||||||
|
|
||||||
|
private static void ApplySceneHeader(Scene scene, ScanScene ss, bool isNew, ScanApplyOptions o)
|
||||||
|
{
|
||||||
|
if (isNew || o.OverwriteExisting)
|
||||||
|
{
|
||||||
|
scene.Title = ss.Title ?? scene.Title ?? ss.Key;
|
||||||
|
if (ss.SceneType is not null) scene.SceneType = ParseScene(ss.SceneType);
|
||||||
|
if (ss.DefaultDurationSec is not null) scene.DefaultDurationSec = ss.DefaultDurationSec;
|
||||||
|
if (ss.MinDurationSec is not null) scene.MinDurationSec = ss.MinDurationSec;
|
||||||
|
if (ss.MaxDurationSec is not null) scene.MaxDurationSec = ss.MaxDurationSec;
|
||||||
|
if (ss.Sort is not null) scene.Sort = ss.Sort.Value;
|
||||||
|
scene.UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
if (isNew && string.IsNullOrEmpty(scene.Title)) scene.Title = ss.Key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── content elements ──────────────────────────────────────────────────────
|
||||||
|
private static bool ElementDiffers(ScanElement s, SceneContentElement e) =>
|
||||||
|
(s.Type is not null && ParseElemType(s.Type) != e.Type)
|
||||||
|
|| (s.DefaultValue is not null && s.DefaultValue != e.DefaultValue)
|
||||||
|
|| (s.FontFace is not null && s.FontFace != e.FontFace)
|
||||||
|
|| (s.FontSize is not null && s.FontSize != e.FontSize)
|
||||||
|
|| (s.Justify is not null && ParseJustify(s.Justify) != e.Justify)
|
||||||
|
|| (s.IsHidden is not null && s.IsHidden != e.IsHidden);
|
||||||
|
|
||||||
|
private void ApplyElements(Scene scene, List<ScanElement> scElems,
|
||||||
|
Dictionary<string, SceneContentElement> exByKey, HashSet<string> scKeys, ScanApplyOptions o)
|
||||||
|
{
|
||||||
|
int sort = 0;
|
||||||
|
foreach (var se in scElems)
|
||||||
|
{
|
||||||
|
if (exByKey.TryGetValue(se.Key, out var e))
|
||||||
|
{
|
||||||
|
if (o.OverwriteExisting) WriteElement(e, se, scene.Id, sort);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var ne = new SceneContentElement { SceneId = scene.Id, Key = se.Key };
|
||||||
|
WriteElement(ne, se, scene.Id, sort);
|
||||||
|
db.SceneContentElements.Add(ne);
|
||||||
|
}
|
||||||
|
sort++;
|
||||||
|
}
|
||||||
|
if (o.RemoveOrphanElements)
|
||||||
|
foreach (var e in exByKey.Values.Where(e => !scKeys.Contains(e.Key)))
|
||||||
|
db.SceneContentElements.Remove(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteElement(SceneContentElement e, ScanElement s, Guid sceneId, int sort)
|
||||||
|
{
|
||||||
|
e.SceneId = sceneId;
|
||||||
|
e.Title = s.Title ?? (string.IsNullOrEmpty(e.Title) ? s.Key : e.Title);
|
||||||
|
if (s.Type is not null) e.Type = ParseElemType(s.Type);
|
||||||
|
if (s.DefaultValue is not null) e.DefaultValue = s.DefaultValue;
|
||||||
|
if (s.FontFace is not null) { e.FontFace = s.FontFace; e.DefaultFontFace = s.FontFace; }
|
||||||
|
if (s.FontFaceName is not null) e.FontFaceName = s.FontFaceName;
|
||||||
|
if (s.FontSize is not null) { e.FontSize = s.FontSize; e.DefaultFontSize = s.FontSize; }
|
||||||
|
if (s.Justify is not null) e.Justify = ParseJustify(s.Justify);
|
||||||
|
if (s.PositionInContainer is not null) e.PositionInContainer = s.PositionInContainer.Value;
|
||||||
|
if (s.IsTextBox is not null) e.IsTextBox = s.IsTextBox.Value;
|
||||||
|
if (s.MaxSize is not null) e.MaxSize = s.MaxSize;
|
||||||
|
if (s.VideoSupport is not null) e.VideoSupport = s.VideoSupport.Value;
|
||||||
|
if (s.Width is not null) e.Width = s.Width;
|
||||||
|
if (s.Height is not null) e.Height = s.Height;
|
||||||
|
if (s.IsHidden is not null) e.IsHidden = s.IsHidden.Value;
|
||||||
|
if (s.DirectionLayerKey is not null) e.DirectionLayerKey = s.DirectionLayerKey;
|
||||||
|
e.Sort = s.Sort ?? sort;
|
||||||
|
e.UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── colour elements (per scene) ───────────────────────────────────────────
|
||||||
|
private static bool ColorDiffers(ScanColor s, SceneColorElement e) =>
|
||||||
|
(s.DefaultColor is not null && !ColorEq(s.DefaultColor, e.DefaultColor))
|
||||||
|
|| (s.AttrValue is not null && ParseAttr(s.AttrValue) != e.AttrValue)
|
||||||
|
|| (s.Title is not null && s.Title != e.Title);
|
||||||
|
|
||||||
|
private void ApplyColors(Scene scene, List<ScanColor> scColors,
|
||||||
|
Dictionary<string, SceneColorElement> exByKey, HashSet<string> scKeys, ScanApplyOptions o)
|
||||||
|
{
|
||||||
|
int sort = 0;
|
||||||
|
foreach (var sc in scColors)
|
||||||
|
{
|
||||||
|
if (exByKey.TryGetValue(sc.ElementKey, out var e))
|
||||||
|
{
|
||||||
|
if (o.OverwriteExisting) WriteColor(e, sc, scene.Id, sort);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var ne = new SceneColorElement { SceneId = scene.Id, ElementKey = sc.ElementKey, DefaultColor = sc.DefaultColor ?? "#000000" };
|
||||||
|
WriteColor(ne, sc, scene.Id, sort);
|
||||||
|
db.SceneColorElements.Add(ne);
|
||||||
|
}
|
||||||
|
sort++;
|
||||||
|
}
|
||||||
|
if (o.RemoveOrphanElements)
|
||||||
|
foreach (var e in exByKey.Values.Where(e => !scKeys.Contains(e.ElementKey)))
|
||||||
|
db.SceneColorElements.Remove(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteColor(SceneColorElement e, ScanColor s, Guid sceneId, int sort)
|
||||||
|
{
|
||||||
|
e.SceneId = sceneId;
|
||||||
|
e.Title = s.Title ?? (string.IsNullOrEmpty(e.Title) ? s.ElementKey : e.Title);
|
||||||
|
if (s.AttrValue is not null) e.AttrValue = ParseAttr(s.AttrValue);
|
||||||
|
if (s.DefaultColor is not null) e.DefaultColor = s.DefaultColor;
|
||||||
|
e.Sort = s.Sort ?? sort;
|
||||||
|
e.UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── shared colours (project-level) ────────────────────────────────────────
|
||||||
|
private (int added, int changed, int removed) MergeShared(
|
||||||
|
Guid projectId, List<ScanColor> scan, List<SharedColor> existing, ScanApplyOptions? apply)
|
||||||
|
{
|
||||||
|
var byKey = existing.ToDictionary(c => c.ElementKey, StringComparer.Ordinal);
|
||||||
|
var scanKeys = new HashSet<string>(scan.Select(c => c.ElementKey), StringComparer.Ordinal);
|
||||||
|
int added = 0, changed = 0, removed = 0, sort = 0;
|
||||||
|
|
||||||
|
foreach (var sc in scan)
|
||||||
|
{
|
||||||
|
if (byKey.TryGetValue(sc.ElementKey, out var e))
|
||||||
|
{
|
||||||
|
if (ColorDiffersShared(sc, e)) changed++;
|
||||||
|
if (apply is not null && apply.OverwriteExisting)
|
||||||
|
{
|
||||||
|
e.Title = sc.Title ?? e.Title;
|
||||||
|
if (sc.AttrValue is not null) e.AttrValue = ParseAttr(sc.AttrValue);
|
||||||
|
if (sc.DefaultColor is not null) e.DefaultColor = sc.DefaultColor;
|
||||||
|
e.Sort = sc.Sort ?? e.Sort;
|
||||||
|
e.UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
added++;
|
||||||
|
if (apply is not null)
|
||||||
|
db.SharedColors.Add(new SharedColor
|
||||||
|
{
|
||||||
|
ProjectId = projectId, ElementKey = sc.ElementKey, Title = sc.Title ?? sc.ElementKey,
|
||||||
|
AttrValue = ParseAttr(sc.AttrValue), DefaultColor = sc.DefaultColor ?? "#000000", Sort = sc.Sort ?? sort,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sort++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var orphans = existing.Where(c => !scanKeys.Contains(c.ElementKey)).ToList();
|
||||||
|
removed = orphans.Count;
|
||||||
|
if (apply is not null && apply.RemoveOrphanElements)
|
||||||
|
foreach (var c in orphans) db.SharedColors.Remove(c);
|
||||||
|
|
||||||
|
return (added, changed, removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ColorDiffersShared(ScanColor s, SharedColor e) =>
|
||||||
|
(s.DefaultColor is not null && !ColorEq(s.DefaultColor, e.DefaultColor))
|
||||||
|
|| (s.AttrValue is not null && ParseAttr(s.AttrValue) != e.AttrValue)
|
||||||
|
|| (s.Title is not null && s.Title != e.Title);
|
||||||
|
|
||||||
|
// ── parsing helpers ───────────────────────────────────────────────────────
|
||||||
|
private static bool ColorEq(string? a, string? b) =>
|
||||||
|
string.Equals((a ?? "").Trim().TrimStart('#'), (b ?? "").Trim().TrimStart('#'), StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static SceneKind ParseScene(string? v) =>
|
||||||
|
Enum.TryParse<SceneKind>(v, true, out var k) ? k : SceneKind.Normal;
|
||||||
|
|
||||||
|
private static ContentElementType ParseElemType(string? v) =>
|
||||||
|
Enum.TryParse<ContentElementType>(v, true, out var t) ? t : ContentElementType.Text;
|
||||||
|
|
||||||
|
private static JustifyKind ParseJustify(string? v) =>
|
||||||
|
Enum.TryParse<JustifyKind>(v, true, out var j) ? j : JustifyKind.CENTER_JUSTIFY;
|
||||||
|
|
||||||
|
private static AttrValueKind ParseAttr(string? v) =>
|
||||||
|
Enum.TryParse<AttrValueKind>(v, true, out var a) ? a : AttrValueKind.fill;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using FlatRender.ContentSvc.Application.Services;
|
||||||
|
using FlatRender.ContentSvc.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace FlatRender.ContentSvc.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Import a scanned AE project structure into a project's scenes/elements/colours.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("v1/projects")]
|
||||||
|
public class AepImportController(AepImportService svc) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>Dry run — returns the diff (added/changed/removed) without writing.</summary>
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
[HttpPost("{id:guid}/scan/preview")]
|
||||||
|
public async Task<IActionResult> Preview(Guid id, [FromBody] ScanImportRequest req)
|
||||||
|
{
|
||||||
|
if (req?.Scan is null) return BadRequest(new { message = "scan is required" });
|
||||||
|
return Ok(await svc.PreviewAsync(id, req.Scan));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Apply the scan, merging into the project (matched items refreshed, new added).</summary>
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
[HttpPost("{id:guid}/scan/apply")]
|
||||||
|
public async Task<IActionResult> Apply(Guid id, [FromBody] ScanImportRequest req)
|
||||||
|
{
|
||||||
|
if (req?.Scan is null) return BadRequest(new { message = "scan is required" });
|
||||||
|
return Ok(await svc.ApplyAsync(id, req.Scan, req.Options ?? new ScanApplyOptions()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
namespace FlatRender.ContentSvc.Models;
|
||||||
|
|
||||||
|
// ── Canonical scan result ────────────────────────────────────────────────────
|
||||||
|
// Produced by either engine (the AE-JSX scanner on a render node, or the headless
|
||||||
|
// Go quick-scan) and consumed by AepImportService. Both engines emit this shape;
|
||||||
|
// the Go scan simply leaves colour/font/text fields null.
|
||||||
|
|
||||||
|
public record ScanResult(
|
||||||
|
string? Source, // "ae-jsx" | "go-parser"
|
||||||
|
string? RenderComp, // final render comp name, e.g. "frfinal"
|
||||||
|
List<ScanScene>? Scenes,
|
||||||
|
List<ScanColor>? SharedColors
|
||||||
|
);
|
||||||
|
|
||||||
|
public record ScanScene(
|
||||||
|
string Key, // AE comp name
|
||||||
|
string? Title,
|
||||||
|
string? SceneType, // Normal | Config | DesignStart | DesignEnd
|
||||||
|
decimal? DefaultDurationSec,
|
||||||
|
decimal? MinDurationSec,
|
||||||
|
decimal? MaxDurationSec,
|
||||||
|
int? Sort,
|
||||||
|
List<ScanElement>? Elements, // frl_/frd_ editable layers
|
||||||
|
List<ScanColor>? Colors // per-scene frd_ colour zones
|
||||||
|
);
|
||||||
|
|
||||||
|
public record ScanElement(
|
||||||
|
string Key, // AE layer name (frl_… / frd_…)
|
||||||
|
string? Title,
|
||||||
|
string? Type, // Text | TextArea | Media | Audio | … (content_element_type)
|
||||||
|
string? DefaultValue,
|
||||||
|
string? FontFace,
|
||||||
|
string? FontFaceName,
|
||||||
|
int? FontSize,
|
||||||
|
string? Justify, // LEFT_JUSTIFY | CENTER_JUSTIFY | RIGHT_JUSTIFY | FULL_JUSTIFY
|
||||||
|
int? PositionInContainer,
|
||||||
|
bool? IsTextBox,
|
||||||
|
int? MaxSize,
|
||||||
|
bool? VideoSupport,
|
||||||
|
int? Width,
|
||||||
|
int? Height,
|
||||||
|
bool? IsHidden,
|
||||||
|
string? DirectionLayerKey,
|
||||||
|
int? Sort
|
||||||
|
);
|
||||||
|
|
||||||
|
public record ScanColor(
|
||||||
|
string ElementKey, // AE frd_ layer name / shared colour key
|
||||||
|
string? Title,
|
||||||
|
string? AttrValue, // fill | stroke | tracking | dropshadow
|
||||||
|
string? DefaultColor, // #RRGGBB
|
||||||
|
int? Sort
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Import request + diff ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public record ScanApplyOptions(
|
||||||
|
bool RemoveOrphanScenes = false, // soft-delete scenes not present in the scan
|
||||||
|
bool RemoveOrphanElements = false, // delete elements/colours not present in the scan
|
||||||
|
bool OverwriteExisting = true // refresh matched items from the scan (null scan fields are kept)
|
||||||
|
);
|
||||||
|
|
||||||
|
public record ScanImportRequest(
|
||||||
|
ScanResult Scan,
|
||||||
|
ScanApplyOptions? Options
|
||||||
|
);
|
||||||
|
|
||||||
|
public record SceneDiff(
|
||||||
|
string Key, string Title, string Status, // added | changed | unchanged | orphan
|
||||||
|
int ElementsAdded, int ElementsChanged, int ElementsRemoved,
|
||||||
|
int ColorsAdded, int ColorsChanged, int ColorsRemoved
|
||||||
|
);
|
||||||
|
|
||||||
|
public record ImportDiff(
|
||||||
|
bool Applied,
|
||||||
|
int ScenesAdded, int ScenesChanged, int ScenesUnchanged, int ScenesOrphan,
|
||||||
|
int SharedColorsAdded, int SharedColorsChanged, int SharedColorsRemoved,
|
||||||
|
List<SceneDiff> Scenes,
|
||||||
|
List<string> OrphanSceneKeys
|
||||||
|
);
|
||||||
@@ -65,6 +65,7 @@ builder.Services.AddScoped<TemplateService>();
|
|||||||
builder.Services.AddScoped<CmsService>();
|
builder.Services.AddScoped<CmsService>();
|
||||||
builder.Services.AddScoped<AiContentService>();
|
builder.Services.AddScoped<AiContentService>();
|
||||||
builder.Services.AddScoped<SceneColorService>();
|
builder.Services.AddScoped<SceneColorService>();
|
||||||
|
builder.Services.AddScoped<AepImportService>();
|
||||||
|
|
||||||
// HTTP client for the OpenAI-compatible AI provider (base URL is per-tenant config).
|
// HTTP client for the OpenAI-compatible AI provider (base URL is per-tenant config).
|
||||||
builder.Services.AddHttpClient("openai");
|
builder.Services.AddHttpClient("openai");
|
||||||
|
|||||||
@@ -138,6 +138,9 @@ func main() {
|
|||||||
v1.Any("/node-fonts/*path", apiRL, auth, render.Handler())
|
v1.Any("/node-fonts/*path", apiRL, auth, render.Handler())
|
||||||
v1.Any("/admin-exports/*path", apiRL, auth, render.Handler())
|
v1.Any("/admin-exports/*path", apiRL, auth, render.Handler())
|
||||||
v1.Any("/admin-renders", apiRL, auth, render.Handler())
|
v1.Any("/admin-renders", apiRL, auth, render.Handler())
|
||||||
|
v1.Any("/template-bundles/*path", apiRL, auth, render.Handler())
|
||||||
|
v1.Any("/template-scans/*path", apiRL, auth, render.Handler())
|
||||||
|
v1.Any("/template-scan-jobs/*path", apiRL, auth, render.Handler())
|
||||||
v1.Any("/node-updates/*path", apiRL, auth, render.Handler())
|
v1.Any("/node-updates/*path", apiRL, auth, render.Handler())
|
||||||
|
|
||||||
// ── Notification Service ──────────────────────────────────────────────────
|
// ── Notification Service ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ type Agent struct {
|
|||||||
orch *client.Client
|
orch *client.Client
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
currentJob *client.ClaimedJob
|
currentJob *client.ClaimedJob
|
||||||
|
scanning bool // true while running an AE scan job (shares the AE app)
|
||||||
status string // "Ready" | "Busy"
|
status string // "Ready" | "Busy"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,9 +76,25 @@ func (a *Agent) getStatus() (string, *string) {
|
|||||||
jobID := a.currentJob.JobID
|
jobID := a.currentJob.JobID
|
||||||
return a.status, &jobID
|
return a.status, &jobID
|
||||||
}
|
}
|
||||||
|
if a.scanning {
|
||||||
|
return "Busy", nil
|
||||||
|
}
|
||||||
return a.status, 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 ──────────────────────────────────────────────────────────────────────
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -121,10 +138,11 @@ func main() {
|
|||||||
|
|
||||||
// Main loops
|
// Main loops
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(3)
|
wg.Add(4)
|
||||||
go func() { defer wg.Done(); agent.heartbeatLoop(ctx) }()
|
go func() { defer wg.Done(); agent.heartbeatLoop(ctx) }()
|
||||||
go func() { defer wg.Done(); agent.pollLoop(ctx) }()
|
go func() { defer wg.Done(); agent.pollLoop(ctx) }()
|
||||||
go func() { defer wg.Done(); agent.fontSyncLoop(ctx) }()
|
go func() { defer wg.Done(); agent.fontSyncLoop(ctx) }()
|
||||||
|
go func() { defer wg.Done(); agent.scanLoop(ctx) }()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
log.Printf("shutdown complete")
|
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 ────────────────────────────────────────────────────────────
|
// ── Heartbeat loop ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (a *Agent) heartbeatLoop(ctx context.Context) {
|
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) {
|
func (a *Agent) runJob(ctx context.Context, job *client.ClaimedJob) {
|
||||||
log.Printf("[job %s] starting render", job.JobID)
|
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 := ""
|
aepPath := ""
|
||||||
if job.AEPDownloadURL != "" && a.cfg.AEPath != "" {
|
if job.AEPDownloadURL != "" && a.cfg.AEPath != "" {
|
||||||
localAEP := filepath.Join(a.cfg.WorkDir, "templates", job.JobID, "template.aep")
|
dlCtx, dlCancel := context.WithTimeout(ctx, 15*time.Minute)
|
||||||
dlCtx, dlCancel := context.WithTimeout(ctx, 10*time.Minute)
|
p, prepErr := runner.PrepareTemplate(dlCtx, job.AEPDownloadURL, job.IsBundle, job.BundleMD5, a.cfg.WorkDir, job.JobID)
|
||||||
n, dlErr := runner.DownloadFile(dlCtx, job.AEPDownloadURL, localAEP)
|
|
||||||
dlCancel()
|
dlCancel()
|
||||||
if dlErr != nil {
|
if prepErr != nil {
|
||||||
log.Printf("[job %s] AEP download failed (%v) — falling back to mock", job.JobID, dlErr)
|
log.Printf("[job %s] template prepare failed (%v) — falling back to mock", job.JobID, prepErr)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("[job %s] AEP downloaded (%d bytes) → %s", job.JobID, n, localAEP)
|
kind := "aep"
|
||||||
aepPath = localAEP
|
if job.IsBundle {
|
||||||
|
kind = "bundle"
|
||||||
|
}
|
||||||
|
log.Printf("[job %s] template ready (%s) → %s", job.JobID, kind, p)
|
||||||
|
aepPath = p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,9 +148,15 @@ type ClaimedJob struct {
|
|||||||
FrameRate int `json:"frame_rate"`
|
FrameRate int `json:"frame_rate"`
|
||||||
HasMusic bool `json:"has_music"`
|
HasMusic bool `json:"has_music"`
|
||||||
HasVoiceover bool `json:"has_voiceover"`
|
HasVoiceover bool `json:"has_voiceover"`
|
||||||
// AEPDownloadURL is a presigned MinIO GET URL for the .aep template file.
|
// AEPDownloadURL is a presigned MinIO GET URL for the .aep template file
|
||||||
// Empty when the template has not been uploaded yet — triggers mock render.
|
// (or .zip bundle). Empty when the template has not been uploaded yet — triggers mock render.
|
||||||
AEPDownloadURL string `json:"aep_download_url,omitempty"`
|
AEPDownloadURL string `json:"aep_download_url,omitempty"`
|
||||||
|
// IsBundle is true when AEPDownloadURL points to a .zip bundle (.aep + footage/fonts)
|
||||||
|
// that must be extracted before rendering.
|
||||||
|
IsBundle bool `json:"is_bundle,omitempty"`
|
||||||
|
// BundleMD5 identifies the bundle content; used as a local cache key so repeated
|
||||||
|
// renders of the same template download + extract it only once.
|
||||||
|
BundleMD5 string `json:"bundle_md5,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OutputUploadURLResponse is returned by GetOutputUploadURL.
|
// OutputUploadURLResponse is returned by GetOutputUploadURL.
|
||||||
@@ -240,6 +246,66 @@ func (c *Client) ClaimJob(ctx context.Context, nodeID, region string) (*ClaimedJ
|
|||||||
return &job, nil
|
return &job, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScanClaim is returned when an AE scan job is claimed.
|
||||||
|
type ScanClaim struct {
|
||||||
|
ScanJobID string `json:"scan_job_id"`
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
AEPDownloadURL string `json:"aep_download_url"`
|
||||||
|
IsBundle bool `json:"is_bundle"`
|
||||||
|
BundleMD5 string `json:"bundle_md5"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimScan atomically claims the next queued AE scan job.
|
||||||
|
// Returns (nil, nil) when nothing is queued (204 No Content).
|
||||||
|
func (c *Client) ClaimScan(ctx context.Context, nodeID, region string) (*ScanClaim, error) {
|
||||||
|
resp, err := c.do(ctx, http.MethodPost, "/v1/internal/scan/claim",
|
||||||
|
ClaimJobRequest{NodeID: nodeID, Region: region})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusNoContent {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("scan claim: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var sc ScanClaim
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&sc); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan claim decode: %w", err)
|
||||||
|
}
|
||||||
|
return &sc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportScanResult posts the raw ScanResult JSON produced by scan.jsx.
|
||||||
|
func (c *Client) ReportScanResult(ctx context.Context, scanJobID string, resultJSON []byte) error {
|
||||||
|
// json.RawMessage marshals to its raw bytes, so the body is the JSON verbatim.
|
||||||
|
resp, err := c.do(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("/v1/internal/scan/%s/result", scanJobID), json.RawMessage(resultJSON))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("scan result: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportScanFail marks a scan job as failed.
|
||||||
|
func (c *Client) ReportScanFail(ctx context.Context, scanJobID, reason string) error {
|
||||||
|
resp, err := c.do(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("/v1/internal/scan/%s/fail", scanJobID), FailRequest{Reason: reason})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("scan fail: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// UpdatePreview sends a base64-encoded preview frame to the orchestrator.
|
// UpdatePreview sends a base64-encoded preview frame to the orchestrator.
|
||||||
// Errors are non-fatal — the UI simply won't update the preview image.
|
// Errors are non-fatal — the UI simply won't update the preview image.
|
||||||
func (c *Client) UpdatePreview(ctx context.Context, jobID, imageB64 string) error {
|
func (c *Client) UpdatePreview(ctx context.Context, jobID, imageB64 string) error {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,6 +31,11 @@ type Config struct {
|
|||||||
// Leave empty to use mock rendering (for development / testing without AE).
|
// Leave empty to use mock rendering (for development / testing without AE).
|
||||||
AEPath string
|
AEPath string
|
||||||
|
|
||||||
|
// AfterFxPath is the full path to afterfx.exe (the AE app, used to run the
|
||||||
|
// template scanner script). Defaults to afterfx.exe alongside aerender.exe.
|
||||||
|
// Leave AEPath empty too to disable scanning (dev/mock).
|
||||||
|
AfterFxPath string
|
||||||
|
|
||||||
// WorkDir is the scratch directory for render temp files and AE project copies.
|
// WorkDir is the scratch directory for render temp files and AE project copies.
|
||||||
WorkDir string
|
WorkDir string
|
||||||
|
|
||||||
@@ -59,6 +65,7 @@ func Load() (*Config, error) {
|
|||||||
NodeHMACSecret: getEnv("NODE_HMAC_SECRET", "node-secret-change-me"),
|
NodeHMACSecret: getEnv("NODE_HMAC_SECRET", "node-secret-change-me"),
|
||||||
Region: getEnv("NODE_REGION", ""),
|
Region: getEnv("NODE_REGION", ""),
|
||||||
AEPath: getEnv("AE_PATH", ""),
|
AEPath: getEnv("AE_PATH", ""),
|
||||||
|
AfterFxPath: getEnv("AFTERFX_PATH", ""),
|
||||||
WorkDir: getEnv("WORK_DIR", os.TempDir()),
|
WorkDir: getEnv("WORK_DIR", os.TempDir()),
|
||||||
AgentVersion: getEnv("AGENT_VERSION", "0.1.0"),
|
AgentVersion: getEnv("AGENT_VERSION", "0.1.0"),
|
||||||
AEVersion: getEnv("AE_VERSION", "2024"),
|
AEVersion: getEnv("AE_VERSION", "2024"),
|
||||||
@@ -69,6 +76,10 @@ func Load() (*Config, error) {
|
|||||||
if c.NodeID == "" {
|
if c.NodeID == "" {
|
||||||
return nil, fmt.Errorf("NODE_ID environment variable is required")
|
return nil, fmt.Errorf("NODE_ID environment variable is required")
|
||||||
}
|
}
|
||||||
|
// Derive afterfx.exe next to aerender.exe when not explicitly set.
|
||||||
|
if c.AfterFxPath == "" && c.AEPath != "" {
|
||||||
|
c.AfterFxPath = filepath.Join(filepath.Dir(c.AEPath), "afterfx.exe")
|
||||||
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// bundle.go prepares an After Effects template for rendering. A template may be a
|
||||||
|
// plain .aep/.aepx file, or a .zip bundle containing the .aep plus its footage and
|
||||||
|
// fonts. Bundles are extracted so aerender can resolve relative footage paths.
|
||||||
|
//
|
||||||
|
// Prepared templates are cached on disk keyed by the bundle's MD5, so when many
|
||||||
|
// renders use the same template the node downloads + extracts it only once.
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PrepareTemplate ensures the template referenced by url is available locally and
|
||||||
|
// returns the absolute path to the .aep/.aepx file aerender should open.
|
||||||
|
//
|
||||||
|
// - isBundle=false: url is a raw project file; it is downloaded as-is.
|
||||||
|
// - isBundle=true: url is a .zip; it is downloaded and extracted, then the .aep
|
||||||
|
// inside is located.
|
||||||
|
//
|
||||||
|
// md5 (when non-empty) is the cache key: a prepared template with the same md5 is
|
||||||
|
// reused without re-downloading. baseDir is the agent work dir; jobID is the
|
||||||
|
// fallback cache key when md5 is empty.
|
||||||
|
func PrepareTemplate(ctx context.Context, url string, isBundle bool, md5, baseDir, jobID string) (string, error) {
|
||||||
|
cacheKey := md5
|
||||||
|
if cacheKey == "" {
|
||||||
|
cacheKey = jobID // no md5 → per-job, no reuse
|
||||||
|
}
|
||||||
|
cacheDir := filepath.Join(baseDir, "templates", "cache", sanitize(cacheKey))
|
||||||
|
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("mkdir cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isBundle {
|
||||||
|
aepPath := filepath.Join(cacheDir, "template.aep")
|
||||||
|
if fileExists(aepPath) {
|
||||||
|
return aepPath, nil // cache hit
|
||||||
|
}
|
||||||
|
if _, err := DownloadFile(ctx, url, aepPath); err != nil {
|
||||||
|
return "", fmt.Errorf("download aep: %w", err)
|
||||||
|
}
|
||||||
|
return aepPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bundle: the resolved .aep path is remembered in a marker so cache hits skip
|
||||||
|
// both download and extraction.
|
||||||
|
marker := filepath.Join(cacheDir, "aep_path.txt")
|
||||||
|
if b, err := os.ReadFile(marker); err == nil {
|
||||||
|
if p := strings.TrimSpace(string(b)); p != "" && fileExists(p) {
|
||||||
|
return p, nil // cache hit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zipPath := filepath.Join(cacheDir, "bundle.zip")
|
||||||
|
if _, err := DownloadFile(ctx, url, zipPath); err != nil {
|
||||||
|
return "", fmt.Errorf("download bundle: %w", err)
|
||||||
|
}
|
||||||
|
extractDir := filepath.Join(cacheDir, "extracted")
|
||||||
|
if err := os.RemoveAll(extractDir); err != nil {
|
||||||
|
return "", fmt.Errorf("clean extract dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := ExtractZip(zipPath, extractDir); err != nil {
|
||||||
|
return "", fmt.Errorf("extract bundle: %w", err)
|
||||||
|
}
|
||||||
|
aepPath, err := FindAEP(extractDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(marker, []byte(aepPath), 0o644)
|
||||||
|
_ = os.Remove(zipPath) // free space — the extracted tree is what we render from
|
||||||
|
return aepPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractZip unpacks the zip at zipPath into destDir, guarding against path
|
||||||
|
// traversal ("zip slip"). Directories and parents are created as needed.
|
||||||
|
func ExtractZip(zipPath, destDir string) error {
|
||||||
|
zr, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open zip: %w", err)
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
destAbs, err := filepath.Abs(destDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range zr.File {
|
||||||
|
// Clean and confine the target path to destDir.
|
||||||
|
target := filepath.Join(destDir, f.Name) // #nosec G305 — validated below
|
||||||
|
targetAbs, aerr := filepath.Abs(target)
|
||||||
|
if aerr != nil {
|
||||||
|
return aerr
|
||||||
|
}
|
||||||
|
if targetAbs != destAbs && !strings.HasPrefix(targetAbs, destAbs+string(os.PathSeparator)) {
|
||||||
|
return fmt.Errorf("illegal path in zip: %q", f.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.FileInfo().IsDir() {
|
||||||
|
if err := os.MkdirAll(targetAbs, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(targetAbs), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := extractOne(f, targetAbs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractOne(f *zip.File, target string) error {
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
// #nosec G110 — template bundles are admin-uploaded, not untrusted user input.
|
||||||
|
if _, err := io.Copy(out, rc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAEP locates the After Effects project file within an extracted bundle. It
|
||||||
|
// prefers the shallowest file (fewest path segments), .aep over .aepx, and ignores
|
||||||
|
// macOS resource-fork siblings ("._name") and __MACOSX entries.
|
||||||
|
func FindAEP(root string) (string, error) {
|
||||||
|
var matches []string
|
||||||
|
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
if info.Name() == "__MACOSX" {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := info.Name()
|
||||||
|
if strings.HasPrefix(name, "._") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(name))
|
||||||
|
if ext == ".aep" || ext == ".aepx" {
|
||||||
|
matches = append(matches, p)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("walk bundle: %w", err)
|
||||||
|
}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return "", fmt.Errorf("no .aep file found in bundle")
|
||||||
|
}
|
||||||
|
sort.Slice(matches, func(i, j int) bool {
|
||||||
|
di, dj := strings.Count(matches[i], string(os.PathSeparator)), strings.Count(matches[j], string(os.PathSeparator))
|
||||||
|
if di != dj {
|
||||||
|
return di < dj // shallower first
|
||||||
|
}
|
||||||
|
ei, ej := strings.ToLower(filepath.Ext(matches[i])), strings.ToLower(filepath.Ext(matches[j]))
|
||||||
|
if ei != ej {
|
||||||
|
return ei == ".aep" // .aep before .aepx
|
||||||
|
}
|
||||||
|
return matches[i] < matches[j]
|
||||||
|
})
|
||||||
|
return matches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(p string) bool {
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitize keeps a cache key safe for use as a directory name.
|
||||||
|
func sanitize(s string) string {
|
||||||
|
return strings.Map(func(r rune) rune {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||||
|
return r
|
||||||
|
default:
|
||||||
|
return '_'
|
||||||
|
}
|
||||||
|
}, s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeZip builds a zip at path from the given name→content entries (dirs implied
|
||||||
|
// by trailing slash). Returns the path.
|
||||||
|
func writeZip(t *testing.T, path string, entries map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create zip: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
zw := zip.NewWriter(f)
|
||||||
|
for name, content := range entries {
|
||||||
|
w, err := zw.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zip create entry %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write([]byte(content)); err != nil {
|
||||||
|
t.Fatalf("zip write %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
t.Fatalf("close zip: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractAndFindAEP(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
zipPath := filepath.Join(dir, "bundle.zip")
|
||||||
|
// Realistic bundle: project + footage, plus a macOS resource-fork decoy that
|
||||||
|
// must NOT be chosen as the project file.
|
||||||
|
writeZip(t, zipPath, map[string]string{
|
||||||
|
"MyTemplate/template.aep": "AEP-DATA",
|
||||||
|
"MyTemplate/footage/clip.mp4": "VIDEO",
|
||||||
|
"MyTemplate/fonts/Vazir.ttf": "FONT",
|
||||||
|
"__MACOSX/MyTemplate/._template.aep": "RESOURCE-FORK",
|
||||||
|
})
|
||||||
|
|
||||||
|
dest := filepath.Join(dir, "extracted")
|
||||||
|
if err := ExtractZip(zipPath, dest); err != nil {
|
||||||
|
t.Fatalf("ExtractZip: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// footage + font extracted
|
||||||
|
if _, err := os.Stat(filepath.Join(dest, "MyTemplate", "footage", "clip.mp4")); err != nil {
|
||||||
|
t.Errorf("footage not extracted: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dest, "MyTemplate", "fonts", "Vazir.ttf")); err != nil {
|
||||||
|
t.Errorf("font not extracted: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
aep, err := FindAEP(dest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindAEP: %v", err)
|
||||||
|
}
|
||||||
|
if filepath.Base(aep) != "template.aep" {
|
||||||
|
t.Errorf("expected template.aep, got %s", aep)
|
||||||
|
}
|
||||||
|
if strings.Contains(aep, "__MACOSX") || strings.Contains(filepath.Base(aep), "._") {
|
||||||
|
t.Errorf("FindAEP picked a macOS resource-fork decoy: %s", aep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindAEPPrefersShallowAndAepOverAepx(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// deep .aepx + shallow .aep — shallow .aep must win.
|
||||||
|
mustWrite(t, filepath.Join(dir, "a", "b", "deep.aepx"), "x")
|
||||||
|
mustWrite(t, filepath.Join(dir, "root.aep"), "x")
|
||||||
|
aep, err := FindAEP(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindAEP: %v", err)
|
||||||
|
}
|
||||||
|
if filepath.Base(aep) != "root.aep" {
|
||||||
|
t.Errorf("expected root.aep (shallow, .aep), got %s", aep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindAEPNoneFound(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
mustWrite(t, filepath.Join(dir, "readme.txt"), "no project here")
|
||||||
|
if _, err := FindAEP(dir); err == nil {
|
||||||
|
t.Error("expected error when no .aep present, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractZipRejectsZipSlip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
zipPath := filepath.Join(dir, "evil.zip")
|
||||||
|
writeZip(t, zipPath, map[string]string{
|
||||||
|
"../escape.txt": "pwned",
|
||||||
|
})
|
||||||
|
dest := filepath.Join(dir, "extracted")
|
||||||
|
if err := ExtractZip(zipPath, dest); err == nil {
|
||||||
|
t.Error("expected zip-slip to be rejected, got nil error")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "escape.txt")); err == nil {
|
||||||
|
t.Error("zip-slip wrote a file outside the destination dir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustWrite(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,6 +111,9 @@ func aeRender(ctx context.Context, aePath string, job *Job, outputPath string, o
|
|||||||
|
|
||||||
log.Printf("[ae] running: %s %v", aePath, args)
|
log.Printf("[ae] running: %s %v", aePath, args)
|
||||||
cmd := exec.CommandContext(ctx, aePath, args...)
|
cmd := exec.CommandContext(ctx, aePath, args...)
|
||||||
|
// Run from the project's folder so a .zip bundle's relative footage/font paths
|
||||||
|
// resolve correctly (the .aep sits alongside its assets after extraction).
|
||||||
|
cmd.Dir = filepath.Dir(job.AEPFilePath)
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed scan.jsx
|
||||||
|
var scanScript []byte
|
||||||
|
|
||||||
|
// WriteScanScript writes the embedded scanner JSX into workDir and returns its path.
|
||||||
|
func WriteScanScript(workDir string) (string, error) {
|
||||||
|
dir := filepath.Join(workDir, "scripts")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
p := filepath.Join(dir, "scan.jsx")
|
||||||
|
if err := os.WriteFile(p, scanScript, 0o644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunScan runs the After Effects template scanner against aepPath and returns the
|
||||||
|
// JSON the script writes to outPath. afterfxPath must be afterfx.exe (the AE app,
|
||||||
|
// not aerender.exe — only the app can run ScriptUI/ExtendScript).
|
||||||
|
//
|
||||||
|
// afterfx -r runs the script and the script calls app.quit(); we still poll for the
|
||||||
|
// output file because afterfx can return before the file is flushed.
|
||||||
|
func RunScan(ctx context.Context, afterfxPath, aepPath, workDir, outPath string) ([]byte, error) {
|
||||||
|
scriptPath, err := WriteScanScript(workDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("write scan script: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = os.Remove(outPath)
|
||||||
|
_ = os.Remove(outPath + ".error")
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, afterfxPath, "-r", scriptPath)
|
||||||
|
cmd.Env = append(os.Environ(), "FR_SCAN_AEP="+aepPath, "FR_SCAN_OUT="+outPath)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
// afterfx may exit non-zero on app.quit() — don't treat the exit code as fatal;
|
||||||
|
// success is determined by the presence of the output JSON file.
|
||||||
|
_ = cmd.Run()
|
||||||
|
|
||||||
|
for {
|
||||||
|
if eb, rerr := os.ReadFile(outPath + ".error"); rerr == nil && len(eb) > 0 {
|
||||||
|
return nil, fmt.Errorf("scan script error: %s", string(eb))
|
||||||
|
}
|
||||||
|
if b, rerr := os.ReadFile(outPath); rerr == nil && len(b) > 0 {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, fmt.Errorf("scan timed out waiting for %s", outPath)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/*
|
||||||
|
* FlatRender — After Effects template SCANNER (read-only / ExtendScript).
|
||||||
|
*
|
||||||
|
* Walks an opened .aep project and emits its structure as JSON for the content
|
||||||
|
* service to import (scenes, frl_/frd_ elements, frd_/frshare colours).
|
||||||
|
*
|
||||||
|
* Launch (headless), with the node agent setting env vars first:
|
||||||
|
* SET FR_SCAN_AEP=C:\work\templates\cache\<md5>\extracted\proj\template.aep
|
||||||
|
* SET FR_SCAN_OUT=C:\work\scans\<job>\scan.json
|
||||||
|
* "...\Adobe After Effects <ver>\Support Files\afterfx.exe" -r "...\scan.jsx"
|
||||||
|
*
|
||||||
|
* Conventions (mirrors the legacy NewBrain/JSX binder, in reverse):
|
||||||
|
* comp "frfinal" → final render comp (recorded, not a scene)
|
||||||
|
* comp "frshare" → shared colours: each layer name = key, its "Source Text" = colour value
|
||||||
|
* layer frl_* → editable element: TextLayer→Text (font/size/justify/text), else Media/Audio
|
||||||
|
* layer frd_* → data/direction layer; if its text looks like a colour → per-scene colour zone
|
||||||
|
* any comp containing frl_/frd_ layers → a scene (key = comp name, duration = comp.duration)
|
||||||
|
*
|
||||||
|
* Output shape == content-svc ScanResult:
|
||||||
|
* { source, render_comp, scenes:[{key,title,scene_type,default_duration_sec,sort,
|
||||||
|
* elements:[{key,title,type,default_value,font_face,font_size,justify,width,height,video_support,is_hidden,sort}],
|
||||||
|
* colors:[{element_key,title,attr_value,default_color,sort}] }],
|
||||||
|
* shared_colors:[{element_key,title,attr_value,default_color,sort}] }
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
function getenv(name) { try { return $.getenv(name); } catch (e) { return null; } }
|
||||||
|
|
||||||
|
var aepPath = getenv("FR_SCAN_AEP");
|
||||||
|
var outPath = getenv("FR_SCAN_OUT") || (Folder.temp.fsName + "/fr_scan.json");
|
||||||
|
|
||||||
|
// ── minimal JSON serializer (older AE has no JSON.stringify) ──────────────
|
||||||
|
function esc(s) {
|
||||||
|
s = String(s); var out = "", i, c;
|
||||||
|
for (i = 0; i < s.length; i++) {
|
||||||
|
c = s.charAt(i);
|
||||||
|
if (c === '"') out += '\\"';
|
||||||
|
else if (c === '\\') out += '\\\\';
|
||||||
|
else if (c === '\n') out += '\\n';
|
||||||
|
else if (c === '\r') out += '\\r';
|
||||||
|
else if (c === '\t') out += '\\t';
|
||||||
|
else if (c < ' ') out += '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4);
|
||||||
|
else out += c;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function jstr(v) {
|
||||||
|
if (v === null || v === undefined) return "null";
|
||||||
|
var t = typeof v;
|
||||||
|
if (t === "number") return isFinite(v) ? String(v) : "null";
|
||||||
|
if (t === "boolean") return v ? "true" : "false";
|
||||||
|
if (t === "string") return '"' + esc(v) + '"';
|
||||||
|
if (v instanceof Array) {
|
||||||
|
var a = [], i; for (i = 0; i < v.length; i++) a.push(jstr(v[i]));
|
||||||
|
return "[" + a.join(",") + "]";
|
||||||
|
}
|
||||||
|
var props = [], k;
|
||||||
|
for (k in v) { if (v.hasOwnProperty(k)) props.push('"' + esc(k) + '":' + jstr(v[k])); }
|
||||||
|
return "{" + props.join(",") + "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── colour helpers ────────────────────────────────────────────────────────
|
||||||
|
function trim(s) { return String(s).replace(/^\s+|\s+$/g, ""); }
|
||||||
|
function isColor(s) {
|
||||||
|
if (!s) return false; s = trim(s);
|
||||||
|
return /^#?[0-9a-fA-F]{6}$/.test(s) || /^\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}$/.test(s);
|
||||||
|
}
|
||||||
|
function normColor(s) {
|
||||||
|
s = trim(s);
|
||||||
|
if (/^[0-9a-fA-F]{6}$/.test(s)) return "#" + s; // bare hex → add #
|
||||||
|
var m = s.match(/^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})$/); // r,g,b → #hex
|
||||||
|
if (m) {
|
||||||
|
function h(n) { n = Math.max(0, Math.min(255, parseInt(n, 10))); var x = n.toString(16); return x.length === 1 ? "0" + x : x; }
|
||||||
|
return "#" + h(m[1]) + h(m[2]) + h(m[3]);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function justifyName(j) {
|
||||||
|
try {
|
||||||
|
if (j === ParagraphJustification.LEFT_JUSTIFY) return "LEFT_JUSTIFY";
|
||||||
|
if (j === ParagraphJustification.RIGHT_JUSTIFY) return "RIGHT_JUSTIFY";
|
||||||
|
if (j === ParagraphJustification.FULL_JUSTIFY) return "FULL_JUSTIFY";
|
||||||
|
} catch (e) {}
|
||||||
|
return "CENTER_JUSTIFY";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readText(layer) {
|
||||||
|
try {
|
||||||
|
var st = layer.property("Source Text");
|
||||||
|
if (!st) return null;
|
||||||
|
var td = st.value; // TextDocument
|
||||||
|
return { text: td.text, font: td.font, fontSize: Math.round(td.fontSize), justify: justifyName(td.justification) };
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pre3(name) { return String(name).substring(0, 3); }
|
||||||
|
|
||||||
|
function scanScene(comp) {
|
||||||
|
var elements = [], colors = [];
|
||||||
|
for (var i = 1; i <= comp.numLayers; i++) {
|
||||||
|
var layer = comp.layer(i), name = layer.name, p = pre3(name);
|
||||||
|
if (p === "frl") {
|
||||||
|
var el = { key: name, title: name, sort: i };
|
||||||
|
var txt = readText(layer);
|
||||||
|
if (txt) {
|
||||||
|
el.type = "Text"; el.default_value = txt.text;
|
||||||
|
el.font_face = txt.font; el.font_size = txt.fontSize; el.justify = txt.justify;
|
||||||
|
} else {
|
||||||
|
var src = null; try { src = layer.source; } catch (e) {}
|
||||||
|
if (src && src.hasVideo === false && src.hasAudio === true) {
|
||||||
|
el.type = "Audio";
|
||||||
|
} else {
|
||||||
|
el.type = "Media";
|
||||||
|
if (src) { try { el.width = src.width; el.height = src.height; } catch (e) {} }
|
||||||
|
try { el.video_support = !!(src && src.hasVideo); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elements.push(el);
|
||||||
|
} else if (p === "frd") {
|
||||||
|
var t2 = readText(layer);
|
||||||
|
if (t2 && isColor(t2.text)) {
|
||||||
|
colors.push({ element_key: name, title: name, attr_value: "fill", default_color: normColor(t2.text), sort: i });
|
||||||
|
} else {
|
||||||
|
var del = { key: name, title: name, type: "Text", is_hidden: true, sort: i };
|
||||||
|
if (t2) { del.default_value = t2.text; del.font_face = t2.font; del.font_size = t2.fontSize; }
|
||||||
|
elements.push(del);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { elements: elements, colors: colors };
|
||||||
|
}
|
||||||
|
|
||||||
|
function compHasEditable(comp) {
|
||||||
|
for (var k = 1; k <= comp.numLayers; k++) {
|
||||||
|
var pp = pre3(comp.layer(k).name);
|
||||||
|
if (pp === "frl" || pp === "frd") return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
if (aepPath) app.open(new File(aepPath));
|
||||||
|
var proj = app.project;
|
||||||
|
var result = { source: "ae-jsx", render_comp: null, scenes: [], shared_colors: [] };
|
||||||
|
|
||||||
|
for (var i = 1; i <= proj.numItems; i++) {
|
||||||
|
var item = proj.item(i);
|
||||||
|
if (!(item instanceof CompItem)) continue;
|
||||||
|
var nm = item.name;
|
||||||
|
|
||||||
|
if (nm === "frfinal") { result.render_comp = "frfinal"; continue; }
|
||||||
|
if (nm === "frshare") {
|
||||||
|
for (var j = 1; j <= item.numLayers; j++) {
|
||||||
|
var cl = item.layer(j), ct = readText(cl);
|
||||||
|
result.shared_colors.push({
|
||||||
|
element_key: cl.name, title: cl.name, attr_value: "fill",
|
||||||
|
default_color: (ct && isColor(ct.text)) ? normColor(ct.text) : "#000000", sort: j
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!compHasEditable(item)) continue;
|
||||||
|
|
||||||
|
var s = scanScene(item);
|
||||||
|
result.scenes.push({
|
||||||
|
key: nm, title: nm, scene_type: "Normal",
|
||||||
|
default_duration_sec: Math.round(item.duration * 100) / 100,
|
||||||
|
sort: result.scenes.length, elements: s.elements, colors: s.colors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var out = new File(outPath);
|
||||||
|
out.encoding = "UTF-8";
|
||||||
|
out.open("w");
|
||||||
|
out.write(jstr(result));
|
||||||
|
out.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
run();
|
||||||
|
} catch (e) {
|
||||||
|
try { var ef = new File(outPath + ".error"); ef.open("w"); ef.write("scan error: " + e.toString()); ef.close(); } catch (e2) {}
|
||||||
|
}
|
||||||
|
// Quit AE so the headless afterfx process returns (set FR_SCAN_QUIT=0 to keep open while debugging).
|
||||||
|
try { if (getenv("FR_SCAN_QUIT") !== "0") app.quit(); } catch (e) {}
|
||||||
|
})();
|
||||||
@@ -68,6 +68,8 @@ func main() {
|
|||||||
exportH := handlers.NewExportHandler(store, mc, minioBucket)
|
exportH := handlers.NewExportHandler(store, mc, minioBucket)
|
||||||
nodeH := handlers.NewNodeHandler(store)
|
nodeH := handlers.NewNodeHandler(store)
|
||||||
fontH := handlers.NewFontHandler(store)
|
fontH := handlers.NewFontHandler(store)
|
||||||
|
bundleH := handlers.NewTemplateBundleHandler(mc, minioTemplatesBucket)
|
||||||
|
scanH := handlers.NewScanHandler(store, mc, minioTemplatesBucket)
|
||||||
internalH := handlers.NewInternalHandler(store, notifyClient, mc, minioTemplatesBucket, minioBucket)
|
internalH := handlers.NewInternalHandler(store, notifyClient, mc, minioTemplatesBucket, minioBucket)
|
||||||
|
|
||||||
// ── Router ────────────────────────────────────────────────────────────────
|
// ── Router ────────────────────────────────────────────────────────────────
|
||||||
@@ -148,6 +150,14 @@ func main() {
|
|||||||
// ── Render queue (admin: all users' jobs) ─────────────────────────────────
|
// ── Render queue (admin: all users' jobs) ─────────────────────────────────
|
||||||
v1.GET("/admin-renders", auth, admin, renderH.AdminList)
|
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) ────────────────
|
// ── Exports management (admin: all users' rendered videos) ────────────────
|
||||||
adminExports := v1.Group("/admin-exports", auth, admin)
|
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/fail", internalH.Fail)
|
||||||
internal.POST("/render/jobs/:job_id/crash", internalH.Crash)
|
internal.POST("/render/jobs/:job_id/crash", internalH.Crash)
|
||||||
internal.POST("/render/jobs/:job_id/replica-ready", internalH.ReplicaReady)
|
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)
|
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"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/flatrender/render-svc/internal/db"
|
"github.com/flatrender/render-svc/internal/db"
|
||||||
@@ -254,18 +255,37 @@ func (h *InternalHandler) Claim(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate presigned AEP download URL. AEP files are stored at
|
// Resolve the canonical template object. Each template is stored once, per
|
||||||
// templates/{original_project_id}/template.aep in the templates bucket.
|
// project id, at templates/{original_project_id}/<name> in the templates bucket
|
||||||
// Errors are non-fatal — node agent falls back to mock render when URL is empty.
|
// 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 := ""
|
aepURL := ""
|
||||||
|
isBundle := false
|
||||||
|
bundleMD5 := ""
|
||||||
if h.minio != nil {
|
if h.minio != nil {
|
||||||
objectKey := fmt.Sprintf("templates/%s/template.aep", job.OriginalProjectID)
|
candidates := []struct {
|
||||||
purl, perr := h.minio.PresignedGetObject(
|
name string
|
||||||
context.Background(), h.templatesBucket, objectKey,
|
bundle bool
|
||||||
2*time.Hour, nil,
|
}{
|
||||||
)
|
{"bundle.zip", true},
|
||||||
if perr == nil {
|
{"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()
|
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,
|
HasMusic: job.HasMusic,
|
||||||
HasVoiceover: job.HasVoiceover,
|
HasVoiceover: job.HasVoiceover,
|
||||||
AEPDownloadURL: aepURL,
|
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"`
|
FrameRate int `json:"frame_rate"`
|
||||||
HasMusic bool `json:"has_music"`
|
HasMusic bool `json:"has_music"`
|
||||||
HasVoiceover bool `json:"has_voiceover"`
|
HasVoiceover bool `json:"has_voiceover"`
|
||||||
// AEPDownloadURL is a presigned MinIO GET URL for the .aep project file.
|
// AEPDownloadURL is a presigned MinIO GET URL for the .aep project file
|
||||||
// Valid for 2 hours. Empty when the template is not yet uploaded.
|
// (or .zip bundle). Valid for 2 hours. Empty when the template is not yet uploaded.
|
||||||
AEPDownloadURL string `json:"aep_download_url,omitempty"`
|
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.
|
// OutputUploadURLResponse is returned by POST .../output-upload-url.
|
||||||
|
|||||||
@@ -32,9 +32,11 @@ async function forward(
|
|||||||
if (!isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
if (!isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
const search = req.nextUrl.search ?? "";
|
const search = req.nextUrl.search ?? "";
|
||||||
// Trailing slash on the collection root avoids the gateway's 307 redirect.
|
// Trailing slash on the collection root avoids the gateway's 307 redirect
|
||||||
|
// (which, for POST, would otherwise rely on the client re-sending the body).
|
||||||
const joined = path.join("/");
|
const joined = path.join("/");
|
||||||
const gwPath = `/v1/${joined}${path.length === 1 && method === "GET" ? "/" : ""}${search}`;
|
const isCollectionRoot = path.length === 1 && (method === "GET" || method === "POST");
|
||||||
|
const gwPath = `/v1/${joined}${isCollectionRoot ? "/" : ""}${search}`;
|
||||||
|
|
||||||
let body: string | undefined;
|
let body: string | undefined;
|
||||||
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const btn = "rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
|
||||||
|
const ghost = "rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e] disabled:opacity-50";
|
||||||
|
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
|
||||||
|
|
||||||
|
interface SceneDiff {
|
||||||
|
key: string; title: string; status: string;
|
||||||
|
elements_added: number; elements_changed: number; elements_removed: number;
|
||||||
|
colors_added: number; colors_changed: number; colors_removed: number;
|
||||||
|
}
|
||||||
|
interface ImportDiff {
|
||||||
|
applied: boolean;
|
||||||
|
scenes_added: number; scenes_changed: number; scenes_unchanged: number; scenes_orphan: number;
|
||||||
|
shared_colors_added: number; shared_colors_changed: number; shared_colors_removed: number;
|
||||||
|
scenes: SceneDiff[]; orphan_scene_keys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = "idle" | "scanning" | "preview" | "applying" | "done" | "error";
|
||||||
|
|
||||||
|
const STATUS_FA: Record<string, string> = {
|
||||||
|
added: "جدید", changed: "تغییر", unchanged: "بدون تغییر", orphan: "حذفشده در AEP",
|
||||||
|
};
|
||||||
|
const STATUS_CLS: Record<string, string> = {
|
||||||
|
added: "bg-emerald-500/15 text-emerald-300",
|
||||||
|
changed: "bg-amber-500/15 text-amber-300",
|
||||||
|
unchanged: "bg-gray-500/15 text-gray-400",
|
||||||
|
orphan: "bg-red-500/15 text-red-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Scan a project's AE template and merge the discovered scenes/colours (review-diff-then-merge). */
|
||||||
|
export function ProjectScanImport({ projectId, onClose, onApplied }: {
|
||||||
|
projectId: string; onClose: () => void; onApplied: () => void;
|
||||||
|
}) {
|
||||||
|
const [step, setStep] = useState<Step>("idle");
|
||||||
|
const [statusMsg, setStatusMsg] = useState("");
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [scan, setScan] = useState<unknown>(null);
|
||||||
|
const [diff, setDiff] = useState<ImportDiff | null>(null);
|
||||||
|
const [removeOrphans, setRemoveOrphans] = useState(false);
|
||||||
|
const [overwrite, setOverwrite] = useState(true);
|
||||||
|
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const fail = (m: string) => { setErr(m); setStep("error"); };
|
||||||
|
|
||||||
|
// shared: send a scan to content for a dry-run diff
|
||||||
|
const preview = useCallback(async (scanResult: unknown) => {
|
||||||
|
setScan(scanResult);
|
||||||
|
setStatusMsg("در حال محاسبهٔ تفاوتها…");
|
||||||
|
const r = await fetch(`/api/admin/resource/projects/${projectId}/scan/preview`, {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ scan: scanResult }),
|
||||||
|
});
|
||||||
|
const d = await r.json().catch(() => null);
|
||||||
|
if (!r.ok) { fail(d?.error ?? "محاسبهٔ تفاوتها ناموفق بود"); return; }
|
||||||
|
setDiff(d); setStep("preview");
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
// Quick scan — headless Go parser (no AE)
|
||||||
|
const runQuick = async () => {
|
||||||
|
setStep("scanning"); setErr(null); setStatusMsg("در حال خواندن ساختار پروژه از فایل AEP…");
|
||||||
|
const r = await fetch(`/api/admin/resource/template-scans/${projectId}/quick`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
||||||
|
const d = await r.json().catch(() => null);
|
||||||
|
if (!r.ok) { fail(d?.error ?? "اسکن سریع ناموفق بود (آیا فایل AEP آپلود شده؟)"); return; }
|
||||||
|
await preview(d);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Full scan — queue an AE job on a render node, then poll
|
||||||
|
const runFull = async () => {
|
||||||
|
setStep("scanning"); setErr(null); setStatusMsg("در حال ارسال کار اسکن به نود افترافکت…");
|
||||||
|
const r = await fetch(`/api/admin/resource/template-scans/${projectId}/jobs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
||||||
|
const d = await r.json().catch(() => null);
|
||||||
|
if (!r.ok || !d?.id) { fail(d?.error ?? "ایجاد کار اسکن ناموفق بود"); return; }
|
||||||
|
const jobId = d.id;
|
||||||
|
const started = Date.now();
|
||||||
|
const poll = async () => {
|
||||||
|
const jr = await fetch(`/api/admin/resource/template-scan-jobs/${jobId}`, { cache: "no-store" });
|
||||||
|
const job = await jr.json().catch(() => null);
|
||||||
|
if (!jr.ok) { fail(job?.error ?? "خطا در دریافت وضعیت اسکن"); return; }
|
||||||
|
if (job.status === "done") { await preview(job.result); return; }
|
||||||
|
if (job.status === "error") { fail("اسکن روی نود ناموفق بود: " + (job.error ?? "")); return; }
|
||||||
|
if (Date.now() - started > 6 * 60 * 1000) { fail("اسکن طول کشید — آیا یک نود افترافکت آنلاین است؟"); return; }
|
||||||
|
setStatusMsg(job.status === "running" ? "در حال اجرای اسکریپت اسکن در افترافکت…" : "در صف اجرا روی نود…");
|
||||||
|
pollRef.current = setTimeout(poll, 3000);
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const apply = async () => {
|
||||||
|
setStep("applying"); setStatusMsg("در حال اعمال تغییرات…");
|
||||||
|
const r = await fetch(`/api/admin/resource/projects/${projectId}/scan/apply`, {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ scan, options: { remove_orphan_scenes: removeOrphans, remove_orphan_elements: removeOrphans, overwrite_existing: overwrite } }),
|
||||||
|
});
|
||||||
|
const d = await r.json().catch(() => null);
|
||||||
|
if (!r.ok) { fail(d?.error ?? "اعمال ناموفق بود"); return; }
|
||||||
|
setDiff(d); setStep("done"); onApplied();
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => { if (pollRef.current) clearTimeout(pollRef.current); onClose(); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-stretch justify-center bg-black/70 p-2 sm:p-6" dir="rtl" onClick={close}>
|
||||||
|
<div className={`${card} flex max-h-full w-full max-w-2xl flex-col`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between border-b border-[#1e2235] px-5 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-white">اسکن از افترافکت — خواندن صحنهها و رنگها</h2>
|
||||||
|
<button className="rounded-lg px-2 py-1 text-gray-400 hover:bg-[#161a2e] hover:text-white" onClick={close}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
|
{/* idle → choose engine */}
|
||||||
|
{step === "idle" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-gray-400">ساختار قالب را مستقیماً از فایل افترافکت بخوانید. ابتدا یک پیشنمایش از تغییرات میبینید و سپس اعمال میکنید (ویرایشهای دستی حفظ میشوند).</p>
|
||||||
|
<button className="w-full rounded-lg border border-[#262b40] p-3 text-right hover:bg-[#161a2e]" onClick={runQuick}>
|
||||||
|
<div className="text-sm font-medium text-white">اسکن سریع (بدون افترافکت)</div>
|
||||||
|
<div className="mt-0.5 text-xs text-gray-500">فقط نام صحنهها و مدتها را از فایل AEP میخواند. فوری، بدون نیاز به نود. رنگها/فونتها بعداً با اسکن کامل پر میشوند.</div>
|
||||||
|
</button>
|
||||||
|
<button className="w-full rounded-lg border border-[#262b40] p-3 text-right hover:bg-[#161a2e]" onClick={runFull}>
|
||||||
|
<div className="text-sm font-medium text-white">اسکن کامل (روی نود افترافکت)</div>
|
||||||
|
<div className="mt-0.5 text-xs text-gray-500">صحنهها، عناصر (frl_/frd_)، فونتها، چینش و رنگها (frshare) را کامل میخواند. نیازمند یک نود افترافکت آنلاین است.</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(step === "scanning" || step === "applying") && (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-10 text-center">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent" />
|
||||||
|
<p className="text-sm text-gray-300">{statusMsg}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "error" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{err}</p>
|
||||||
|
<button className={ghost} onClick={() => { setErr(null); setStep("idle"); }}>تلاش دوباره</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(step === "preview" || step === "done") && diff && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{step === "done" && <p className="rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">تغییرات با موفقیت اعمال شد ✓</p>}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
<Stat label="صحنهٔ جدید" value={diff.scenes_added} cls="text-emerald-300" />
|
||||||
|
<Stat label="تغییریافته" value={diff.scenes_changed} cls="text-amber-300" />
|
||||||
|
<Stat label="بدون تغییر" value={diff.scenes_unchanged} cls="text-gray-400" />
|
||||||
|
<Stat label="در AEP نیست" value={diff.scenes_orphan} cls="text-red-300" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
رنگهای مشترک: <span className="text-emerald-300">{diff.shared_colors_added} جدید</span> ·{" "}
|
||||||
|
<span className="text-amber-300">{diff.shared_colors_changed} تغییر</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{diff.scenes.length > 0 && (
|
||||||
|
<div className="max-h-64 space-y-1 overflow-y-auto rounded-lg border border-[#1e2235] p-2">
|
||||||
|
{diff.scenes.map((s) => (
|
||||||
|
<div key={s.key} className="flex items-center justify-between rounded bg-[#0c0e1a] px-2 py-1.5 text-xs">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className={`rounded px-1.5 py-0.5 text-[10px] ${STATUS_CLS[s.status] ?? ""}`}>{STATUS_FA[s.status] ?? s.status}</span>
|
||||||
|
<span className="text-gray-200">{s.title}</span>
|
||||||
|
<code className="rounded bg-[#1e2235] px-1 text-[10px] text-indigo-300" dir="ltr">{s.key}</code>
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-500">
|
||||||
|
{s.elements_added + s.elements_changed > 0 && `${s.elements_added + s.elements_changed} عنصر · `}
|
||||||
|
{s.colors_added + s.colors_changed > 0 && `${s.colors_added + s.colors_changed} رنگ`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "preview" && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1.5 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||||
|
<input type="checkbox" checked={overwrite} onChange={(e) => setOverwrite(e.target.checked)} className="h-4 w-4 accent-indigo-500" />
|
||||||
|
بهروزرسانی موارد موجود از روی اسکن (مقادیر خالیِ اسکن، دادههای فعلی را پاک نمیکند)
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||||
|
<input type="checkbox" checked={removeOrphans} onChange={(e) => setRemoveOrphans(e.target.checked)} className="h-4 w-4 accent-indigo-500" />
|
||||||
|
حذف صحنهها/عناصری که دیگر در AEP وجود ندارند
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button className={ghost} onClick={() => setStep("idle")}>بازگشت</button>
|
||||||
|
<button className={btn} onClick={apply}>اعمال تغییرات</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "done" && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button className={btn} onClick={close}>بستن</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, cls }: { label: string; value: number; cls: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-2 text-center">
|
||||||
|
<div className={`text-lg font-semibold ${cls}`}>{value.toLocaleString("fa-IR")}</div>
|
||||||
|
<div className="text-[10px] text-gray-500">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { FileUploadField } from "@/components/admin/FileUploadField";
|
||||||
|
import { ProjectScanImport } from "@/components/admin/ProjectScanImport";
|
||||||
|
|
||||||
|
// ── styles ───────────────────────────────────────────────────────────────────
|
||||||
|
const inp = "rounded-lg border border-[#262b40] bg-[#0c0e1a] px-2.5 py-1.5 text-sm text-gray-100 outline-none focus:border-indigo-500";
|
||||||
|
const btn = "rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
|
||||||
|
const ghost = "rounded-lg border border-[#262b40] px-2.5 py-1 text-xs text-gray-300 hover:bg-[#161a2e] disabled:opacity-50";
|
||||||
|
const lbl = "mb-1 block text-xs text-gray-400";
|
||||||
|
const del = "rounded-lg border border-red-500/30 px-2.5 py-1 text-xs text-red-300 hover:bg-red-500/10";
|
||||||
|
|
||||||
|
// ── types (snake_case — matches content-svc JSON) ─────────────────────────────
|
||||||
|
interface Scene {
|
||||||
|
id: string; project_id: string; key: string; title: string; localized_title?: string | null;
|
||||||
|
scene_type: string; image?: string | null; demo?: string | null; scene_color_svg?: string | null;
|
||||||
|
snapshot_url?: string | null; generate_kf: boolean; default_duration_sec?: number | null;
|
||||||
|
min_duration_sec?: number | null; max_duration_sec?: number | null; overlap_at_end_sec: number;
|
||||||
|
can_handle_duration: boolean; manual_color_selection: boolean; sort: number; is_active: boolean;
|
||||||
|
}
|
||||||
|
interface SharedColor {
|
||||||
|
id: string; project_id: string; element_key: string; title: string; icon?: string | null;
|
||||||
|
attr_value: string; default_color: string; sort: number;
|
||||||
|
}
|
||||||
|
interface PresetItem { id?: string; element_key: string; value: string; sort: number }
|
||||||
|
interface Preset { id: string; project_id: string; name?: string | null; sort: number; items: PresetItem[] }
|
||||||
|
|
||||||
|
const SCENE_TYPES = [
|
||||||
|
{ v: "Normal", l: "معمولی" },
|
||||||
|
{ v: "Config", l: "پیکربندی" },
|
||||||
|
{ v: "DesignStart", l: "شروع طراحی" },
|
||||||
|
{ v: "DesignEnd", l: "پایان طراحی" },
|
||||||
|
];
|
||||||
|
const ATTR_VALUES = [
|
||||||
|
{ v: "fill", l: "Fill (پُرکننده)" },
|
||||||
|
{ v: "stroke", l: "Stroke (خط دور)" },
|
||||||
|
{ v: "tracking", l: "Tracking (فاصله)" },
|
||||||
|
{ v: "dropshadow", l: "Drop Shadow (سایه)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// localized_title is stored as a JSON string {"fa":"…","en":"…"}
|
||||||
|
function parseLocalized(v?: string | null): { fa: string; en: string } {
|
||||||
|
if (!v) return { fa: "", en: "" };
|
||||||
|
try { const o = JSON.parse(v); return { fa: o.fa ?? "", en: o.en ?? "" }; } catch { return { fa: "", en: "" }; }
|
||||||
|
}
|
||||||
|
function buildLocalized(fa: string, en: string): string | null {
|
||||||
|
if (!fa.trim() && !en.trim()) return null;
|
||||||
|
return JSON.stringify({ fa: fa.trim(), en: en.trim() });
|
||||||
|
}
|
||||||
|
|
||||||
|
type SceneDraft = Omit<Scene, "id" | "project_id"> & { _fa: string; _en: string };
|
||||||
|
|
||||||
|
function emptyDraft(sort: number): SceneDraft {
|
||||||
|
return {
|
||||||
|
key: "", title: "", localized_title: null, scene_type: "Normal", image: null, demo: null,
|
||||||
|
scene_color_svg: null, snapshot_url: null, generate_kf: false, default_duration_sec: null,
|
||||||
|
min_duration_sec: null, max_duration_sec: null, overlap_at_end_sec: 0, can_handle_duration: true,
|
||||||
|
manual_color_selection: false, sort, is_active: true, _fa: "", _en: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function sceneToDraft(s: Scene): SceneDraft {
|
||||||
|
const loc = parseLocalized(s.localized_title);
|
||||||
|
return { ...s, _fa: loc.fa, _en: loc.en };
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
export function ProjectScenes({ projectId }: { projectId: string }) {
|
||||||
|
const [tab, setTab] = useState<"scenes" | "colors" | "presets">("scenes");
|
||||||
|
return (
|
||||||
|
<div dir="rtl">
|
||||||
|
<div className="mb-4 flex gap-1 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-1 text-sm">
|
||||||
|
{([["scenes", "صحنهها"], ["colors", "رنگهای مشترک"], ["presets", "پریستهای رنگ"]] as const).map(([k, l]) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
onClick={() => setTab(k)}
|
||||||
|
className={`flex-1 rounded-md px-3 py-1.5 transition-colors ${tab === k ? "bg-indigo-600/20 font-medium text-indigo-300" : "text-gray-400 hover:text-white"}`}
|
||||||
|
>{l}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{tab === "scenes" && <ScenesTab projectId={projectId} />}
|
||||||
|
{tab === "colors" && <ColorsTab projectId={projectId} />}
|
||||||
|
{tab === "presets" && <PresetsTab projectId={projectId} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scenes ────────────────────────────────────────────────────────────────────
|
||||||
|
function ScenesTab({ projectId }: { projectId: string }) {
|
||||||
|
const [rows, setRows] = useState<Scene[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [draft, setDraft] = useState<SceneDraft | null>(null);
|
||||||
|
const [editId, setEditId] = useState<string | null>(null); // null+draft => new
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [scanOpen, setScanOpen] = useState(false);
|
||||||
|
const base = "/api/admin/resource/scenes";
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const r = await fetch(`${base}?project_id=${projectId}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null);
|
||||||
|
setRows(Array.isArray(r) ? r : r?.data ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
}, [projectId]);
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!draft) return;
|
||||||
|
if (!draft.key.trim() || !draft.title.trim()) { setErr("کلید و عنوان صحنه الزامی است"); return; }
|
||||||
|
setSaving(true); setErr(null);
|
||||||
|
const body = {
|
||||||
|
project_id: projectId, key: draft.key.trim(), title: draft.title.trim(),
|
||||||
|
localized_title: buildLocalized(draft._fa, draft._en), scene_type: draft.scene_type,
|
||||||
|
image: draft.image || null, demo: draft.demo || null, scene_color_svg: draft.scene_color_svg || null,
|
||||||
|
snapshot_url: draft.snapshot_url || null, generate_kf: draft.generate_kf,
|
||||||
|
default_duration_sec: draft.default_duration_sec, min_duration_sec: draft.min_duration_sec,
|
||||||
|
max_duration_sec: draft.max_duration_sec, overlap_at_end_sec: draft.overlap_at_end_sec ?? 0,
|
||||||
|
can_handle_duration: draft.can_handle_duration, manual_color_selection: draft.manual_color_selection,
|
||||||
|
sort: draft.sort ?? 0, is_active: draft.is_active,
|
||||||
|
};
|
||||||
|
const res = await fetch(editId ? `${base}/${editId}` : base, {
|
||||||
|
method: editId ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const d = await res.json().catch(() => null);
|
||||||
|
if (res.ok) { setDraft(null); setEditId(null); load(); }
|
||||||
|
else setErr(d?.message ?? d?.error?.message ?? "ذخیرهٔ صحنه ناموفق بود");
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (s: Scene) => {
|
||||||
|
if (!confirm(`صحنهٔ «${s.title}» حذف شود؟`)) return;
|
||||||
|
await fetch(`${base}/${s.id}`, { method: "DELETE" });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (draft) {
|
||||||
|
return (
|
||||||
|
<SceneForm
|
||||||
|
draft={draft} setDraft={setDraft} saving={saving} err={err}
|
||||||
|
onCancel={() => { setDraft(null); setEditId(null); setErr(null); }}
|
||||||
|
onSave={save} isEdit={!!editId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs text-gray-500">صحنهها بلوکهای قابلویرایش این قالب هستند. کلید هر صحنه باید با نام کامپوزیشن افترافکت یکی باشد.</p>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<button className="rounded-lg border border-indigo-500/40 px-3 py-2 text-sm text-indigo-300 hover:bg-indigo-600/10" onClick={() => setScanOpen(true)}>اسکن از افترافکت</button>
|
||||||
|
<button className={btn} onClick={() => { setEditId(null); setDraft(emptyDraft(rows.length)); }}>+ صحنهٔ جدید</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{scanOpen && (
|
||||||
|
<ProjectScanImport projectId={projectId} onClose={() => setScanOpen(false)} onApplied={load} />
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-6 text-center text-sm text-gray-500">در حال بارگذاری…</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-[#262b40] py-6 text-center text-sm text-gray-600">هنوز صحنهای اضافه نشده.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{rows.map((s) => (
|
||||||
|
<li key={s.id} className="flex items-center justify-between rounded-lg border border-[#1e2235] bg-[#0c0e1a] px-3 py-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="text-[10px] text-gray-600">#{s.sort}</span>
|
||||||
|
<span className="truncate text-sm text-gray-200">{s.title}</span>
|
||||||
|
<code className="truncate rounded bg-[#1e2235] px-1.5 py-0.5 text-[10px] text-indigo-300" dir="ltr">{s.key}</code>
|
||||||
|
<span className="rounded bg-[#1e2235] px-1.5 py-0.5 text-[10px] text-gray-400">{SCENE_TYPES.find((t) => t.v === s.scene_type)?.l ?? s.scene_type}</span>
|
||||||
|
{!s.is_active && <span className="rounded bg-gray-500/15 px-1.5 py-0.5 text-[10px] text-gray-400">غیرفعال</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<button className={ghost} onClick={() => { setEditId(s.id); setDraft(sceneToDraft(s)); }}>ویرایش</button>
|
||||||
|
<button className={del} onClick={() => remove(s)}>حذف</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v: number | null | undefined) { return v === null || v === undefined ? "" : String(v); }
|
||||||
|
function toNum(v: string): number | null { return v.trim() === "" ? null : Number(v); }
|
||||||
|
|
||||||
|
function SceneForm({ draft, setDraft, onSave, onCancel, saving, err, isEdit }: {
|
||||||
|
draft: SceneDraft; setDraft: (d: SceneDraft) => void; onSave: () => void; onCancel: () => void;
|
||||||
|
saving: boolean; err: string | null; isEdit: boolean;
|
||||||
|
}) {
|
||||||
|
const set = (p: Partial<SceneDraft>) => setDraft({ ...draft, ...p });
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-white">{isEdit ? "ویرایش صحنه" : "صحنهٔ جدید"}</h3>
|
||||||
|
<button className={ghost} onClick={onCancel}>→ بازگشت به فهرست</button>
|
||||||
|
</div>
|
||||||
|
{err && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{err}</p>}
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div><label className={lbl}>کلید (نام کامپوزیشن AE) *</label><input className={`${inp} w-full`} dir="ltr" value={draft.key} onChange={(e) => set({ key: e.target.value })} placeholder="scene_intro" /></div>
|
||||||
|
<div><label className={lbl}>عنوان *</label><input className={`${inp} w-full`} value={draft.title} onChange={(e) => set({ title: e.target.value })} /></div>
|
||||||
|
<div><label className={lbl}>عنوان (فارسی)</label><input className={`${inp} w-full`} value={draft._fa} onChange={(e) => set({ _fa: e.target.value })} /></div>
|
||||||
|
<div><label className={lbl}>عنوان (انگلیسی)</label><input className={`${inp} w-full`} dir="ltr" value={draft._en} onChange={(e) => set({ _en: e.target.value })} /></div>
|
||||||
|
<div>
|
||||||
|
<label className={lbl}>نوع صحنه</label>
|
||||||
|
<select className={`${inp} w-full`} value={draft.scene_type} onChange={(e) => set({ scene_type: e.target.value })}>
|
||||||
|
{SCENE_TYPES.map((t) => <option key={t.v} value={t.v}>{t.l}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div><label className={lbl}>ترتیب</label><input className={`${inp} w-full`} type="number" dir="ltr" value={num(draft.sort)} onChange={(e) => set({ sort: Number(e.target.value) || 0 })} /></div>
|
||||||
|
|
||||||
|
<div><label className={lbl}>مدت پیشفرض (ثانیه)</label><input className={`${inp} w-full`} type="number" step="0.1" dir="ltr" value={num(draft.default_duration_sec)} onChange={(e) => set({ default_duration_sec: toNum(e.target.value) })} /></div>
|
||||||
|
<div><label className={lbl}>همپوشانی پایان (ثانیه)</label><input className={`${inp} w-full`} type="number" step="0.1" dir="ltr" value={num(draft.overlap_at_end_sec)} onChange={(e) => set({ overlap_at_end_sec: Number(e.target.value) || 0 })} /></div>
|
||||||
|
<div><label className={lbl}>حداقل مدت (ثانیه)</label><input className={`${inp} w-full`} type="number" step="0.1" dir="ltr" value={num(draft.min_duration_sec)} onChange={(e) => set({ min_duration_sec: toNum(e.target.value) })} /></div>
|
||||||
|
<div><label className={lbl}>حداکثر مدت (ثانیه)</label><input className={`${inp} w-full`} type="number" step="0.1" dir="ltr" value={num(draft.max_duration_sec)} onChange={(e) => set({ max_duration_sec: toNum(e.target.value) })} /></div>
|
||||||
|
|
||||||
|
<div><label className={lbl}>تصویر صحنه</label><FileUploadField value={draft.image ?? ""} onChange={(u) => set({ image: u })} accept="image/*" /></div>
|
||||||
|
<div><label className={lbl}>دموی صحنه (ویدیو/تصویر)</label><FileUploadField value={draft.demo ?? ""} onChange={(u) => set({ demo: u })} accept="video/*,image/*" /></div>
|
||||||
|
<div><label className={lbl}>اسنپشات (فریم نمونه)</label><FileUploadField value={draft.snapshot_url ?? ""} onChange={(u) => set({ snapshot_url: u })} accept="image/*" /></div>
|
||||||
|
<div><label className={lbl}>SVG رنگ (اختیاری)</label><input className={`${inp} w-full`} dir="ltr" value={draft.scene_color_svg ?? ""} onChange={(e) => set({ scene_color_svg: e.target.value })} placeholder="آدرس یا کد SVG" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-3 sm:grid-cols-2">
|
||||||
|
<Check label="تولید کیفریم (Generate KF)" checked={draft.generate_kf} onChange={(v) => set({ generate_kf: v })} />
|
||||||
|
<Check label="مدت قابل تغییر توسط کاربر" checked={draft.can_handle_duration} onChange={(v) => set({ can_handle_duration: v })} />
|
||||||
|
<Check label="انتخاب رنگ دستی" checked={draft.manual_color_selection} onChange={(v) => set({ manual_color_selection: v })} />
|
||||||
|
<Check label="فعال" checked={draft.is_active} onChange={(v) => set({ is_active: v })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-[#1e2235] pt-3">
|
||||||
|
<button className={ghost} onClick={onCancel}>انصراف</button>
|
||||||
|
<button className={btn} onClick={onSave} disabled={saving}>{saving ? "در حال ذخیره…" : isEdit ? "ذخیرهٔ تغییرات" : "افزودن صحنه"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Check({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
|
||||||
|
return (
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm text-gray-300">
|
||||||
|
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} className="h-4 w-4 accent-indigo-500" />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared Colors ─────────────────────────────────────────────────────────────
|
||||||
|
function ColorsTab({ projectId }: { projectId: string }) {
|
||||||
|
const [rows, setRows] = useState<SharedColor[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [edit, setEdit] = useState<Partial<SharedColor> | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const base = "/api/admin/resource/shared-colors";
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const r = await fetch(`${base}?project_id=${projectId}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null);
|
||||||
|
setRows(Array.isArray(r) ? r : r?.data ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
}, [projectId]);
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!edit) return;
|
||||||
|
if (!edit.element_key?.trim() || !edit.title?.trim()) { setErr("کلید عنصر و عنوان الزامی است"); return; }
|
||||||
|
setSaving(true); setErr(null);
|
||||||
|
const body = {
|
||||||
|
project_id: projectId, element_key: edit.element_key.trim(), title: edit.title.trim(),
|
||||||
|
icon: edit.icon || null, attr_value: edit.attr_value || "fill",
|
||||||
|
default_color: edit.default_color || "#000000", sort: edit.sort ?? rows.length,
|
||||||
|
};
|
||||||
|
const res = await fetch(edit.id ? `${base}/${edit.id}` : base, {
|
||||||
|
method: edit.id ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const d = await res.json().catch(() => null);
|
||||||
|
if (res.ok) { setEdit(null); load(); } else setErr(d?.message ?? "ذخیره ناموفق بود");
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
const remove = async (c: SharedColor) => { if (!confirm(`رنگ «${c.title}» حذف شود؟`)) return; await fetch(`${base}/${c.id}`, { method: "DELETE" }); load(); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-gray-500">رنگهایی که در کل پروژه مشترکاند (کامپوزیشن frshare). کلید عنصر باید با نام لایهٔ frd_ مطابقت داشته باشد.</p>
|
||||||
|
<button className={btn} onClick={() => setEdit({ attr_value: "fill", default_color: "#3366ff", sort: rows.length })}>+ رنگ جدید</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{edit && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-3">
|
||||||
|
{err && <p className="rounded bg-red-500/10 px-3 py-2 text-sm text-red-300">{err}</p>}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div><label className={lbl}>کلید عنصر (frd_…) *</label><input className={`${inp} w-full`} dir="ltr" value={edit.element_key ?? ""} onChange={(e) => setEdit({ ...edit, element_key: e.target.value })} placeholder="frd_primary" /></div>
|
||||||
|
<div><label className={lbl}>عنوان *</label><input className={`${inp} w-full`} value={edit.title ?? ""} onChange={(e) => setEdit({ ...edit, title: e.target.value })} /></div>
|
||||||
|
<div>
|
||||||
|
<label className={lbl}>نوع ویژگی</label>
|
||||||
|
<select className={`${inp} w-full`} value={edit.attr_value ?? "fill"} onChange={(e) => setEdit({ ...edit, attr_value: e.target.value })}>
|
||||||
|
{ATTR_VALUES.map((a) => <option key={a.v} value={a.v}>{a.l}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={lbl}>رنگ پیشفرض</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="color" className="h-9 w-12 rounded border border-[#262b40] bg-[#0c0e1a]" value={/^#[0-9a-fA-F]{6}$/.test(edit.default_color ?? "") ? edit.default_color : "#000000"} onChange={(e) => setEdit({ ...edit, default_color: e.target.value })} />
|
||||||
|
<input className={`${inp} flex-1`} dir="ltr" value={edit.default_color ?? ""} onChange={(e) => setEdit({ ...edit, default_color: e.target.value })} placeholder="#RRGGBB" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div><label className={lbl}>آیکون (اختیاری)</label><input className={`${inp} w-full`} dir="ltr" value={edit.icon ?? ""} onChange={(e) => setEdit({ ...edit, icon: e.target.value })} /></div>
|
||||||
|
<div><label className={lbl}>ترتیب</label><input className={`${inp} w-full`} type="number" dir="ltr" value={num(edit.sort)} onChange={(e) => setEdit({ ...edit, sort: Number(e.target.value) || 0 })} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button className={ghost} onClick={() => { setEdit(null); setErr(null); }}>انصراف</button>
|
||||||
|
<button className={btn} onClick={save} disabled={saving}>{saving ? "…" : "ذخیره"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-6 text-center text-sm text-gray-500">در حال بارگذاری…</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-[#262b40] py-6 text-center text-sm text-gray-600">رنگ مشترکی تعریف نشده.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{rows.map((c) => (
|
||||||
|
<li key={c.id} className="flex items-center justify-between rounded-lg border border-[#1e2235] bg-[#0c0e1a] px-3 py-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="h-5 w-5 shrink-0 rounded border border-[#262b40]" style={{ backgroundColor: c.default_color }} />
|
||||||
|
<span className="truncate text-sm text-gray-200">{c.title}</span>
|
||||||
|
<code className="truncate rounded bg-[#1e2235] px-1.5 py-0.5 text-[10px] text-indigo-300" dir="ltr">{c.element_key}</code>
|
||||||
|
<span className="rounded bg-[#1e2235] px-1.5 py-0.5 text-[10px] text-gray-400">{c.attr_value}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<button className={ghost} onClick={() => setEdit(c)}>ویرایش</button>
|
||||||
|
<button className={del} onClick={() => remove(c)}>حذف</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Color Presets ─────────────────────────────────────────────────────────────
|
||||||
|
function PresetsTab({ projectId }: { projectId: string }) {
|
||||||
|
const [rows, setRows] = useState<Preset[]>([]);
|
||||||
|
const [colors, setColors] = useState<SharedColor[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [edit, setEdit] = useState<Partial<Preset> | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const base = "/api/admin/resource/color-presets";
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const [r, c] = await Promise.all([
|
||||||
|
fetch(`${base}?project_id=${projectId}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null),
|
||||||
|
fetch(`/api/admin/resource/shared-colors?project_id=${projectId}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null),
|
||||||
|
]);
|
||||||
|
setRows(Array.isArray(r) ? r : r?.data ?? []);
|
||||||
|
setColors(Array.isArray(c) ? c : c?.data ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
}, [projectId]);
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const colorKeys = useMemo(() => colors.map((c) => ({ key: c.element_key, title: c.title })), [colors]);
|
||||||
|
|
||||||
|
const startNew = () => setEdit({ name: "", sort: rows.length, items: [] });
|
||||||
|
const setItems = (items: PresetItem[]) => setEdit((e) => (e ? { ...e, items } : e));
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!edit) return;
|
||||||
|
setSaving(true); setErr(null);
|
||||||
|
const body = {
|
||||||
|
project_id: projectId, name: edit.name?.trim() || null, sort: edit.sort ?? rows.length,
|
||||||
|
items: (edit.items ?? []).map((it, i) => ({ element_key: it.element_key, value: it.value, sort: it.sort ?? i })),
|
||||||
|
};
|
||||||
|
const res = await fetch(edit.id ? `${base}/${edit.id}` : base, {
|
||||||
|
method: edit.id ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const d = await res.json().catch(() => null);
|
||||||
|
if (res.ok) { setEdit(null); load(); } else setErr(d?.message ?? "ذخیره ناموفق بود");
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
const remove = async (p: Preset) => { if (!confirm("این پریست حذف شود؟")) return; await fetch(`${base}/${p.id}`, { method: "DELETE" }); load(); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-gray-500">پالتهای آمادهٔ رنگ برای کل پروژه. هر آیتم یک «کلید عنصر» را به یک رنگ نگاشت میکند.</p>
|
||||||
|
<button className={btn} onClick={startNew}>+ پریست جدید</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{edit && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-3">
|
||||||
|
{err && <p className="rounded bg-red-500/10 px-3 py-2 text-sm text-red-300">{err}</p>}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div><label className={lbl}>نام پریست</label><input className={`${inp} w-full`} value={edit.name ?? ""} onChange={(e) => setEdit({ ...edit, name: e.target.value })} placeholder="مثلاً تیره" /></div>
|
||||||
|
<div><label className={lbl}>ترتیب</label><input className={`${inp} w-full`} type="number" dir="ltr" value={num(edit.sort)} onChange={(e) => setEdit({ ...edit, sort: Number(e.target.value) || 0 })} /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-400">رنگهای پریست</span>
|
||||||
|
<button className={ghost} onClick={() => setItems([...(edit.items ?? []), { element_key: colorKeys[0]?.key ?? "", value: "#3366ff", sort: (edit.items ?? []).length }])}>+ افزودن رنگ</button>
|
||||||
|
</div>
|
||||||
|
{(edit.items ?? []).length === 0 ? (
|
||||||
|
<p className="text-[11px] text-gray-600">رنگی اضافه نشده.</p>
|
||||||
|
) : (edit.items ?? []).map((it, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
{colorKeys.length > 0 ? (
|
||||||
|
<select className={`${inp} flex-1`} value={it.element_key} onChange={(e) => { const a = [...(edit.items ?? [])]; a[i] = { ...it, element_key: e.target.value }; setItems(a); }}>
|
||||||
|
{colorKeys.map((c) => <option key={c.key} value={c.key}>{c.title} ({c.key})</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input className={`${inp} flex-1`} dir="ltr" value={it.element_key} onChange={(e) => { const a = [...(edit.items ?? [])]; a[i] = { ...it, element_key: e.target.value }; setItems(a); }} placeholder="frd_primary" />
|
||||||
|
)}
|
||||||
|
<input type="color" className="h-9 w-12 rounded border border-[#262b40]" value={/^#[0-9a-fA-F]{6}$/.test(it.value) ? it.value : "#000000"} onChange={(e) => { const a = [...(edit.items ?? [])]; a[i] = { ...it, value: e.target.value }; setItems(a); }} />
|
||||||
|
<input className={`${inp} w-28`} dir="ltr" value={it.value} onChange={(e) => { const a = [...(edit.items ?? [])]; a[i] = { ...it, value: e.target.value }; setItems(a); }} />
|
||||||
|
<button className={del} onClick={() => setItems((edit.items ?? []).filter((_, j) => j !== i))}>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button className={ghost} onClick={() => { setEdit(null); setErr(null); }}>انصراف</button>
|
||||||
|
<button className={btn} onClick={save} disabled={saving}>{saving ? "…" : "ذخیره"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-6 text-center text-sm text-gray-500">در حال بارگذاری…</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-[#262b40] py-6 text-center text-sm text-gray-600">پریستی تعریف نشده.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{rows.map((p) => (
|
||||||
|
<li key={p.id} className="flex items-center justify-between rounded-lg border border-[#1e2235] bg-[#0c0e1a] px-3 py-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="truncate text-sm text-gray-200">{p.name || "بدون نام"}</span>
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
{p.items.slice(0, 8).map((it) => <span key={it.id ?? it.element_key} className="h-4 w-4 rounded-sm border border-[#262b40]" style={{ backgroundColor: it.value }} title={it.element_key} />)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-600">{p.items.length} رنگ</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<button className={ghost} onClick={() => setEdit(p)}>ویرایش</button>
|
||||||
|
<button className={del} onClick={() => remove(p)}>حذف</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { AdminThumb } from "@/components/admin/AdminThumb";
|
import { AdminThumb } from "@/components/admin/AdminThumb";
|
||||||
import { FileUploadField } from "@/components/admin/FileUploadField";
|
import { FileUploadField } from "@/components/admin/FileUploadField";
|
||||||
import { ProjectAssets } from "@/components/admin/ProjectAssets";
|
import { ProjectAssets } from "@/components/admin/ProjectAssets";
|
||||||
|
import { ProjectScenes } from "@/components/admin/ProjectScenes";
|
||||||
|
|
||||||
interface Proj {
|
interface Proj {
|
||||||
id: string; container_id: string; container_name: string; container_slug: string;
|
id: string; container_id: string; container_name: string; container_slug: string;
|
||||||
@@ -30,6 +31,8 @@ export function ProjectsAdmin() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const [openAssets, setOpenAssets] = useState<Proj | null>(null);
|
const [openAssets, setOpenAssets] = useState<Proj | null>(null);
|
||||||
|
const [openScenes, setOpenScenes] = useState<Proj | null>(null);
|
||||||
|
const [aepMsg, setAepMsg] = useState<string | null>(null);
|
||||||
const [containers, setContainers] = useState<{ id: string; name: string }[]>([]);
|
const [containers, setContainers] = useState<{ id: string; name: string }[]>([]);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [nf, setNf] = useState({ ...emptyNew });
|
const [nf, setNf] = useState({ ...emptyNew });
|
||||||
@@ -74,10 +77,30 @@ export function ProjectsAdmin() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const attachAep = async (p: Proj, url: string) => {
|
const attachAep = async (p: Proj, url: string) => {
|
||||||
|
if (!url) return;
|
||||||
|
setAepMsg("در حال ذخیرهٔ قالب…");
|
||||||
|
// 1. Copy the uploaded file into the canonical per-template location
|
||||||
|
// (templates/{project_id}/…) so every render of this template reuses it.
|
||||||
|
const bundleRes = await fetch(`/api/admin/resource/template-bundles/${p.id}`, {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ source_url: url }),
|
||||||
|
});
|
||||||
|
const bundle = await bundleRes.json().catch(() => null);
|
||||||
|
// 2. Record the canonical location + md5 on the project (keeps the raw URL even
|
||||||
|
// if the copy step is unavailable, so nothing is lost).
|
||||||
|
const meta: Record<string, unknown> = { aep_file_url: url, render_aep_comp: p.render_aep_comp || "flatrender" };
|
||||||
|
if (bundleRes.ok && bundle) {
|
||||||
|
meta.aep_minio_bucket = bundle.bucket;
|
||||||
|
meta.aep_minio_key = bundle.key;
|
||||||
|
meta.aep_file_md5 = bundle.md5;
|
||||||
|
meta.aep_file_size_bytes = bundle.size_bytes;
|
||||||
|
}
|
||||||
await fetch(`/api/admin/resource/projects/${p.id}/aep`, {
|
await fetch(`/api/admin/resource/projects/${p.id}/aep`, {
|
||||||
method: "PATCH", headers: { "Content-Type": "application/json" },
|
method: "PATCH", headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ aep_file_url: url, render_aep_comp: p.render_aep_comp || "flatrender" }),
|
body: JSON.stringify(meta),
|
||||||
});
|
});
|
||||||
|
if (!bundleRes.ok) setAepMsg(`ذخیره شد، اما آمادهسازی قالب ناموفق بود: ${bundle?.error ?? "خطای ناشناخته"}`);
|
||||||
|
else setAepMsg(bundle?.is_bundle ? "باندل zip آپلود و برای رندر آماده شد ✓" : "فایل افترافکت ذخیره شد ✓");
|
||||||
load();
|
load();
|
||||||
};
|
};
|
||||||
const remove = async (p: Proj) => {
|
const remove = async (p: Proj) => {
|
||||||
@@ -178,7 +201,8 @@ export function ProjectsAdmin() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<button className={ghost} onClick={() => setOpenAssets(p)}>فایلها</button>
|
<button className={ghost} onClick={() => setOpenScenes(p)}>صحنهها</button>
|
||||||
|
<button className={ghost} onClick={() => { setAepMsg(null); setOpenAssets(p); }}>فایلها</button>
|
||||||
<button className="rounded-lg border border-red-500/30 px-2.5 py-1 text-xs text-red-300 hover:bg-red-500/10" onClick={() => remove(p)}>حذف</button>
|
<button className="rounded-lg border border-red-500/30 px-2.5 py-1 text-xs text-red-300 hover:bg-red-500/10" onClick={() => remove(p)}>حذف</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -200,8 +224,10 @@ export function ProjectsAdmin() {
|
|||||||
<h2 className="text-sm font-semibold text-white">مدیریت فایلها — {openAssets.name} <span className="text-gray-500">({openAssets.container_name})</span></h2>
|
<h2 className="text-sm font-semibold text-white">مدیریت فایلها — {openAssets.name} <span className="text-gray-500">({openAssets.container_name})</span></h2>
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-gray-400">فایل افترافکت (.aep / .zip)</label>
|
<label className="mb-1 block text-xs font-medium text-gray-400">فایل افترافکت (.aep یا باندل .zip)</label>
|
||||||
<FileUploadField value={openAssets.aep_file_url ?? ""} onChange={(u) => { attachAep(openAssets, u); setOpenAssets({ ...openAssets, aep_file_url: u }); }} accept=".aep,.aepx,.zip" />
|
<FileUploadField value={openAssets.aep_file_url ?? ""} onChange={(u) => { attachAep(openAssets, u); setOpenAssets({ ...openAssets, aep_file_url: u }); }} accept=".aep,.aepx,.zip" />
|
||||||
|
<p className="mt-1 text-[11px] text-gray-500">برای پروژههایی که فوتیج/فونت دارند، کل پروژه را بهصورت فایل zip آپلود کنید؛ هنگام رندر روی نود استخراج میشود.</p>
|
||||||
|
{aepMsg && <p className="mt-1 text-[11px] text-indigo-300">{aepMsg}</p>}
|
||||||
</div>
|
</div>
|
||||||
<ProjectAssets projectId={openAssets.id} />
|
<ProjectAssets projectId={openAssets.id} />
|
||||||
</div>
|
</div>
|
||||||
@@ -211,6 +237,20 @@ export function ProjectsAdmin() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{openScenes && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-stretch justify-center bg-black/70 p-2 sm:p-6" dir="rtl" onClick={() => setOpenScenes(null)}>
|
||||||
|
<div className={`${card} flex max-h-full w-full max-w-4xl flex-col`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between border-b border-[#1e2235] px-5 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-white">صحنهها و رنگها — {openScenes.name} <span className="text-gray-500">({openScenes.container_name})</span></h2>
|
||||||
|
<button className="rounded-lg px-2 py-1 text-gray-400 hover:bg-[#161a2e] hover:text-white" onClick={() => setOpenScenes(null)}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
|
<ProjectScenes projectId={openScenes.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user