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, }; await using var tx = await db.Database.BeginTransactionAsync(); db.SavedProjects.Add(project); await db.SaveChangesAsync(); // Deep-copy the template's scene graph (scenes + content/colour elements) from // the content schema so the new project opens editable in the studio. Same DB, // so this is one atomic cross-schema copy. if (req.CopyDefaultValues && req.OriginalProjectId != Guid.Empty) await CopyTemplateGraphAsync(project.Id, req.OriginalProjectId); await tx.CommitAsync(); return await GetProjectAsync(project.Id, userId); } /// /// Copies a content template project's scenes, content elements and colour elements /// into a freshly-created editable project. Runs inside the caller's transaction; /// the temp scene-id map drops on commit. Same Postgres DB → atomic cross-schema copy. /// private async Task CopyTemplateGraphAsync(Guid savedProjectId, Guid originalProjectId) { // 1. scenes → capture old→new id mapping in a temp table await db.Database.ExecuteSqlRawAsync(@" CREATE TEMP TABLE _scene_map ON COMMIT DROP AS WITH ins AS ( INSERT INTO studio.saved_scenes (saved_project_id, original_scene_id, key, title, image, demo, scene_color_svg, scene_type, sort, scene_length_sec, min_duration_sec, max_duration_sec, overlap_at_end_sec, can_handle_duration, manual_color_selection, created_at, updated_at) SELECT {0}, s.id, s.key, s.title, s.image, s.demo, s.scene_color_svg, s.scene_type::text, s.sort, COALESCE(s.default_duration_sec, 0), s.min_duration_sec, s.max_duration_sec, s.overlap_at_end_sec, s.can_handle_duration, s.manual_color_selection, now(), now() FROM content.scenes s WHERE s.project_id = {1} AND s.deleted_at IS NULL AND s.is_active = true RETURNING id AS new_id, original_scene_id AS old_id ) SELECT new_id, old_id FROM ins;", savedProjectId, originalProjectId); // 2. content elements — incl. repeater children flattened via repeater_item_key await db.Database.ExecuteSqlRawAsync(@" INSERT INTO studio.saved_scene_contents (saved_scene_id, key, title, localized_title, hint, type, value, font_face, font_face_name, font_size, default_font_size, default_font_face, justify, position_in_container, direction_layer_value, is_text_box, ai_input_type, mapped_list, thumbnail, sort, repeater_item_key, repeater_index, status, created_at, updated_at) SELECT sm.new_id, ce.key, ce.title, ce.localized_title, ce.hint, ce.type::text, ce.default_value, ce.font_face, ce.font_face_name, ce.font_size, ce.default_font_size, ce.default_font_face, ce.justify::text, ce.position_in_container, ce.direction_layer_value, ce.is_text_box, ce.ai_input_type::text, ce.mapped_list, ce.thumbnail, ce.sort, ri.repeat_item_key, ri.sort, 'default', now(), now() FROM content.scene_content_elements ce JOIN _scene_map sm ON sm.old_id = ce.scene_id LEFT JOIN content.repeater_items ri ON ri.id = ce.repeater_item_id;"); // 3. colour elements await db.Database.ExecuteSqlRawAsync(@" INSERT INTO studio.saved_scene_colors (saved_scene_id, element_key, title, icon, attr_value, value, is_selected, sort) SELECT sm.new_id, ce.element_key, ce.title, ce.icon, ce.attr_value::text, COALESCE(ce.default_color, ''), false, ce.sort FROM content.scene_color_elements ce JOIN _scene_map sm ON sm.old_id = ce.scene_id;"); // 3b. characters — studio's `key` is a uuid; store the original character id await db.Database.ExecuteSqlRawAsync(@" INSERT INTO studio.saved_scene_characters (saved_scene_id, key, name, icon) SELECT sm.new_id, ch.id, ch.name, ch.icon FROM content.scene_characters ch JOIN _scene_map sm ON sm.old_id = ch.scene_id;"); // 3c. character controllers (correlate the new character by scene + original id) await db.Database.ExecuteSqlRawAsync(@" INSERT INTO studio.saved_scene_character_controllers (saved_scene_character_id, name, key, value, sort) SELECT sc.id, cc.name, cc.key, cc.default_value, cc.sort FROM content.scene_character_controllers cc JOIN content.scene_characters cch ON cch.id = cc.scene_character_id JOIN _scene_map sm ON sm.old_id = cch.scene_id JOIN studio.saved_scene_characters sc ON sc.saved_scene_id = sm.new_id AND sc.key = cch.id;"); // 3d. colour presets await db.Database.ExecuteSqlRawAsync(@" INSERT INTO studio.saved_scene_color_presets (saved_scene_id, is_selected, sort) SELECT sm.new_id, false, cp.sort FROM content.scene_color_presets cp JOIN _scene_map sm ON sm.old_id = cp.scene_id;"); // 3e. colour preset items (correlate the new preset by scene + sort) await db.Database.ExecuteSqlRawAsync(@" INSERT INTO studio.saved_scene_color_preset_items (preset_id, element_key, value, sort) SELECT sp.id, ci.element_key, ci.value, ci.sort FROM content.scene_color_preset_items ci JOIN content.scene_color_presets cp ON cp.id = ci.preset_id JOIN _scene_map sm ON sm.old_id = cp.scene_id JOIN studio.saved_scene_color_presets sp ON sp.saved_scene_id = sm.new_id AND sp.sort = cp.sort;"); // 4. shared (project-level) colours — frshare frd_* controls await db.Database.ExecuteSqlRawAsync(@" INSERT INTO studio.saved_shared_colors (saved_project_id, element_key, title, icon, attr_value, value, is_selected, sort) SELECT {0}, sc.element_key, sc.title, sc.icon, sc.attr_value::text, COALESCE(sc.default_color, ''), false, sc.sort FROM content.shared_colors sc WHERE sc.project_id = {1};", savedProjectId, originalProjectId); } 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 }; }