feat: cross-aspect project duplication + AEP convention/rule-engine spec
Build backend images / build content-svc (push) Failing after 1s
Build backend images / build file-svc (push) Failing after 0s
Build backend images / build gateway (push) Failing after 0s
Build backend images / build identity-svc (push) Failing after 0s
Build backend images / build notification-svc (push) Failing after 1s
Build backend images / build render-svc (push) Failing after 2s
Build backend images / build studio-svc (push) Failing after 0s

- content-svc: DuplicateProjectAsync clones full scene/element/colour graph
  (identical keys, new dimensions/aspect; AEP intentionally not copied;
  starts unpublished) + POST /v1/projects/{id}/duplicate.
- admin: «تکثیر» button + modal on each project row; aspects reduced to
  supported 16:9/1:1/9:16; free fps default 21 (clamped 1-60).
- docs/aep-template-convention.md: versioned (v1/v2) convention + rule-engine
  spec — modes, scene types, flatrender assembly, duration/fade model,
  fit-box, input types, expression-driven data flow, output spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 16:59:23 +03:30
parent 1ff6e494c0
commit ee670552a8
9 changed files with 872 additions and 3 deletions
@@ -184,6 +184,163 @@ public class TemplateService(ContentDbContext db)
return await GetProjectDetailAsync(project.Id);
}
/// <summary>Clone a project (all scenes / content-elements / colour-elements / presets /
/// shared colours / shared layers) into a NEW project for a different aspect ratio. All
/// keys/values are copied identically — only the output dimensions (+ optional name/container)
/// change. The AEP file is intentionally NOT copied: each aspect has its own .aep, attached
/// after. The duplicate starts unpublished until its .aep is set.</summary>
public async Task<ProjectDetailResponse> DuplicateProjectAsync(Guid id, DuplicateProjectRequest req)
{
var src = await db.Projects
.Include(p => p.Scenes.Where(s => s.DeletedAt == null)).ThenInclude(s => s.RepeaterItems)
.Include(p => p.Scenes).ThenInclude(s => s.ContentElements)
.Include(p => p.Scenes).ThenInclude(s => s.ColorElements)
.Include(p => p.Scenes).ThenInclude(s => s.ColorPresets).ThenInclude(cp => cp.Items)
.Include(p => p.SharedColors)
.Include(p => p.SharedLayers)
.Include(p => p.SharedColorPresets).ThenInclude(sp => sp.Items)
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == id)
?? throw new KeyNotFoundException($"Project {id} not found");
var resolution = src.Resolution;
if (req.Resolution != null)
{
if (!Enum.TryParse<ResolutionKind>(req.Resolution, true, out var r))
throw new ArgumentException($"Invalid Resolution: {req.Resolution}");
resolution = r;
}
var np = new Project
{
ContainerId = req.ContainerId ?? src.ContainerId,
ProjectServerId = src.ProjectServerId,
Name = string.IsNullOrWhiteSpace(req.Name) ? $"{src.Name} ({req.Aspect ?? src.Aspect})" : req.Name!,
Description = src.Description, Image = src.Image, FullDemo = src.FullDemo,
DemoScriptTag = src.DemoScriptTag, DownloadLink = src.DownloadLink, Folder = src.Folder,
// AEP fields deliberately left null — this aspect gets its own .aep.
OriginalWidth = req.OriginalWidth ?? src.OriginalWidth,
OriginalHeight = req.OriginalHeight ?? src.OriginalHeight,
Aspect = req.Aspect ?? src.Aspect,
ProjectDurationSec = src.ProjectDurationSec, MinDurationSec = src.MinDurationSec,
MaxDurationSec = src.MaxDurationSec, FreeFps = src.FreeFps, ChooseMode = src.ChooseMode,
Resolution = resolution, VipFactor = src.VipFactor, RenderAepComp = src.RenderAepComp,
SharedLayerImage = src.SharedLayerImage, SharedColorsSvg = src.SharedColorsSvg,
SharedColorPresetsSvg = src.SharedColorPresetsSvg,
IsPublished = false, Sort = src.Sort,
};
foreach (var s in src.Scenes.OrderBy(x => x.Sort))
{
var ns = new Scene
{
Key = s.Key, Title = s.Title, LocalizedTitle = s.LocalizedTitle, SceneType = s.SceneType,
Image = s.Image, Demo = s.Demo, SceneColorSvg = s.SceneColorSvg, SnapshotUrl = s.SnapshotUrl,
GenerateKf = s.GenerateKf, DefaultDurationSec = s.DefaultDurationSec, MinDurationSec = s.MinDurationSec,
MaxDurationSec = s.MaxDurationSec, OverlapAtEndSec = s.OverlapAtEndSec, CanHandleDuration = s.CanHandleDuration,
ManualColorSelection = s.ManualColorSelection, Sort = s.Sort, IsActive = s.IsActive,
};
// Repeater items first, so repeater-scoped content elements can re-parent to the clones.
var repMap = new Dictionary<Guid, RepeaterItem>();
foreach (var ri in s.RepeaterItems)
{
var nri = new RepeaterItem
{
Title = ri.Title, RepeatBoxKey = ri.RepeatBoxKey, RepeatItemKey = ri.RepeatItemKey,
MaxRepeatCount = ri.MaxRepeatCount, UserCanChangeSort = ri.UserCanChangeSort,
RepeatSortStrategy = ri.RepeatSortStrategy, Sort = ri.Sort,
};
repMap[ri.Id] = nri;
ns.RepeaterItems.Add(nri);
}
foreach (var ce in s.ContentElements)
{
var nce = CloneContentElement(ce);
ns.ContentElements.Add(nce);
if (ce.RepeaterItemId.HasValue && repMap.TryGetValue(ce.RepeaterItemId.Value, out var nri))
nri.ContentElements.Add(nce); // sets RepeaterItemId on the clone
}
foreach (var col in s.ColorElements)
ns.ColorElements.Add(new SceneColorElement
{
ElementKey = col.ElementKey, Title = col.Title, Icon = col.Icon,
AttrValue = col.AttrValue, DefaultColor = col.DefaultColor, Sort = col.Sort,
});
foreach (var cp in s.ColorPresets)
{
var ncp = new SceneColorPreset { Name = cp.Name, Sort = cp.Sort };
foreach (var it in cp.Items)
ncp.Items.Add(new SceneColorPresetItem { ElementKey = it.ElementKey, Value = it.Value, Sort = it.Sort });
ns.ColorPresets.Add(ncp);
}
np.Scenes.Add(ns);
}
foreach (var sc in src.SharedColors)
np.SharedColors.Add(new SharedColor
{
ElementKey = sc.ElementKey, Title = sc.Title, Icon = sc.Icon,
AttrValue = sc.AttrValue, DefaultColor = sc.DefaultColor, Sort = sc.Sort,
});
foreach (var sp in src.SharedColorPresets)
{
var nsp = new SharedColorPreset { Name = sp.Name, Sort = sp.Sort };
foreach (var it in sp.Items)
nsp.Items.Add(new SharedColorPresetItem { ElementKey = it.ElementKey, Value = it.Value, Sort = it.Sort });
np.SharedColorPresets.Add(nsp);
}
foreach (var sl in src.SharedLayers)
np.SharedLayers.Add(CloneSharedLayer(sl));
db.Projects.Add(np);
await db.SaveChangesAsync();
return await GetProjectDetailAsync(np.Id);
}
private static SceneContentElement CloneContentElement(SceneContentElement e) => new()
{
Key = e.Key, Title = e.Title, LocalizedTitle = e.LocalizedTitle, Hint = e.Hint,
Type = e.Type, DefaultValue = e.DefaultValue,
FontId = e.FontId, FontFace = e.FontFace, FontFaceName = e.FontFaceName, FontSize = e.FontSize,
DefaultFontSize = e.DefaultFontSize, DefaultFontFace = e.DefaultFontFace,
IsFontChangeable = e.IsFontChangeable, IsFontSizeChangeable = e.IsFontSizeChangeable,
Justify = e.Justify, CanJustify = e.CanJustify, PositionInContainer = e.PositionInContainer,
IsTextBox = e.IsTextBox, MaxSize = e.MaxSize, DirectionLayerKey = e.DirectionLayerKey,
DirectionLayerValue = e.DirectionLayerValue, VideoSupport = e.VideoSupport,
MinDurationSec = e.MinDurationSec, MaxDurationSec = e.MaxDurationSec, Width = e.Width, Height = e.Height,
Thumbnail = e.Thumbnail, MappedList = e.MappedList, CounterMode = e.CounterMode,
AiInputType = e.AiInputType, IsHidden = e.IsHidden, IsFocused = e.IsFocused,
OpacityControllerKey = e.OpacityControllerKey,
Dp1Image = e.Dp1Image, Dp1Title = e.Dp1Title, Dp2Image = e.Dp2Image, Dp2Title = e.Dp2Title,
Dp3Image = e.Dp3Image, Dp3Title = e.Dp3Title, Dp4Image = e.Dp4Image, Dp4Title = e.Dp4Title,
VirtualCount = e.VirtualCount, Sort = e.Sort,
};
private static SharedLayer CloneSharedLayer(SharedLayer l) => new()
{
Key = l.Key, Title = l.Title, LocalizedTitle = l.LocalizedTitle, Hint = l.Hint,
Type = l.Type, DefaultValue = l.DefaultValue,
FontId = l.FontId, FontFace = l.FontFace, FontFaceName = l.FontFaceName, FontSize = l.FontSize,
DefaultFontSize = l.DefaultFontSize, DefaultFontFace = l.DefaultFontFace,
IsFontChangeable = l.IsFontChangeable, IsFontSizeChangeable = l.IsFontSizeChangeable,
Justify = l.Justify, CanJustify = l.CanJustify, PositionInContainer = l.PositionInContainer,
IsTextBox = l.IsTextBox, MaxSize = l.MaxSize, DirectionLayerKey = l.DirectionLayerKey,
DirectionLayerValue = l.DirectionLayerValue, VideoSupport = l.VideoSupport,
MinDurationSec = l.MinDurationSec, MaxDurationSec = l.MaxDurationSec, Width = l.Width, Height = l.Height,
Thumbnail = l.Thumbnail, MappedList = l.MappedList, CounterMode = l.CounterMode,
AiInputType = l.AiInputType, IsHidden = l.IsHidden, IsFocused = l.IsFocused,
Dp1Image = l.Dp1Image, Dp1Title = l.Dp1Title, Dp2Image = l.Dp2Image, Dp2Title = l.Dp2Title,
Dp3Image = l.Dp3Image, Dp3Title = l.Dp3Title, Dp4Image = l.Dp4Image, Dp4Title = l.Dp4Title,
VirtualCount = l.VirtualCount, Sort = l.Sort,
};
public async Task<ProjectDetailResponse> UpdateProjectAsync(Guid id, UpdateProjectRequest req)
{
var project = await db.Projects.FindAsync(id)
@@ -0,0 +1,17 @@
using FlatRender.ContentSvc.Application.Services;
using FlatRender.ContentSvc.Models.Requests;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.ContentSvc.Controllers;
/// <summary>Duplicate a project to another aspect ratio (same scene/element structure, new size).</summary>
[ApiController]
[Route("v1/projects")]
public class ProjectDuplicateController(TemplateService svc) : ControllerBase
{
[Authorize(Roles = "Admin")]
[HttpPost("{id:guid}/duplicate")]
public async Task<IActionResult> Duplicate(Guid id, [FromBody] DuplicateProjectRequest req)
=> Ok(await svc.DuplicateProjectAsync(id, req ?? new DuplicateProjectRequest(null, null, null, null, null, null)));
}
@@ -0,0 +1,13 @@
namespace FlatRender.ContentSvc.Models.Requests;
/// <summary>Clone a project to a new aspect ratio. Only dimensions (+ optional name/container/
/// resolution) change; all scene/element/colour keys are copied identically. Null fields inherit
/// from the source project.</summary>
public record DuplicateProjectRequest(
string? Aspect,
int? OriginalWidth,
int? OriginalHeight,
string? Resolution,
string? Name,
Guid? ContainerId
);