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
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:
@@ -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
|
||||
);
|
||||
Reference in New Issue
Block a user