6661f53734
Build backend images / build content-svc (push) Failing after 1m25s
Build backend images / build file-svc (push) Failing after 1m10s
Build backend images / build gateway (push) Failing after 56s
Build backend images / build identity-svc (push) Failing after 53s
Build backend images / build notification-svc (push) Failing after 57s
Build backend images / build render-svc (push) Failing after 48s
Build backend images / build studio-svc (push) Failing after 1m5s
- scan.jsx: app.beginSuppressDialogs() + clean quit (no AE hang on font/footage dialogs); FIX-mode branch parses frl_c(x)t/m(y) layer names → scenes by c(x); flexible/mockup keep comp-based walk; FR_SCAN_MODE selects. - render-svc: scan job carries project mode; cancel endpoint + node watchdog that kills AE on cancel; parseObjectURL handles minio:// (bucket in host); scan with no template fails cleanly; status guards so late results can't un-cancel. - content importer: revive soft-deleted scenes instead of duplicate-inserting (fixes scenes_project_id_key unique violation); orphan diff ignores deleted. - admin: scan dialog gets project-type picker + elapsed timer + Cancel button. - node-agent: AE-2026 wiring (host port 5010, host-reachable presign endpoint), FR_SCAN_MODE plumbing. docs/aep-template-convention.md: per-type naming + bundles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
285 lines
14 KiB
C#
285 lines
14 KiB
C#
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)
|
|
{
|
|
// Load ALL scenes incl. soft-deleted: the UNIQUE(project_id,key) constraint
|
|
// covers deleted rows too, so a re-scan must REVIVE a soft-deleted match
|
|
// rather than insert a duplicate (which would violate the constraint).
|
|
var existing = await db.Scenes
|
|
.Where(s => s.ProjectId == projectId)
|
|
.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);
|
|
}
|
|
else if (scene!.DeletedAt is not null)
|
|
{
|
|
scene.DeletedAt = null; // revive a soft-deleted scene the scan re-discovered
|
|
}
|
|
ApplySceneHeader(scene!, ss, isNew, apply);
|
|
ApplyElements(scene!, scElems, exElemByKey, scElemKeys, apply);
|
|
ApplyColors(scene!, scColors, exColorByKey, scColorKeys, apply);
|
|
}
|
|
}
|
|
|
|
// Orphans = currently-active scenes the scan no longer contains (ignore
|
|
// already soft-deleted rows so they don't inflate the diff).
|
|
var orphans = existing.Where(s => s.DeletedAt == null && !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;
|
|
}
|