feat: align legacy operator and playout behavior

This commit is contained in:
2026-07-15 21:04:05 +09:00
parent 06a16e5741
commit 83398044c6
77 changed files with 6377 additions and 391 deletions

View File

@@ -12,12 +12,12 @@ public sealed record LegacyParityStartupCompositionResolution(
/// <summary>
/// Resolves the startup-only MainForm background behavior used by LegacyParityApp.
/// An explicit local file remains authoritative; only an absent file enables the
/// validated legacy 기본.vrv fallback.
/// validated legacy Cuts\video\기본.vrv fallback.
/// </summary>
public static class LegacyParityStartupCompositionResolver
{
public const string MissingLegacyDefaultWarning =
"신뢰 배경 폴더에서 기본.vrv를 확인할 수 없어 배경없음으로 시작했습니다.";
"신뢰 Cuts 경로에서 video\\기본.vrv를 확인할 수 없어 배경없음으로 시작했습니다.";
public static LegacyParityStartupCompositionResolution Resolve(
PlayoutOptions options,
@@ -34,7 +34,7 @@ public static class LegacyParityStartupCompositionResolver
try
{
return new LegacyParityStartupCompositionResolution(
PlayoutSceneCompositionFactory.CreateLegacyDefault(
PlayoutSceneCompositionFactory.CreateLegacyStartupDefault(
options,
configured.FadeDuration),
string.Empty);

View File

@@ -21,7 +21,7 @@ public sealed class PlayoutOptions
public int LayoutIndex { get; set; } = 10;
/// <summary>MainForm ComboDi.SelectedIndex default passed to SetSceneEffectType.</summary>
/// <summary>MainForm ComboDi.SelectedIndex (0..19) passed to SetSceneEffectType.</summary>
public int LegacySceneFadeDuration { get; set; } = 6;
/// <summary>

View File

@@ -4,12 +4,14 @@ using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
/// <summary>
/// Validates the trusted MainForm-level fade/background configuration before the
/// first DryRun or actual PREPARE. Web content never supplies these values.
/// Validates the MainForm-level fade/background composition before the first DryRun
/// or actual PREPARE. Background paths stay native/trusted; fade is restricted to the
/// original closed DissolveTime selector index (0..19).
/// </summary>
public static class PlayoutSceneCompositionFactory
{
public const string LegacyDefaultBackgroundFileName = "기본.vrv";
public const string LegacyStartupDefaultBackgroundAssetPath = @"video\기본.vrv";
private static readonly IReadOnlySet<string> AllowedExtensions = new HashSet<string>(
[
@@ -116,6 +118,38 @@ public static class PlayoutSceneCompositionFactory
}
}
/// <summary>
/// Restores the background assigned by MainForm at startup, Cuts\video\기본.vrv.
/// This is intentionally distinct from the F2/F3 default in the sibling 배경 root.
/// </summary>
public static LegacySceneCueCompositionOptions CreateLegacyStartupDefault(
PlayoutOptions options,
int fadeDuration)
{
ArgumentNullException.ThrowIfNull(options);
ValidateCommon(fadeDuration, options);
try
{
var root = TrustedPlayoutAssetPath.ValidateRoot(
options.SceneDirectory?.Trim() ?? string.Empty);
var candidate = TrustedPlayoutAssetPath.ResolveRelativeFile(
root,
LegacyStartupDefaultBackgroundAssetPath,
AllowedExtensions);
return CreateResult(
options,
fadeDuration,
LegacySceneBackgroundKind.Video,
Path.GetRelativePath(root, candidate),
PlayoutAssetRoot.SceneDirectory);
}
catch (TrustedPlayoutAssetException)
{
throw Invalid();
}
}
public static bool TryGetTrustedBackgroundDirectory(
PlayoutOptions options,
out string directory)
@@ -190,17 +224,18 @@ public static class PlayoutSceneCompositionFactory
PlayoutOptions options,
int fadeDuration,
LegacySceneBackgroundKind kind,
string relativePath) => new(
string relativePath,
PlayoutAssetRoot assetRoot = PlayoutAssetRoot.OperatorBackgroundDirectory) => new(
fadeDuration,
kind,
relativePath,
options.LegacySceneBackgroundVideoLoopCount,
options.LegacySceneBackgroundVideoLoopInfinite,
PlayoutAssetRoot.OperatorBackgroundDirectory);
assetRoot);
private static void ValidateCommon(int fadeDuration, PlayoutOptions options)
{
if (fadeDuration is < 0 or > 60 ||
if (fadeDuration is < 0 or > 19 ||
!Enum.IsDefined(options.LegacySceneBackgroundKind) ||
options.LegacySceneBackgroundVideoLoopCount is < 0 or > 10_000)
{

View File

@@ -208,7 +208,7 @@ internal sealed record ValidatedPlayoutOptions(
throw new PlayoutRequestException("장면 이름이 올바르지 않습니다.");
}
if (cue.FadeDuration is < 0 or > 60)
if (cue.FadeDuration is < 0 or > 19)
{
throw new PlayoutRequestException("장면 전환 시간이 올바르지 않습니다.");
}
@@ -388,6 +388,14 @@ internal sealed record ValidatedPlayoutOptions(
throw new PlayoutRequestException("장면 배경 영상 반복 값이 올바르지 않습니다.");
}
// Installed K3D Interop declares LoopInfinite as Int32. The source
// application uses only 0, 1 (s5006), and 10 (common background).
if (video.LoopInfinite is not (0 or 1 or 10))
{
throw new PlayoutRequestException(
"장면 배경 영상 무한 반복 값이 허용된 원본 값이 아닙니다.");
}
return video with
{
AssetPath = ResolveAssetPath(video.AssetPath, video.AssetRoot)

View File

@@ -22,6 +22,8 @@ internal interface IK3dSession : IDisposable
bool HasPendingPlayCallbacks { get; }
bool HasPendingTakeOutCallbacks { get; }
bool TryPreventKtapConnect();
void Connect(ValidatedPlayoutOptions options);
@@ -230,6 +232,9 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
public bool HasPendingPlayCallbacks =>
SupportsLifecycleCallbacks && _pendingPlayCallbacks != 0;
public bool HasPendingTakeOutCallbacks => SupportsLifecycleCallbacks &&
(_pendingCutOutCallbacks != 0 || _pendingStopAllCallbacks != 0);
public bool TryPreventKtapConnect()
{
var previous = Interlocked.CompareExchange(ref _ktapDispatchGate, 2, 0);
@@ -855,7 +860,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
"SetBackgroundVideo",
video.AssetPath,
video.LoopCount,
video.LoopInfinite ? 1 : 0);
video.LoopInfinite);
break;
default:
throw new InvalidOperationException("The scene setup mutation is not allowlisted.");

View File

@@ -15,6 +15,7 @@ public static class K3dComConstants
public const string ApartmentThreadingModel = "Apartment";
public const int LegacyLayoutIndex = 10;
public const int SceneEffectInEnabled = 1;
// MainForm passes the vendor API's raw bInEffect value 10. It is not a Boolean.
public const int SceneEffectInEnabled = 10;
public const int SceneChangeEffectFade = 7;
}

View File

@@ -1204,6 +1204,18 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return MarkOutcomeUnknown(operation);
}
if (_session?.HasPendingTakeOutCallbacks == true)
{
const string pendingTakeOutMessage =
"The previous SDK CutOut/StopAll completion callback is still pending. " +
"No further scene command is allowed until Tornado confirms the result.";
PublishStatus(pendingTakeOutMessage);
return Result(
operation,
PlayoutResultCode.Unavailable,
pendingTakeOutMessage);
}
if (RequiresCompletedPlay(operation) &&
_session?.HasPendingPlayCallbacks == true)
{
@@ -1810,8 +1822,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
private PlayoutStatus BuildStatus(string message)
{
var connected = _session is not null && !_outcomeUnknown;
var commandAvailable = _options.Mode == PlayoutMode.DryRun ||
(connected && IsProcessEligible(_processSnapshot));
var commandAvailable = (_options.Mode == PlayoutMode.DryRun ||
(connected && IsProcessEligible(_processSnapshot))) &&
_session?.HasPendingTakeOutCallbacks != true;
var liveAllowed = connected &&
IsProcessEligible(_processSnapshot) &&
CanTakeIn();
@@ -1830,6 +1843,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
{
OperationTimeoutMilliseconds = (int)_options.OperationTimeout.TotalMilliseconds,
IsPlayCompletionPending = _session?.HasPendingPlayCallbacks == true,
IsTakeOutCompletionPending = _session?.HasPendingTakeOutCallbacks == true,
LastKtapConnectState = (PlayoutKtapConnectState)Volatile.Read(
ref _lastKtapConnectState),
KtapHelloObserved = Volatile.Read(ref _ktapHelloObservedState) switch