Files
flatrender/services/studio/FlatRender.StudioSvc/Application/Services/StudioService.cs
T
soroush.asadi 90ac0b81d1 feat: V2 microservices stack — backend services, gateway, JWT auth
Add full V2 architecture: identity, content, studio (.NET 10) and file,
render, notification, gateway (Go) services with vendored deps, plus DB
migrations, event/API contracts, and an init-db script.

Wire the Next.js frontend to the gateway: server-side JWT auth routes
(login/register/refresh/logout/me), gateway fetch helper, and session/
cookie/jwt helpers under src/lib.

Containerize the stack via docker-compose.v2.yml and per-service
Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and
MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via
next/font/local to avoid Google Fonts (geo-blocked).

Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:29:31 +03:30

298 lines
15 KiB
C#

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<PagedResponse<SavedProjectSummaryResponse>> 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<SavedProjectType>(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<SavedProjectSummaryResponse>(
items.Select(MapSummary),
new PaginationMeta(req.Page, req.PageSize, total,
(int)Math.Ceiling((double)total / req.PageSize)));
}
public async Task<SavedProjectFullResponse> 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);
}
/// <summary>Internal: load project without user-ownership check (for service-to-service calls).</summary>
public async Task<SavedProjectFullResponse> GetProjectForRenderAsync(Guid id)
{
var p = await LoadProjectQuery()
.FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Project {id} not found");
return MapFull(p);
}
private IQueryable<Domain.Entities.SavedProject> 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<SavedProjectFullResponse> 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<SavedProjectFullResponse> 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<SavedProjectType>(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();
}
/// <summary>
/// Full scene save — replaces all scene data atomically.
/// Called by the studio editor on every save checkpoint.
/// </summary>
public async Task<SavedProjectFullResponse> SaveScenesAsync(
Guid projectId, Guid userId, List<SaveSceneRequest> 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
};
}