using FlatRender.StudioSvc.Domain.Entities; using FlatRender.StudioSvc.Domain.Enums; using FlatRender.StudioSvc.Infrastructure.Data; using FlatRender.StudioSvc.Models.Requests; using FlatRender.StudioSvc.Models.Responses; using Microsoft.EntityFrameworkCore; namespace FlatRender.StudioSvc.Application.Services; public class StudioService(StudioDbContext db) { public async Task> ListProjectsAsync( Guid userId, SavedProjectListRequest req) { var q = db.SavedProjects.Where(x => x.UserId == userId); if (!string.IsNullOrWhiteSpace(req.Q)) q = q.Where(x => EF.Functions.ILike(x.Name, $"%{req.Q}%")); if (!string.IsNullOrWhiteSpace(req.Type) && Enum.TryParse(req.Type, true, out var t)) q = q.Where(x => x.Type == t); var total = await q.LongCountAsync(); var items = await q .OrderByDescending(x => x.LastEditDate) .Skip((req.Page - 1) * req.PageSize) .Take(req.PageSize) .ToListAsync(); return new PagedResponse( items.Select(MapSummary), new PaginationMeta(req.Page, req.PageSize, total, (int)Math.Ceiling((double)total / req.PageSize))); } public async Task GetProjectAsync(Guid id, Guid userId) { var p = await LoadProjectQuery() .FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId) ?? throw new KeyNotFoundException($"Project {id} not found"); return MapFull(p); } /// Internal: load project without user-ownership check (for service-to-service calls). public async Task GetProjectForRenderAsync(Guid id) { var p = await LoadProjectQuery() .FirstOrDefaultAsync(x => x.Id == id) ?? throw new KeyNotFoundException($"Project {id} not found"); return MapFull(p); } private IQueryable LoadProjectQuery() => db.SavedProjects .Include(x => x.Scenes.OrderBy(s => s.Sort)) .ThenInclude(s => s.Contents.OrderBy(c => c.Sort)) .Include(x => x.Scenes).ThenInclude(s => s.Colors.OrderBy(c => c.Sort)) .Include(x => x.Scenes).ThenInclude(s => s.ColorPresets.OrderBy(c => c.Sort)) .ThenInclude(cp => cp.Items.OrderBy(i => i.Sort)) .Include(x => x.Scenes).ThenInclude(s => s.Characters) .ThenInclude(ch => ch.Controllers.OrderBy(c => c.Sort)) .Include(x => x.SharedColors.OrderBy(c => c.Sort)) .Include(x => x.SharedColorPresets.OrderBy(c => c.Sort)) .ThenInclude(cp => cp.Items.OrderBy(i => i.Sort)) .Include(x => x.SharedLayers.OrderBy(l => l.Sort)); public async Task CreateProjectAsync( Guid userId, Guid tenantId, CreateSavedProjectRequest req) { var project = new SavedProject { TenantId = tenantId, UserId = userId, OriginalProjectId = req.OriginalProjectId, OriginalProjectName = req.Name ?? $"Project {DateTime.UtcNow:yyyy-MM-dd}", Name = req.Name ?? $"Project {DateTime.UtcNow:yyyy-MM-dd}", SelectedPresetStoryId = req.PresetStoryId, ProjectDurationSec = 0, }; db.SavedProjects.Add(project); await db.SaveChangesAsync(); return await GetProjectAsync(project.Id, userId); } public async Task UpdateProjectAsync( Guid id, Guid userId, UpdateSavedProjectRequest req) { var project = await db.SavedProjects .FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId) ?? throw new KeyNotFoundException($"Project {id} not found"); if (req.Name != null) project.Name = req.Name; if (req.Image != null) project.Image = req.Image; if (req.Type != null && Enum.TryParse(req.Type, true, out var t)) project.Type = t; if (req.MusicFileId.HasValue) project.MusicFileId = req.MusicFileId; if (req.MusicTrackId.HasValue) project.MusicTrackId = req.MusicTrackId; if (req.MusicVolume.HasValue) project.MusicVolume = req.MusicVolume.Value; if (req.VoiceoverFileId.HasValue) project.VoiceoverFileId = req.VoiceoverFileId; if (req.VoiceoverVolume.HasValue) project.VoiceoverVolume = req.VoiceoverVolume.Value; if (req.VoiceoverRecordedInBrowser.HasValue) project.VoiceoverRecordedInBrowser = req.VoiceoverRecordedInBrowser.Value; if (req.SfxVolume.HasValue) project.SfxVolume = req.SfxVolume.Value; if (req.SfxEnabled.HasValue) project.SfxEnabled = req.SfxEnabled.Value; if (req.AudioVisualizerMusicUrl != null) project.AudioVisualizerMusicUrl = req.AudioVisualizerMusicUrl; if (req.AudioVisualizerDurationSec.HasValue) project.AudioVisualizerDurationSec = req.AudioVisualizerDurationSec; if (req.ManualColorPicker.HasValue) project.ManualColorPicker = req.ManualColorPicker.Value; if (req.SelectedPresetStoryId.HasValue) project.SelectedPresetStoryId = req.SelectedPresetStoryId; if (req.LastEditStep != null) project.LastEditStep = req.LastEditStep; if (req.EditState != null) project.EditState = req.EditState; project.LastEditDate = DateTime.UtcNow; project.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return await GetProjectAsync(id, userId); } public async Task DeleteProjectAsync(Guid id, Guid userId) { var project = await db.SavedProjects .FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId) ?? throw new KeyNotFoundException($"Project {id} not found"); project.DeletedAt = DateTime.UtcNow; await db.SaveChangesAsync(); } /// /// Full scene save — replaces all scene data atomically. /// Called by the studio editor on every save checkpoint. /// public async Task SaveScenesAsync( Guid projectId, Guid userId, List scenes) { var project = await db.SavedProjects .FirstOrDefaultAsync(x => x.Id == projectId && x.UserId == userId) ?? throw new KeyNotFoundException($"Project {projectId} not found"); // Delete existing scenes (cascade deletes children) var existing = await db.SavedScenes .Where(x => x.SavedProjectId == projectId).ToListAsync(); db.SavedScenes.RemoveRange(existing); foreach (var (sceneReq, idx) in scenes.Select((s, i) => (s, i))) { var scene = new SavedScene { SavedProjectId = projectId, OriginalSceneId = sceneReq.OriginalSceneId, Key = sceneReq.Key, Title = sceneReq.Title, Image = sceneReq.Image, SceneColorSvg = sceneReq.SceneColorSvg, SceneType = sceneReq.SceneType, Sort = sceneReq.Sort, SceneLengthSec = sceneReq.SceneLengthSec, MinDurationSec = sceneReq.MinDurationSec, MaxDurationSec = sceneReq.MaxDurationSec, OverlapAtEndSec = sceneReq.OverlapAtEndSec, CanHandleDuration = sceneReq.CanHandleDuration, ManualColorSelection = sceneReq.ManualColorSelection, }; foreach (var c in sceneReq.Contents) scene.Contents.Add(MapContentEntity(c)); foreach (var c in sceneReq.Colors) scene.Colors.Add(new SavedSceneColor { ElementKey = c.ElementKey, Title = c.Title, Icon = c.Icon, AttrValue = c.AttrValue, Value = c.Value, IsSelected = c.IsSelected, Sort = c.Sort }); db.SavedScenes.Add(scene); } // Sync shared colors var sharedColors = scenes.SelectMany(s => s.SharedColors).DistinctBy(x => x.ElementKey).ToList(); if (sharedColors.Count > 0) { var existingSC = await db.SavedSharedColors .Where(x => x.SavedProjectId == projectId).ToListAsync(); db.SavedSharedColors.RemoveRange(existingSC); foreach (var c in sharedColors) db.SavedSharedColors.Add(new SavedSharedColor { SavedProjectId = projectId, ElementKey = c.ElementKey, Title = c.Title, Icon = c.Icon, AttrValue = c.AttrValue, Value = c.Value, IsSelected = c.IsSelected, Sort = c.Sort }); } // Sync shared layers var sharedLayers = scenes.SelectMany(s => s.SharedLayers).DistinctBy(x => x.Key).ToList(); if (sharedLayers.Count > 0) { var existingSL = await db.SavedSharedLayers .Where(x => x.SavedProjectId == projectId).ToListAsync(); db.SavedSharedLayers.RemoveRange(existingSL); foreach (var l in sharedLayers) db.SavedSharedLayers.Add(MapSharedLayerEntity(projectId, l)); } project.LastEditDate = DateTime.UtcNow; project.UpdatedAt = DateTime.UtcNow; // Recalc total duration project.ProjectDurationSec = scenes.Sum(s => s.SceneLengthSec - s.OverlapAtEndSec); await db.SaveChangesAsync(); return await GetProjectAsync(projectId, userId); } // ── Mappers ─────────────────────────────────────────────────────────────── private static SavedProjectSummaryResponse MapSummary(SavedProject p) => new( p.Id, p.UserId, p.OriginalProjectId, p.OriginalProjectName, p.OriginalContainerSlug, p.Name, p.Image, p.Type.ToString(), p.Resolution, p.ChooseMode, p.ProjectDurationSec, p.LastEditDate, p.CreatedAt); private static SavedProjectFullResponse MapFull(SavedProject p) => new( p.Id, p.UserId, p.OriginalProjectId, p.OriginalProjectName, p.OriginalContainerSlug, p.Name, p.Image, p.Type.ToString(), p.FrameRate, p.ProjectDurationSec, p.Resolution, p.ChooseMode, p.VipFactor, p.MusicFileId, p.MusicTrackId, p.MusicVolume, p.VoiceoverFileId, p.VoiceoverVolume, p.VoiceoverRecordedInBrowser, p.SfxVolume, p.SfxEnabled, p.AudioVisualizerMusicUrl, p.AudioVisualizerDurationSec, p.ManualColorPicker, p.SelectedPresetStoryId, p.LastEditStep, p.EditState, p.LastEditDate, p.CreatedAt, p.Scenes.Select(MapSceneResponse).ToList(), p.SharedColors.Select(MapSharedColorResponse).ToList(), p.SharedColorPresets.Select(MapSharedColorPresetResponse).ToList(), p.SharedLayers.Select(MapSharedLayerResponse).ToList() ); private static SavedSceneResponse MapSceneResponse(SavedScene s) => new( s.Id, s.OriginalSceneId, s.Key, s.Title, s.Image, s.SceneType, s.Sort, s.SceneLengthSec, s.MinDurationSec, s.MaxDurationSec, s.OverlapAtEndSec, s.CanHandleDuration, s.ManualColorSelection, s.SelectedColorPresetId, s.Contents.Select(MapContentResponse).ToList(), s.Colors.Select(c => new SavedSceneColorResponse(c.Id, c.ElementKey, c.Title, c.Icon, c.AttrValue, c.Value, c.IsSelected, c.Sort)).ToList(), s.ColorPresets.Select(cp => new SavedSceneColorPresetResponse(cp.Id, cp.IsSelected, cp.Sort, cp.Items.Select(i => new SavedColorPresetItemResponse(i.Id, i.ElementKey, i.Value, i.Sort)).ToList() )).ToList(), s.Characters.Select(ch => new SavedSceneCharacterResponse(ch.Id, ch.Key, ch.Name, ch.Icon, ch.Controllers.Select(c => new SavedCharacterControllerResponse(c.Id, c.Name, c.Key, c.Value, c.Sort)).ToList() )).ToList() ); private static SavedSceneContentResponse MapContentResponse(SavedSceneContent c) => new( c.Id, c.Key, c.Title, c.Type, c.Value, c.ValueFileId, c.FileUrlCached, c.FontFace, c.FontSize, c.Justify, c.PositionInContainer, c.DirectionLayerValue, c.IsTextBox, c.AiInputType, c.SelectedDp, c.RepeaterItemKey, c.RepeaterIndex, c.IsFocused, c.MappedList, c.Sort); private static SavedSharedColorResponse MapSharedColorResponse(SavedSharedColor c) => new(c.Id, c.ElementKey, c.Title, c.Icon, c.AttrValue, c.Value, c.IsSelected, c.Sort); private static SavedSharedColorPresetResponse MapSharedColorPresetResponse(SavedSharedColorPreset cp) => new(cp.Id, cp.Name, cp.IsSelected, cp.Sort, cp.Items.Select(i => new SavedColorPresetItemResponse(i.Id, i.ElementKey, i.Value, i.Sort)).ToList()); private static SavedSharedLayerResponse MapSharedLayerResponse(SavedSharedLayer l) => new( l.Id, l.Key, l.Title, l.Type, l.Value, l.ValueFileId, l.FileUrlCached, l.FontFace, l.FontSize, l.Justify, l.PositionInContainer, l.DirectionLayerValue, l.IsTextBox, l.AiInputType, l.MappedList, l.Width, l.Height, l.IsFocused, l.IsFontChangeable, l.IsFontSizeChangeable, l.Sort); private static SavedSceneContent MapContentEntity(SaveSceneContentRequest c) => new() { Key = c.Key, Title = c.Title, Type = c.Type, Value = c.Value, ValueFileId = c.ValueFileId, InsertedFileType = c.InsertedFileType, FontFace = c.FontFace, FontFaceName = c.FontFaceName, FontSize = c.FontSize, DefaultFontSize = c.DefaultFontSize, DefaultFontFace = c.DefaultFontFace, Justify = c.Justify, PositionInContainer = c.PositionInContainer, DirectionLayerValue = c.DirectionLayerValue, IsTextBox = c.IsTextBox, AiInputType = c.AiInputType, SelectedDp = c.SelectedDp, RepeaterItemKey = c.RepeaterItemKey, RepeaterIndex = c.RepeaterIndex, IsFocused = c.IsFocused, MappedList = c.MappedList, Thumbnail = c.Thumbnail, Sort = c.Sort }; private static SavedSharedLayer MapSharedLayerEntity(Guid projectId, SaveSharedLayerRequest l) => new() { SavedProjectId = projectId, Key = l.Key, Title = l.Title, Type = l.Type, Value = l.Value, ValueFileId = l.ValueFileId, FontFace = l.FontFace, FontFaceName = l.FontFaceName, FontSize = l.FontSize, Justify = l.Justify, PositionInContainer = l.PositionInContainer, DirectionLayerValue = l.DirectionLayerValue, IsTextBox = l.IsTextBox, AiInputType = l.AiInputType, MappedList = l.MappedList, Thumbnail = l.Thumbnail, Width = l.Width, Height = l.Height, IsFocused = l.IsFocused, IsFontChangeable = l.IsFontChangeable, IsFontSizeChangeable = l.IsFontSizeChangeable, Sort = l.Sort }; }