using FlatRender.ContentSvc.Domain.Entities; using FlatRender.ContentSvc.Domain.Enums; using FlatRender.ContentSvc.Infrastructure.Data; using FlatRender.ContentSvc.Models.Requests; using FlatRender.ContentSvc.Models.Responses; using Microsoft.EntityFrameworkCore; namespace FlatRender.ContentSvc.Application.Services; public class TemplateService(ContentDbContext db) { public async Task> GetContainersAsync(ContainerListRequest req) { var q = db.ProjectContainers .Include(x => x.ContainerCategories).ThenInclude(x => x.Category) .Include(x => x.ContainerTags).ThenInclude(x => x.Tag) .AsQueryable(); if (!string.IsNullOrWhiteSpace(req.Search)) q = q.Where(x => EF.Functions.ILike(x.Name, $"%{req.Search}%") || EF.Functions.ILike(x.Slug, $"%{req.Search}%")); if (req.CategoryId.HasValue) q = q.Where(x => x.ContainerCategories.Any(cc => cc.CategoryId == req.CategoryId)); if (!string.IsNullOrWhiteSpace(req.TagSlug)) q = q.Where(x => x.ContainerTags.Any(ct => ct.Tag.Slug == req.TagSlug)); if (req.IsPublished.HasValue) q = q.Where(x => x.IsPublished == req.IsPublished); if (req.IsPremium.HasValue) q = q.Where(x => x.IsPremium == req.IsPremium); if (!string.IsNullOrWhiteSpace(req.Mode)) { if (Enum.TryParse(req.Mode, true, out var mode)) q = q.Where(x => x.PrimaryMode == mode); } q = req.Sort switch { "sort_asc" => q.OrderBy(x => x.Sort), "name_asc" => q.OrderBy(x => x.Name), "view_count_desc" => q.OrderByDescending(x => x.ViewCount), "use_count_desc" or "popular" => q.OrderByDescending(x => x.UseCount).ThenByDescending(x => x.ViewCount), "rating_desc" => q.OrderByDescending(x => x.RateAvg).ThenByDescending(x => x.RateCount), _ => q.OrderByDescending(x => x.SortDate) }; var total = await q.LongCountAsync(); var items = await q.Skip((req.Page - 1) * req.PageSize).Take(req.PageSize).ToListAsync(); return new PagedResponse( items.Select(MapContainerSummary), new PaginationMeta(req.Page, req.PageSize, total, (int)Math.Ceiling((double)total / req.PageSize)) ); } public async Task GetContainerBySlugAsync(string slug) { var container = await db.ProjectContainers .Include(x => x.ContainerCategories).ThenInclude(x => x.Category) .Include(x => x.ContainerTags).ThenInclude(x => x.Tag) .Include(x => x.Projects.Where(p => p.DeletedAt == null)) .FirstOrDefaultAsync(x => x.Slug == slug) ?? throw new KeyNotFoundException($"Container '{slug}' not found"); return MapContainerDetail(container); } public async Task GetContainerByIdAsync(Guid id) { var container = await db.ProjectContainers .Include(x => x.ContainerCategories).ThenInclude(x => x.Category) .Include(x => x.ContainerTags).ThenInclude(x => x.Tag) .Include(x => x.Projects.Where(p => p.DeletedAt == null)) .FirstOrDefaultAsync(x => x.Id == id) ?? throw new KeyNotFoundException($"Container {id} not found"); return MapContainerDetail(container); } public async Task CreateContainerAsync(CreateContainerRequest req) { if (!Enum.TryParse(req.PrimaryMode, true, out var mode)) throw new ArgumentException($"Invalid PrimaryMode: {req.PrimaryMode}"); var container = new ProjectContainer { Slug = req.Slug, Name = req.Name, Description = req.Description, Keywords = req.Keywords, NewsText = req.NewsText, Image = req.Image, Demo = req.Demo, FullDemo = req.FullDemo, MiniDemo = req.MiniDemo, DemoScriptTag = req.DemoScriptTag, IsPublished = req.IsPublished, IsPremium = req.IsPremium, IsMockup = req.IsMockup, PrimaryMode = mode, Sort = req.Sort, SortDate = DateTime.UtcNow }; db.ProjectContainers.Add(container); await db.SaveChangesAsync(); await SyncContainerCategoriesAsync(container.Id, req.CategoryIds); await SyncContainerTagsAsync(container.Id, req.TagIds); return await GetContainerByIdAsync(container.Id); } public async Task UpdateContainerAsync(Guid id, UpdateContainerRequest req) { var container = await db.ProjectContainers.FindAsync(id) ?? throw new KeyNotFoundException($"Container {id} not found"); if (!Enum.TryParse(req.PrimaryMode, true, out var mode)) throw new ArgumentException($"Invalid PrimaryMode: {req.PrimaryMode}"); container.Slug = req.Slug; container.Name = req.Name; container.Description = req.Description; container.Keywords = req.Keywords; container.NewsText = req.NewsText; container.Image = req.Image; container.Demo = req.Demo; container.FullDemo = req.FullDemo; container.MiniDemo = req.MiniDemo; container.DemoScriptTag = req.DemoScriptTag; container.IsPublished = req.IsPublished; container.IsPremium = req.IsPremium; container.IsMockup = req.IsMockup; container.PrimaryMode = mode; container.Sort = req.Sort; container.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); await SyncContainerCategoriesAsync(id, req.CategoryIds); await SyncContainerTagsAsync(id, req.TagIds); return await GetContainerByIdAsync(id); } public async Task DeleteContainerAsync(Guid id) { var container = await db.ProjectContainers.FindAsync(id) ?? throw new KeyNotFoundException($"Container {id} not found"); container.DeletedAt = DateTime.UtcNow; await db.SaveChangesAsync(); } /// Lightweight ranking update — set a template's manual sort weight without a full edit. public async Task SetContainerSortAsync(Guid id, int sort) { var container = await db.ProjectContainers.FindAsync(id) ?? throw new KeyNotFoundException($"Container {id} not found"); container.Sort = sort; container.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); } public async Task GetProjectDetailAsync(Guid id) { var project = await db.Projects .Include(x => x.Scenes.Where(s => s.DeletedAt == null)).ThenInclude(x => x.RepeaterItems) .Include(x => x.Scenes).ThenInclude(x => x.ContentElements) .Include(x => x.Scenes).ThenInclude(x => x.ColorElements) .Include(x => x.Scenes).ThenInclude(x => x.ColorPresets).ThenInclude(x => x.Items) .Include(x => x.Scenes).ThenInclude(x => x.Characters).ThenInclude(x => x.Controllers).ThenInclude(x => x.Options) .Include(x => x.SharedColors) .Include(x => x.SharedLayers) .FirstOrDefaultAsync(x => x.Id == id) ?? throw new KeyNotFoundException($"Project {id} not found"); return MapProjectDetail(project); } public async Task CreateProjectAsync(CreateProjectRequest req) { if (!Enum.TryParse(req.ChooseMode, true, out var chooseMode)) throw new ArgumentException($"Invalid ChooseMode: {req.ChooseMode}"); if (!Enum.TryParse(req.Resolution, true, out var resolution)) throw new ArgumentException($"Invalid Resolution: {req.Resolution}"); var project = new Project { ContainerId = req.ContainerId, ProjectServerId = req.ProjectServerId, Name = req.Name, Description = req.Description, Image = req.Image, FullDemo = req.FullDemo, DemoScriptTag = req.DemoScriptTag, DownloadLink = req.DownloadLink, Folder = req.Folder, OriginalWidth = req.OriginalWidth, OriginalHeight = req.OriginalHeight, Aspect = req.Aspect, ProjectDurationSec = req.ProjectDurationSec, MinDurationSec = req.MinDurationSec, MaxDurationSec = req.MaxDurationSec, FreeFps = req.FreeFps, ChooseMode = chooseMode, Resolution = resolution, VipFactor = req.VipFactor, RenderAepComp = req.RenderAepComp, IsPublished = req.IsPublished, Sort = req.Sort }; db.Projects.Add(project); await db.SaveChangesAsync(); return await GetProjectDetailAsync(project.Id); } /// 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. public async Task 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(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(); 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 UpdateProjectAsync(Guid id, UpdateProjectRequest req) { var project = await db.Projects.FindAsync(id) ?? throw new KeyNotFoundException($"Project {id} not found"); if (!Enum.TryParse(req.ChooseMode, true, out var chooseMode)) throw new ArgumentException($"Invalid ChooseMode: {req.ChooseMode}"); if (!Enum.TryParse(req.Resolution, true, out var resolution)) throw new ArgumentException($"Invalid Resolution: {req.Resolution}"); project.Name = req.Name; project.Description = req.Description; project.Image = req.Image; project.FullDemo = req.FullDemo; project.DemoScriptTag = req.DemoScriptTag; project.DownloadLink = req.DownloadLink; project.Folder = req.Folder; project.OriginalWidth = req.OriginalWidth; project.OriginalHeight = req.OriginalHeight; project.Aspect = req.Aspect; project.ProjectDurationSec = req.ProjectDurationSec; project.MinDurationSec = req.MinDurationSec; project.MaxDurationSec = req.MaxDurationSec; project.FreeFps = req.FreeFps; project.ChooseMode = chooseMode; project.Resolution = resolution; project.VipFactor = req.VipFactor; project.RenderAepComp = req.RenderAepComp; project.SharedLayerImage = req.SharedLayerImage; project.SharedColorsSvg = req.SharedColorsSvg; project.SharedColorPresetsSvg = req.SharedColorPresetsSvg; project.IsPublished = req.IsPublished; project.Sort = req.Sort; project.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return await GetProjectDetailAsync(project.Id); } /// Partial update — only applies supplied (non-null) fields, so editing an /// aspect/resolution never clears render/colour data the full update would require. public async Task PatchProjectAsync(Guid id, PatchProjectRequest req) { var project = await db.Projects.FindAsync(id) ?? throw new KeyNotFoundException($"Project {id} not found"); if (req.Name != null) project.Name = req.Name; if (req.Description != null) project.Description = req.Description; if (req.Aspect != null) project.Aspect = req.Aspect; if (req.Resolution != null) { if (!Enum.TryParse(req.Resolution, true, out var resolution)) throw new ArgumentException($"Invalid Resolution: {req.Resolution}"); project.Resolution = resolution; } if (req.ChooseMode != null) { if (!Enum.TryParse(req.ChooseMode, true, out var chooseMode)) throw new ArgumentException($"Invalid ChooseMode: {req.ChooseMode}"); project.ChooseMode = chooseMode; } if (req.OriginalWidth.HasValue) project.OriginalWidth = req.OriginalWidth.Value; if (req.OriginalHeight.HasValue) project.OriginalHeight = req.OriginalHeight.Value; if (req.ProjectDurationSec.HasValue) project.ProjectDurationSec = req.ProjectDurationSec.Value; if (req.MinDurationSec.HasValue) project.MinDurationSec = req.MinDurationSec; if (req.MaxDurationSec.HasValue) project.MaxDurationSec = req.MaxDurationSec; if (req.FreeFps.HasValue) project.FreeFps = req.FreeFps.Value; if (req.IsPublished.HasValue) project.IsPublished = req.IsPublished.Value; if (req.Sort.HasValue) project.Sort = req.Sort.Value; // media/colour fields (set by the assets-bundle ingestion) if (req.Image != null) project.Image = req.Image; if (req.FullDemo != null) project.FullDemo = req.FullDemo; if (req.SharedColorsSvg != null) project.SharedColorsSvg = req.SharedColorsSvg; project.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return await GetProjectDetailAsync(project.Id); } public async Task DeleteProjectAsync(Guid id) { var project = await db.Projects.FindAsync(id) ?? throw new KeyNotFoundException($"Project {id} not found"); project.DeletedAt = DateTime.UtcNow; await db.SaveChangesAsync(); } public async Task IncrementContainerViewAsync(Guid id) { await db.ProjectContainers .Where(x => x.Id == id) .ExecuteUpdateAsync(s => s.SetProperty(x => x.ViewCount, x => x.ViewCount + 1)); } // ── Helpers ─────────────────────────────────────────────────────────────── private async Task SyncContainerCategoriesAsync(Guid containerId, List categoryIds) { var existing = await db.ContainerCategories.Where(x => x.ContainerId == containerId).ToListAsync(); db.ContainerCategories.RemoveRange(existing); for (int i = 0; i < categoryIds.Count; i++) db.ContainerCategories.Add(new ContainerCategory { ContainerId = containerId, CategoryId = categoryIds[i], Sort = i }); await db.SaveChangesAsync(); } private async Task SyncContainerTagsAsync(Guid containerId, List tagIds) { var existing = await db.ContainerTags.Where(x => x.ContainerId == containerId).ToListAsync(); db.ContainerTags.RemoveRange(existing); foreach (var tagId in tagIds) db.ContainerTags.Add(new ContainerTag { ContainerId = containerId, TagId = tagId }); await db.SaveChangesAsync(); } private static ContainerSummaryResponse MapContainerSummary(ProjectContainer c) => new( c.Id, c.Slug, c.Name, c.Description, c.Image, c.Demo, c.MiniDemo, c.IsPublished, c.IsPremium, c.IsMockup, c.PrimaryMode.ToString(), c.RateAvg, c.RateCount, c.ViewCount, c.UseCount, c.Sort, c.SortDate, c.ContainerCategories.Select(cc => cc.Category.Slug).ToList(), c.ContainerTags.Select(ct => ct.Tag.Name).ToList() ); private static ContainerDetailResponse MapContainerDetail(ProjectContainer c) => new( c.Id, c.Slug, c.Name, c.Description, c.Keywords, c.NewsText, c.Image, c.Demo, c.FullDemo, c.MiniDemo, c.DemoScriptTag, c.IsPublished, c.IsPremium, c.IsMockup, c.PrimaryMode.ToString(), c.RateAvg, c.RateCount, c.ViewCount, c.UseCount, c.Sort, c.SortDate, c.Projects.Select(MapProject).ToList(), c.ContainerCategories.Select(cc => MapCategoryFlat(cc.Category)).ToList(), c.ContainerTags.Select(ct => new TagResponse(ct.Tag.Id, ct.Tag.Name, ct.Tag.LatinName, ct.Tag.Slug, ct.Tag.AppliesToMode, ct.Tag.IsActive)).ToList() ); private static CategoryResponse MapCategoryFlat(Category c) => new( c.Id, c.ParentId, c.Name, c.Slug, c.Description, c.ImageUrl, c.Icon, c.IsActive, c.Sort, [] ); private static ProjectResponse MapProject(Project p) => new( p.Id, p.ContainerId, p.Name, p.Image, p.FullDemo, p.OriginalWidth, p.OriginalHeight, p.Aspect, p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec, p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(), p.IsPublished, p.Sort, p.AepFileUrl, p.AepFileSizeBytes, p.RenderAepComp ); /// Browse/search all projects (template items) across containers. public async Task> ListProjectsAsync(string? q, Guid? containerId, int page, int pageSize) { if (page < 1) page = 1; if (pageSize < 1 || pageSize > 200) pageSize = 30; var query = db.Projects.Where(p => p.DeletedAt == null); if (containerId.HasValue) query = query.Where(p => p.ContainerId == containerId.Value); if (!string.IsNullOrWhiteSpace(q)) query = query.Where(p => EF.Functions.ILike(p.Name, $"%{q}%")); var total = await query.LongCountAsync(); var items = await query .OrderByDescending(p => p.UpdatedAt) .Skip((page - 1) * pageSize).Take(pageSize) .Select(p => new ProjectListItemResponse( p.Id, p.ContainerId, p.Container.Name, p.Container.Slug, p.Name, p.Image, p.Aspect, p.Resolution.ToString(), p.AepFileUrl, p.RenderAepComp, p.IsPublished, p.Sort)) .ToListAsync(); return new PagedResponse(items, new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize))); } public async Task> ListAssetsAsync(Guid projectId) => await db.ProjectAssets.Where(a => a.ProjectId == projectId).OrderBy(a => a.Sort) .Select(a => new ProjectAssetResponse(a.Id, a.ProjectId, a.Name, a.Kind, a.Url, a.SizeBytes, a.Sort)) .ToListAsync(); public async Task AddAssetAsync(Guid projectId, CreateAssetRequest req) { var a = new ProjectAsset { ProjectId = projectId, Name = req.Name, Kind = string.IsNullOrWhiteSpace(req.Kind) ? "footage" : req.Kind, Url = req.Url, MinioKey = req.MinioKey, SizeBytes = req.SizeBytes, Sort = req.Sort, }; db.ProjectAssets.Add(a); await db.SaveChangesAsync(); return new ProjectAssetResponse(a.Id, a.ProjectId, a.Name, a.Kind, a.Url, a.SizeBytes, a.Sort); } public async Task DeleteAssetAsync(Guid assetId) { var a = await db.ProjectAssets.FindAsync(assetId); if (a != null) { db.ProjectAssets.Remove(a); await db.SaveChangesAsync(); } } /// Attach an uploaded After Effects file (and render composition) to a project. public async Task SetProjectAepAsync(Guid id, SetAepRequest req) { var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id && p.DeletedAt == null) ?? throw new KeyNotFoundException($"Project {id} not found"); if (req.AepFileUrl != null) project.AepFileUrl = req.AepFileUrl; if (req.AepMinioBucket != null) project.AepMinioBucket = req.AepMinioBucket; if (req.AepMinioKey != null) project.AepMinioKey = req.AepMinioKey; if (req.AepFileMd5 != null) project.AepFileMd5 = req.AepFileMd5; if (req.AepFileSizeBytes.HasValue) project.AepFileSizeBytes = req.AepFileSizeBytes; if (!string.IsNullOrWhiteSpace(req.RenderAepComp)) project.RenderAepComp = req.RenderAepComp; if (req.Folder != null) project.Folder = req.Folder; project.AepUploadedAt = DateTime.UtcNow; project.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return await GetProjectDetailAsync(id); } private static ProjectDetailResponse MapProjectDetail(Project p) => new( p.Id, p.ContainerId, p.Name, p.Description, p.Image, p.FullDemo, p.DemoScriptTag, p.DownloadLink, p.OriginalWidth, p.OriginalHeight, p.Aspect, p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec, p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(), p.VipFactor, p.RenderAepComp, p.SharedLayerImage, p.IsPublished, p.Sort, p.Scenes.Select(MapScene).ToList(), p.SharedColors.Select(sc => new SharedColorResponse(sc.Id, sc.ElementKey, sc.Title, sc.Icon, sc.AttrValue.ToString(), sc.DefaultColor, sc.Sort)).ToList(), p.SharedLayers.Select(MapSharedLayer).ToList() ); private static SceneResponse MapScene(Scene s) => new( s.Id, s.ProjectId, s.Key, s.Title, s.LocalizedTitle, s.SceneType.ToString(), s.Image, s.Demo, s.SnapshotUrl, s.GenerateKf, s.DefaultDurationSec, s.MinDurationSec, s.MaxDurationSec, s.OverlapAtEndSec, s.CanHandleDuration, s.ManualColorSelection, s.Sort, s.IsActive ); private static SharedLayerResponse MapSharedLayer(SharedLayer sl) => new( sl.Id, sl.Key, sl.Title, sl.LocalizedTitle, sl.Hint, sl.Type.ToString(), sl.DefaultValue, sl.FontId, sl.FontFace, sl.FontSize, sl.IsFontChangeable, sl.IsFontSizeChangeable, sl.Justify.ToString(), sl.CanJustify, sl.PositionInContainer, sl.IsTextBox, sl.MaxSize, sl.VideoSupport, sl.MinDurationSec, sl.MaxDurationSec, sl.Width, sl.Height, sl.MappedList, sl.AiInputType.ToString(), sl.IsHidden, sl.IsFocused, sl.VirtualCount, sl.Sort ); }