feat: migrate legacy playout workflow and scenes
This commit is contained in:
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user