feat: migrate legacy playout workflow and scenes

This commit is contained in:
2026-07-10 23:58:45 +09:00
parent 491a740505
commit 8dae7b8e0d
128 changed files with 39177 additions and 205 deletions

View File

@@ -104,6 +104,11 @@ internal sealed record ValidatedPlayoutOptions(
allowlist.Add(value!);
}
if (options.Mode is PlayoutMode.Test or PlayoutMode.Live && allowlist.Count == 0)
{
throw Invalid(nameof(options.TestSceneAllowlist));
}
if (options.Mode == PlayoutMode.Test)
{
if (!IsLiteralLoopback(host))
@@ -116,11 +121,6 @@ internal sealed record ValidatedPlayoutOptions(
throw Invalid(nameof(options.OutputChannel));
}
if (allowlist.Count == 0)
{
throw Invalid(nameof(options.TestSceneAllowlist));
}
var pattern = RequiredText(
options.TestProcessWindowTitlePattern,
nameof(options.TestProcessWindowTitlePattern));
@@ -170,6 +170,13 @@ internal sealed record ValidatedPlayoutOptions(
}
public bool IsTestSceneAllowed(PlayoutCue cue)
=> IsOutputSceneAllowed(cue);
/// <summary>
/// Closed scene allowlist applied to every real Test or Live mutation command.
/// The legacy property name is retained for local-config compatibility.
/// </summary>
public bool IsOutputSceneAllowed(PlayoutCue cue)
{
var sceneName = cue.SceneName?.Trim();
return !string.IsNullOrEmpty(sceneName) && TestSceneAllowlist.Contains(sceneName);
@@ -200,6 +207,8 @@ internal sealed record ValidatedPlayoutOptions(
}
}
var mutations = ResolveMutations(cue.Mutations);
var relativePath = cue.SceneFile?.Trim();
if (string.IsNullOrEmpty(relativePath) || Path.IsPathRooted(relativePath))
{
@@ -242,10 +251,226 @@ internal sealed record ValidatedPlayoutOptions(
return cue with
{
SceneFile = candidate,
SceneName = sceneName!
SceneName = sceneName!,
Mutations = mutations
};
}
private IReadOnlyList<PlayoutMutation>? ResolveMutations(
IReadOnlyList<PlayoutMutation>? mutations)
{
if (mutations is null)
{
return null;
}
if (mutations.Count > 100_000)
{
throw new PlayoutRequestException("장면 변경 항목이 너무 많습니다.");
}
var resolved = new PlayoutMutation[mutations.Count];
for (var index = 0; index < mutations.Count; index++)
{
resolved[index] = ResolveMutation(mutations[index]);
}
return resolved;
}
private PlayoutMutation ResolveMutation(PlayoutMutation? mutation)
{
if (mutation is null)
{
throw new PlayoutRequestException("장면 변경 항목이 올바르지 않습니다.");
}
switch (mutation)
{
case PlayoutSetValue value:
ValidateObjectName(value.ObjectName);
ValidateText(value.Value);
return value;
case PlayoutSetAssetValue asset:
ValidateObjectName(asset.ObjectName);
return asset with { AssetPath = ResolveAssetPath(asset.AssetPath) };
case PlayoutSetVisible visible:
ValidateObjectName(visible.ObjectName);
return visible;
case PlayoutSetFaceColor color:
ValidateObjectName(color.ObjectName);
if (!IsByte(color.Red) || !IsByte(color.Green) ||
!IsByte(color.Blue) || !IsByte(color.Alpha))
{
throw new PlayoutRequestException("장면 색상 값이 올바르지 않습니다.");
}
return color;
case PlayoutSetPosition position:
ValidateObjectName(position.ObjectName);
ValidatePoint(position.X, position.Y, position.Z);
ValidateVectorComponents(position.Components);
return position;
case PlayoutSetPositionKey positionKey:
ValidateObjectName(positionKey.ObjectName);
ValidateKeyIndex(positionKey.KeyIndex);
ValidatePoint(positionKey.X, positionKey.Y, positionKey.Z);
ValidateVectorComponents(positionKey.Components);
return positionKey;
case PlayoutSetScale scale:
ValidateObjectName(scale.ObjectName);
ValidatePoint(scale.X, scale.Y, scale.Z);
ValidateVectorComponents(scale.Components);
return scale;
case PlayoutSetCropKey crop:
ValidateObjectName(crop.ObjectName);
ValidateKeyIndex(crop.KeyIndex);
ValidatePoint(crop.Left, crop.Top, crop.Right);
if (!float.IsFinite(crop.Bottom) ||
!Enum.IsDefined(crop.Edges) || crop.Edges == 0)
{
throw new PlayoutRequestException("장면 Crop 값이 올바르지 않습니다.");
}
return crop;
case PlayoutSetCircleAngleKey angle:
ValidateObjectName(angle.ObjectName);
ValidateKeyIndex(angle.KeyIndex);
if (!float.IsFinite(angle.Start) || !float.IsFinite(angle.End) ||
!Enum.IsDefined(angle.Components) || angle.Components == 0)
{
throw new PlayoutRequestException("장면 각도 값이 올바르지 않습니다.");
}
return angle;
case PlayoutSetPathPoints path:
ValidateObjectName(path.ObjectName);
ValidatePoints(path.Points);
return path;
case PlayoutSetPathShapePoints shape:
ValidateObjectName(shape.ObjectName);
ValidatePoints(shape.Points);
return shape;
case PlayoutUseBackground background:
ValidateMutationTiming(background.Timing);
return background;
case PlayoutSetBackgroundTexture texture:
ValidateMutationTiming(texture.Timing);
return texture with { AssetPath = ResolveAssetPath(texture.AssetPath) };
case PlayoutSetBackgroundVideo video:
ValidateMutationTiming(video.Timing);
// The legacy MainForm intentionally uses 2004 for the common studio
// background. Keep the input bounded while retaining that vendor value.
if (video.LoopCount is < 0 or > 10_000)
{
throw new PlayoutRequestException("장면 배경 영상 반복 값이 올바르지 않습니다.");
}
return video with { AssetPath = ResolveAssetPath(video.AssetPath) };
default:
throw new PlayoutRequestException("지원하지 않는 장면 변경 항목입니다.");
}
}
private string ResolveAssetPath(string? relativePath)
{
relativePath = relativePath?.Trim();
if (SceneDirectory is null || string.IsNullOrEmpty(relativePath) ||
Path.IsPathRooted(relativePath) || relativePath.Contains("..", StringComparison.Ordinal) ||
relativePath.Any(char.IsControl))
{
throw new PlayoutRequestException("장면 자산 경로가 올바르지 않습니다.");
}
var extension = Path.GetExtension(relativePath);
string[] allowedExtensions =
[
".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dds",
".vrv", ".avi", ".mp4", ".mov"
];
if (!allowedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
{
throw new PlayoutRequestException("지원하지 않는 장면 자산 형식입니다.");
}
var candidate = Path.GetFullPath(Path.Combine(SceneDirectory, relativePath));
var rootPrefix = SceneDirectory.EndsWith(Path.DirectorySeparatorChar)
? SceneDirectory
: SceneDirectory + Path.DirectorySeparatorChar;
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ||
!File.Exists(candidate) || HasReparsePointBetween(SceneDirectory, candidate))
{
throw new PlayoutRequestException("허용된 장면 자산을 찾을 수 없습니다.");
}
return candidate;
}
private static void ValidateObjectName(string? objectName)
{
if (string.IsNullOrWhiteSpace(objectName) || objectName.Length > 128 ||
objectName.Any(char.IsControl))
{
throw new PlayoutRequestException("장면 오브젝트 이름이 올바르지 않습니다.");
}
}
private static void ValidateText(string? value)
{
if (value is null || value.Length > 16_384 || value.Any(character =>
character is '\0' or '\u0001' or '\u0002' or '\u0003'))
{
throw new PlayoutRequestException("장면 텍스트 값이 올바르지 않습니다.");
}
}
private static void ValidatePoint(float x, float y, float z)
{
if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z))
{
throw new PlayoutRequestException("장면 좌표 값이 올바르지 않습니다.");
}
}
private static void ValidatePoints(IReadOnlyList<PlayoutPoint>? points)
{
if (points is null || points.Count > 10_000)
{
throw new PlayoutRequestException("장면 그래프 점 목록이 올바르지 않습니다.");
}
foreach (var point in points)
{
ValidatePoint(point.X, point.Y, point.Z);
}
}
private static void ValidateVectorComponents(PlayoutVectorComponents components)
{
if (!Enum.IsDefined(components) || components == 0)
{
throw new PlayoutRequestException("장면 벡터 축 값이 올바르지 않습니다.");
}
}
private static void ValidateKeyIndex(int keyIndex)
{
if (keyIndex is < 0 or > 10_000)
{
throw new PlayoutRequestException("장면 키 인덱스가 올바르지 않습니다.");
}
}
private static void ValidateMutationTiming(PlayoutMutationTiming timing)
{
if (!Enum.IsDefined(timing))
{
throw new PlayoutRequestException("장면 변경 시점이 올바르지 않습니다.");
}
}
private static bool IsByte(int value) => value is >= byte.MinValue and <= byte.MaxValue;
private static bool CanMatchProgramTitle(Regex regex)
{
string[] protectedTitles =