feat: add safe Tornado K3D playout adapter

This commit is contained in:
2026-07-10 06:41:48 +09:00
parent 39c4504b87
commit 5a8c7028dc
43 changed files with 7341 additions and 92 deletions

View File

@@ -36,7 +36,23 @@
selectedRows: new Set(),
currentIndex: -1,
dragIndex: -1,
playout: "IDLE",
playout: {
mode: "unknown",
engineState: "IDLE",
processDetected: false,
connected: false,
liveTakeInAllowed: false,
message: "송출 어댑터 상태를 확인하고 있습니다.",
preparedCode: null,
onAirCode: null,
preparedCue: null,
changedAt: null,
changedAtMs: 0,
latestStatusRequestId: null,
pending: null,
error: null,
lastSignature: ""
},
database: { sources: [], loading: true, lastSignature: "" },
liveData: {
requestId: null,
@@ -66,7 +82,22 @@
playlistBody: document.querySelector("#playlistBody"),
playlistCount: document.querySelector("#playlistCount"), playlistEmpty: document.querySelector("#playlistEmpty"),
previewTitle: document.querySelector("#previewTitle"), previewSubtitle: document.querySelector("#previewSubtitle"),
playoutState: document.querySelector("#playoutState"), eventLog: document.querySelector("#eventLog"),
playoutState: document.querySelector("#playoutState"),
playoutSummary: document.querySelector("#playoutSummary"),
playoutSummaryIcon: document.querySelector("#playoutSummaryIcon"),
playoutSummaryDetail: document.querySelector("#playoutSummaryDetail"),
playoutSummaryBadge: document.querySelector("#playoutSummaryBadge"),
tornadoProcessDot: document.querySelector("#tornadoProcessDot"),
tornadoProcessState: document.querySelector("#tornadoProcessState"),
playoutConnectionDot: document.querySelector("#playoutConnectionDot"),
playoutConnectionState: document.querySelector("#playoutConnectionState"),
playoutSafetyMode: document.querySelector("#playoutSafetyMode"),
playoutStatusMessage: document.querySelector("#playoutStatusMessage"),
playoutError: document.querySelector("#playoutError"),
playoutErrorMessage: document.querySelector("#playoutErrorMessage"),
prepareButton: document.querySelector("#prepareButton"), takeInButton: document.querySelector("#takeInButton"),
nextButton: document.querySelector("#nextButton"), takeOutButton: document.querySelector("#takeOutButton"),
eventLog: document.querySelector("#eventLog"),
toast: document.querySelector("#toast")
};
@@ -207,10 +238,12 @@
if (!item) {
elements.previewTitle.textContent = "선택된 그래픽 없음";
elements.previewSubtitle.textContent = "플레이리스트 항목을 선택하세요.";
renderPlayout();
return;
}
elements.previewTitle.textContent = item.title;
elements.previewSubtitle.textContent = `${item.code} · ${item.detail}`;
renderPlayout();
}
function onDragStart(event) {
@@ -294,34 +327,312 @@
}
}
function setPlayout(nextState, message) {
state.playout = nextState;
elements.playoutState.textContent = nextState;
elements.playoutState.classList.toggle("neutral", nextState === "IDLE");
addLog(message);
function normalizePlayoutState(value) {
const normalized = String(value || "").trim().toLocaleLowerCase("en-US").replace(/[\s_-]+/g, "");
if (["prepared", "ready", "cued"].includes(normalized)) return "PREPARED";
if (["program", "onair", "takein", "live"].includes(normalized)) return "PROGRAM";
if (["dryrun", "preview", "simulation", "simulated"].includes(normalized)) return "DRY RUN";
return "IDLE";
}
function prepare() {
if (!state.playlist[state.currentIndex]) return showToast("먼저 그래픽 항목을 선택하세요.");
setPlayout("PREPARED", `${state.playlist[state.currentIndex].code} PREPARE`);
function normalizePlayoutMode(value) {
return String(value || "unknown").trim().toLocaleLowerCase("en-US").replace(/[\s_]+/g, "-");
}
function takeIn() {
if (state.playout !== "PREPARED") return showToast("PREPARE가 먼저 필요합니다.");
setPlayout("PROGRAM", `${state.playlist[state.currentIndex].code} TAKE IN · Tornado 어댑터 대기`);
showToast("UI 동작 확인 완료 — 실제 송출 어댑터는 아직 연결되지 않았습니다.");
function isDryRunMode(mode = state.playout.mode) {
return /dry-?run|preview|simulat/.test(normalizePlayoutMode(mode));
}
function next() {
if (!state.playlist.length) return showToast("플레이리스트가 비어 있습니다.");
state.currentIndex = (state.currentIndex + 1) % state.playlist.length;
renderPlaylist();
updatePreview();
setPlayout("PREPARED", `${state.playlist[state.currentIndex].code} NEXT / PREPARE`);
function isLiveMode(mode = state.playout.mode) {
return /live|program/.test(normalizePlayoutMode(mode));
}
function takeOut() {
setPlayout("IDLE", "TAKE OUT");
function isTestMode(mode = state.playout.mode) {
return /test/.test(normalizePlayoutMode(mode));
}
function playoutDisplayState() {
return isDryRunMode() ? "DRY RUN" : state.playout.engineState;
}
function setStatusDot(element, status) {
element.classList.remove("pending", "healthy", "error", "warn", "ok", "unconfigured");
element.classList.add(status);
}
function cueFromItem(item) {
if (!item) return null;
return { code: String(item.code), title: String(item.title), detail: String(item.detail) };
}
function findCueByCode(code) {
if (!code) return null;
const current = state.playlist[state.currentIndex];
if (String(current?.code) === String(code)) return cueFromItem(current);
return cueFromItem(state.playlist.find(item => String(item.code) === String(code)));
}
function renderPlayout() {
const displayState = playoutDisplayState();
const pending = state.playout.pending;
const dryRun = isDryRunMode();
const liveMode = isLiveMode();
const testMode = isTestMode();
const bridgeReady = Boolean(nativeBridge);
const outcomeUnknown = state.playout.error?.outcomeUnknown === true;
const commandReady = bridgeReady && !pending && !outcomeUnknown && (state.playout.connected || dryRun);
const currentCue = cueFromItem(state.playlist[state.currentIndex]);
const prepared = state.playout.engineState === "PREPARED" || Boolean(state.playout.preparedCode);
const takeInSafe = dryRun || ((testMode || liveMode) && state.playout.liveTakeInAllowed);
elements.playoutState.textContent = displayState;
elements.playoutState.classList.remove("neutral", "live", "dry-run", "error");
if (displayState === "IDLE") elements.playoutState.classList.add("neutral");
if (displayState === "PROGRAM") elements.playoutState.classList.add("live");
if (displayState === "DRY RUN") elements.playoutState.classList.add("dry-run");
elements.tornadoProcessState.textContent = state.playout.processDetected ? "감지됨" : "미감지";
setStatusDot(elements.tornadoProcessDot, state.playout.processDetected ? "healthy" : (bridgeReady ? "error" : "unconfigured"));
elements.playoutConnectionState.textContent = state.playout.connected ? "연결됨" : "미연결";
setStatusDot(elements.playoutConnectionDot, state.playout.connected ? "healthy" : (bridgeReady ? "error" : "unconfigured"));
const safetyContainer = elements.playoutSafetyMode.closest(".playout-health-item");
safetyContainer.classList.remove("live-allowed", "live-locked");
if (dryRun) {
elements.playoutSafetyMode.textContent = "DRY RUN · PROGRAM 차단";
} else if (testMode && state.playout.liveTakeInAllowed) {
elements.playoutSafetyMode.textContent = "TEST · 전용 출력 허용";
safetyContainer.classList.add("live-allowed");
} else if (testMode) {
elements.playoutSafetyMode.textContent = "TEST TAKE IN 잠금";
safetyContainer.classList.add("live-locked");
} else if (liveMode && state.playout.liveTakeInAllowed) {
elements.playoutSafetyMode.textContent = "LIVE 허용";
safetyContainer.classList.add("live-allowed");
} else if (liveMode) {
elements.playoutSafetyMode.textContent = "LIVE TAKE IN 잠금";
safetyContainer.classList.add("live-locked");
} else {
elements.playoutSafetyMode.textContent = bridgeReady ? "모드 확인 중" : "미리보기 전용";
}
const changedLabel = state.playout.changedAtMs
? ` · ${new Date(state.playout.changedAtMs).toLocaleTimeString("ko-KR", { hour12: false })}`
: "";
elements.playoutStatusMessage.textContent = pending
? `${pending.command.toUpperCase()} 요청 처리 중 · ${pending.cue?.code || "송출 엔진"}`
: `${state.playout.message || "송출 상태를 확인할 수 없습니다."}${changedLabel}`;
elements.playoutStatusMessage.classList.toggle("pending", Boolean(pending));
elements.playoutStatusMessage.classList.toggle("error", Boolean(state.playout.error));
elements.playoutSummary.classList.remove("ready", "dry-run", "error");
if (dryRun) {
elements.playoutSummary.classList.add("dry-run");
elements.playoutSummaryBadge.textContent = "DRY RUN";
elements.playoutSummaryDetail.textContent = bridgeReady ? "안전 모드 · PROGRAM 출력 차단" : "브라우저 미리보기 전용";
} else if (state.playout.connected) {
elements.playoutSummary.classList.add("ready");
elements.playoutSummaryBadge.textContent = testMode
? (state.playout.liveTakeInAllowed ? "TEST READY" : "TEST LOCKED")
: (state.playout.liveTakeInAllowed ? "LIVE READY" : "CONNECTED");
elements.playoutSummaryDetail.textContent = testMode
? "테스트 전용 출력 · 안전 게이트 적용"
: (state.playout.processDetected ? "Tornado2 프로세스 · 엔진 연결됨" : "엔진 연결됨 · 프로세스 미감지");
} else {
elements.playoutSummary.classList.add("error");
elements.playoutSummaryBadge.textContent = bridgeReady ? "OFFLINE" : "PREVIEW";
elements.playoutSummaryDetail.textContent = bridgeReady ? "Tornado 어댑터 연결 필요" : "Native Bridge 없음";
}
elements.prepareButton.disabled = !commandReady || !currentCue;
elements.takeInButton.disabled = !commandReady || !prepared || !takeInSafe;
elements.nextButton.disabled = !commandReady || state.playlist.length === 0;
elements.takeOutButton.disabled = !commandReady || (state.playout.engineState === "IDLE" && !state.playout.preparedCode && !state.playout.onAirCode);
for (const button of [elements.prepareButton, elements.takeInButton, elements.nextButton, elements.takeOutButton]) {
button.setAttribute("aria-busy", String(Boolean(pending)));
}
elements.playoutError.hidden = !state.playout.error;
elements.playoutErrorMessage.textContent = state.playout.error?.message || "";
document.querySelector("#clearPlayoutErrorButton").textContent = outcomeUnknown ? "확인 후 잠금 해제" : "닫기";
}
function clearPlayoutError(render = true) {
state.playout.error = null;
if (render) renderPlayout();
}
function showPlayoutError({ code = "PLAYOUT_ERROR", message, retryable = false, outcomeUnknown = false }) {
const details = [message || "송출 명령을 완료하지 못했습니다."];
if (code) details.push(`[${code}]`);
if (outcomeUnknown) details.push("실제 송출 결과를 확인하세요.");
else if (retryable) details.push("상태 확인 후 재시도할 수 있습니다.");
state.playout.error = { code, message: details.join(" "), retryable, outcomeUnknown };
renderPlayout();
showToast(state.playout.error.message);
}
function resolveCommand(command) {
const current = state.playlist[state.currentIndex];
if (command === "prepare") {
return current ? { cue: cueFromItem(current), targetIndex: state.currentIndex } : null;
}
if (command === "take-in") {
const cue = state.playout.preparedCue || findCueByCode(state.playout.preparedCode);
return cue ? { cue, targetIndex: state.currentIndex } : null;
}
if (command === "next") {
if (!state.playlist.length) return null;
const targetIndex = state.currentIndex < 0 ? 0 : (state.currentIndex + 1) % state.playlist.length;
return { cue: cueFromItem(state.playlist[targetIndex]), targetIndex };
}
if (command === "take-out") return { cue: null, targetIndex: state.currentIndex };
return null;
}
function requestPlayoutCommand(command) {
if (!nativeBridge) {
showToast("브라우저 미리보기에서는 송출 명령을 실행하지 않습니다.");
return;
}
if (state.playout.pending) return;
const resolved = resolveCommand(command);
if (!resolved) {
const message = command === "take-in" ? "먼저 PREPARE를 완료하세요." : "먼저 그래픽 항목을 선택하세요.";
showToast(message);
return;
}
const requestId = createId();
const pending = {
requestId,
command,
cue: resolved.cue,
targetIndex: resolved.targetIndex,
timeoutId: null
};
state.playout.pending = pending;
clearPlayoutError(false);
renderPlayout();
addLog(`${resolved.cue?.code || "ENGINE"} ${command.toUpperCase()} 요청`);
const payload = { requestId, command };
if (resolved.cue) payload.cue = resolved.cue;
postNative("playout-command", payload);
pending.timeoutId = setTimeout(() => {
if (state.playout.pending?.requestId !== requestId) return;
state.playout.pending = null;
showPlayoutError({
code: "WEB_TIMEOUT",
message: "송출 엔진 응답 시간이 초과되었습니다.",
retryable: true,
outcomeUnknown: true
});
addLog(`${command.toUpperCase()} 응답 시간 초과 · 결과 미확인`);
requestPlayoutStatus();
}, 15000);
}
function requestPlayoutStatus() {
if (!nativeBridge) return;
const requestId = createId();
state.playout.latestStatusRequestId = requestId;
postNative("request-playout-status", { requestId });
}
function parseChangedAt(value) {
const parsed = typeof value === "string" || typeof value === "number" ? Date.parse(value) : NaN;
return Number.isFinite(parsed) ? parsed : 0;
}
function handlePlayoutStatus(payload) {
if (!payload || typeof payload !== "object") return;
if (payload.requestId && payload.requestId !== state.playout.latestStatusRequestId) return;
const changedAtMs = parseChangedAt(payload.changedAt);
if (changedAtMs && state.playout.changedAtMs && changedAtMs < state.playout.changedAtMs) return;
state.playout.mode = normalizePlayoutMode(payload.mode);
state.playout.engineState = normalizePlayoutState(payload.state);
state.playout.processDetected = payload.processDetected === true;
state.playout.connected = payload.connected === true;
state.playout.liveTakeInAllowed = payload.liveTakeInAllowed === true;
state.playout.message = typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: "Tornado 송출 상태를 수신했습니다.";
state.playout.preparedCode = payload.preparedCode ? String(payload.preparedCode) : null;
state.playout.onAirCode = payload.onAirCode ? String(payload.onAirCode) : null;
state.playout.preparedCue = findCueByCode(state.playout.preparedCode);
state.playout.changedAt = payload.changedAt || null;
state.playout.changedAtMs = changedAtMs || state.playout.changedAtMs;
if (payload.requestId === state.playout.latestStatusRequestId) state.playout.latestStatusRequestId = null;
renderPlayout();
const signature = [state.playout.mode, state.playout.engineState, state.playout.processDetected, state.playout.connected, state.playout.liveTakeInAllowed].join("|");
if (signature !== state.playout.lastSignature) {
addLog(`Tornado 상태 · ${playoutDisplayState()} · ${state.playout.connected ? "연결" : "미연결"}`);
state.playout.lastSignature = signature;
}
}
function finishPendingCommand(payload) {
const pending = state.playout.pending;
if (!pending || payload?.requestId !== pending.requestId || payload?.command !== pending.command) return null;
clearTimeout(pending.timeoutId);
state.playout.pending = null;
return pending;
}
function handlePlayoutCommandResult(payload) {
const pending = finishPendingCommand(payload);
if (!pending) return;
if (payload.succeeded !== true) {
showPlayoutError({ message: payload.message || "송출 명령이 거부되었습니다.", retryable: true });
addLog(`${pending.command.toUpperCase()} 실패 · ${payload.message || "명령 거부"}`);
requestPlayoutStatus();
return;
}
clearPlayoutError(false);
if (payload.dryRun === true) state.playout.mode = "dry-run";
state.playout.engineState = normalizePlayoutState(payload.state);
state.playout.message = typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: `${pending.command.toUpperCase()} 완료`;
state.playout.preparedCode = payload.preparedCode ? String(payload.preparedCode) : null;
state.playout.onAirCode = payload.onAirCode ? String(payload.onAirCode) : null;
if (pending.command === "prepare" || pending.command === "next") {
state.playout.preparedCue = pending.cue;
} else if (!state.playout.preparedCode) {
state.playout.preparedCue = null;
}
if (pending.command === "next") {
state.currentIndex = pending.targetIndex;
renderPlaylist();
updatePreview();
}
renderPlayout();
const safetyLabel = payload.dryRun === true ? "DRY RUN · " : "";
addLog(`${pending.cue?.code || "ENGINE"} ${safetyLabel}${pending.command.toUpperCase()} 완료`);
showToast(state.playout.message);
requestPlayoutStatus();
}
function handlePlayoutCommandError(payload) {
const pending = finishPendingCommand(payload);
if (!pending) return;
showPlayoutError({
code: payload.code,
message: payload.message,
retryable: payload.retryable === true,
outcomeUnknown: payload.outcomeUnknown === true
});
addLog(`${pending.command.toUpperCase()} 오류 · ${payload.code || "PLAYOUT_ERROR"} · ${payload.message || "명령 실패"}`);
requestPlayoutStatus();
}
function addLog(message) {
@@ -715,6 +1026,15 @@
case "market-data-error":
handleMarketDataError(message.payload);
break;
case "playout-status":
handlePlayoutStatus(message.payload);
break;
case "playout-command-result":
handlePlayoutCommandResult(message.payload);
break;
case "playout-command-error":
handlePlayoutCommandError(message.payload);
break;
}
}
@@ -745,14 +1065,16 @@
document.querySelector("#deleteButton").addEventListener("click", deleteSelected);
document.querySelector("#saveButton").addEventListener("click", savePlaylist);
document.querySelector("#loadButton").addEventListener("click", loadPlaylist);
document.querySelector("#prepareButton").addEventListener("click", prepare);
document.querySelector("#takeInButton").addEventListener("click", takeIn);
document.querySelector("#nextButton").addEventListener("click", next);
document.querySelector("#takeOutButton").addEventListener("click", takeOut);
elements.prepareButton.addEventListener("click", () => requestPlayoutCommand("prepare"));
elements.takeInButton.addEventListener("click", () => requestPlayoutCommand("take-in"));
elements.nextButton.addEventListener("click", () => requestPlayoutCommand("next"));
elements.takeOutButton.addEventListener("click", () => requestPlayoutCommand("take-out"));
document.querySelector("#clearPlayoutErrorButton").addEventListener("click", () => clearPlayoutError());
document.querySelector("#clearLogButton").addEventListener("click", () => elements.eventLog.replaceChildren());
document.querySelector("#requestInfoButton").addEventListener("click", () => {
postNative("request-app-info");
requestDatabaseStatus();
requestPlayoutStatus();
if (liveDataViews.has(state.activeMarket)) requestMarketData(state.activeMarket);
});
document.querySelector("#retryLiveDataButton").addEventListener("click", () => {
@@ -764,9 +1086,9 @@
if (event.ctrlKey && event.key.toLowerCase() === "k") {
event.preventDefault(); elements.catalogSearch.focus(); return;
}
if (event.key === "F2") { event.preventDefault(); prepare(); }
if (event.key === "F8") { event.preventDefault(); takeIn(); }
if (event.key === "Escape") { event.preventDefault(); takeOut(); }
if (event.key === "F2") { event.preventDefault(); requestPlayoutCommand("prepare"); }
if (event.key === "F8") { event.preventDefault(); requestPlayoutCommand("take-in"); }
if (event.key === "Escape") { event.preventDefault(); requestPlayoutCommand("take-out"); }
});
}
@@ -776,6 +1098,7 @@
renderPlaylist();
renderDatabaseStatus();
renderLiveData();
renderPlayout();
updateClock();
setInterval(updateClock, 1000);
addLog("WebView 운영 화면 시작");
@@ -785,11 +1108,16 @@
postNative("ready");
postNative("request-app-info");
requestDatabaseStatus();
requestPlayoutStatus();
} else {
elements.bridgeState.textContent = "브라우저 미리보기";
state.database.loading = false;
state.playout.mode = "preview-dry-run";
state.playout.engineState = "IDLE";
state.playout.message = "Native Bridge 없음 · 미리보기 전용 DRY RUN (PROGRAM 출력 차단)";
renderPlayout();
renderDatabaseStatus();
addLog("Native Bridge 없이 브라우저 모드로 실행");
addLog("Native Bridge 없이 미리보기 전용 DRY RUN으로 실행");
}
}

View File

@@ -55,7 +55,7 @@
<article><span class="metric-icon mint"></span><div><strong>WebView UI</strong><small>FarPoint 대체 기반</small></div><b>READY</b></article>
<article><span class="metric-icon blue">71</span><div><strong>데이터 요청</strong><small>기존 SQL 계층 이관</small></div><b>MIGRATED</b></article>
<article id="databaseSummary"><span id="databaseSummaryIcon" class="metric-icon amber">DB</span><div><strong>Oracle · MariaDB</strong><small id="databaseSummaryDetail">연결 상태 확인 중</small></div><b id="databaseSummaryBadge">CHECKING</b></article>
<article><span class="metric-icon violet">T2</span><div><strong>Tornado Engine</strong><small>x64 COM 검증 대기</small></div><b>PENDING</b></article>
<article id="playoutSummary" class="playout-summary"><span id="playoutSummaryIcon" class="metric-icon violet">T2</span><div><strong>Tornado Engine</strong><small id="playoutSummaryDetail">상태 확인 중</small></div><b id="playoutSummaryBadge">CHECKING</b></article>
</section>
<div class="console-grid">
@@ -121,6 +121,27 @@
<div><p class="panel-kicker">PROGRAM / PREVIEW</p><h2>송출 제어</h2></div>
<span id="playoutState" class="badge neutral">IDLE</span>
</div>
<div class="playout-health" aria-label="Tornado 송출 연결 상태">
<div class="playout-health-item">
<span id="tornadoProcessDot" class="status-dot pending"></span>
<small>Tornado2 Process</small>
<strong id="tornadoProcessState">확인 중</strong>
</div>
<div class="playout-health-item">
<span id="playoutConnectionDot" class="status-dot pending"></span>
<small>Engine</small>
<strong id="playoutConnectionState">확인 중</strong>
</div>
<div class="playout-health-item safety">
<small>Safety Mode</small>
<strong id="playoutSafetyMode">확인 중</strong>
</div>
</div>
<div id="playoutStatusMessage" class="playout-status-message" role="status" aria-live="polite">송출 어댑터 상태를 확인하고 있습니다.</div>
<div id="playoutError" class="playout-error" role="alert" hidden>
<div><strong>송출 명령 오류</strong><span id="playoutErrorMessage"></span></div>
<button id="clearPlayoutErrorButton" type="button" aria-label="송출 오류 닫기">닫기</button>
</div>
<div class="preview-stage" id="previewStage">
<div class="safe-area">
<span>PREVIEW</span>

View File

@@ -133,6 +133,12 @@ h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.035em; }
.database-summary.healthy > b { color: var(--mint); }
.database-summary.partial > b { color: var(--amber); }
.database-summary.error > b { color: var(--red); }
.playout-summary.ready { border-color: rgba(50,213,164,.22); }
.playout-summary.dry-run { border-color: rgba(255,186,85,.22); }
.playout-summary.error { border-color: rgba(255,102,128,.24); }
.playout-summary.ready > b { color: var(--mint); }
.playout-summary.dry-run > b { color: var(--amber); }
.playout-summary.error > b { color: var(--red); }
.console-grid { display: grid; grid-template-columns: minmax(250px, .73fr) minmax(410px, 1.2fr) minmax(330px, 1fr); gap: 12px; min-height: 680px; height: calc(100vh - 188px); }
@@ -142,6 +148,7 @@ h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.035em; }
.badge { padding: 4px 7px; border: 1px solid rgba(50,213,164,.22); border-radius: 10px; background: var(--mint-soft); color: var(--mint); font: 8px Consolas, monospace; letter-spacing: .05em; }
.badge.neutral { border-color: var(--border); background: var(--surface-2); color: var(--muted); }
.badge.live { border-color: rgba(50,213,164,.36); background: rgba(50,213,164,.16); box-shadow: 0 0 0 3px rgba(50,213,164,.04); }
.badge.dry-run { border-color: rgba(255,186,85,.32); background: rgba(255,186,85,.11); color: var(--amber); }
.search-box { display: grid; grid-template-columns: 20px 1fr auto; align-items: center; gap: 6px; height: 40px; margin: 13px 13px 10px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: #091522; }
.search-box > span { color: var(--muted); font-size: 17px; }
@@ -239,6 +246,23 @@ input[type="checkbox"] { accent-color: var(--mint); }
.empty-state span { color: var(--muted-2); font-size: 9px; }
.playout-panel { padding-bottom: 12px; }
.playout-health { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px; margin: 10px 13px 0; }
.playout-health-item { display: grid; grid-template-columns: 7px minmax(0, 1fr); align-items: center; min-width: 0; min-height: 38px; column-gap: 6px; padding: 6px 8px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(8,19,31,.72); }
.playout-health-item small { overflow: hidden; color: var(--muted-2); font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.playout-health-item strong { grid-column: 2; overflow: hidden; color: #b9c7d8; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.playout-health-item.safety { grid-template-columns: 1fr; }
.playout-health-item.safety strong { grid-column: 1; color: var(--amber); }
.playout-health-item.safety.live-allowed strong { color: var(--mint); }
.playout-health-item.safety.live-locked strong { color: var(--red); }
.playout-status-message { min-height: 29px; margin: 7px 13px 0; padding: 7px 9px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(8,19,31,.52); color: #8296ad; font-size: 8px; line-height: 1.45; }
.playout-status-message.pending { border-color: rgba(86,168,255,.23); color: #8ec4ff; }
.playout-status-message.error { border-color: rgba(255,102,128,.22); color: #ff9bad; }
.playout-error { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 7px 13px 0; padding: 8px 9px; border: 1px solid rgba(255,102,128,.28); border-radius: 7px; background: rgba(255,102,128,.07); }
.playout-error[hidden] { display: none; }
.playout-error strong, .playout-error span { display: block; }
.playout-error strong { margin-bottom: 2px; color: #ff9bad; font-size: 8px; }
.playout-error span { color: #c98995; font-size: 8px; line-height: 1.4; }
.playout-error button { min-height: 24px; flex: 0 0 auto; padding: 0 7px; border: 1px solid rgba(255,102,128,.25); border-radius: 5px; background: transparent; color: #e38c9c; font-size: 8px; cursor: pointer; }
.preview-stage { position: relative; display: grid; min-height: 255px; margin: 13px; place-items: center; overflow: hidden; border: 1px solid #29405a; border-radius: 9px; background: radial-gradient(circle at 50% 45%, #193552, #08121f 60%); }
.preview-stage::after { position: absolute; inset: 0; background-image: linear-gradient(rgba(65,101,133,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(65,101,133,.08) 1px, transparent 1px); background-size: 24px 24px; content: ""; }
.safe-area { position: relative; z-index: 1; width: 78%; padding: 31px 20px; border: 1px dashed rgba(137,167,195,.24); color: var(--muted); text-align: center; }
@@ -253,6 +277,8 @@ input[type="checkbox"] { accent-color: var(--mint); }
.playout-controls button { display: flex; min-width: 0; height: 42px; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2); color: #b5c4d5; font-size: 9px; font-weight: 700; cursor: pointer; }
.playout-controls button span { color: var(--muted-2); font: 8px Consolas, monospace; }
.playout-controls button:hover { filter: brightness(1.15); }
.playout-controls button:disabled { filter: none; cursor: not-allowed; opacity: .4; }
.playout-controls button[aria-busy="true"] { border-color: rgba(86,168,255,.36); box-shadow: inset 0 0 0 1px rgba(86,168,255,.08); opacity: .72; }
.playout-controls .prepare { border-color: rgba(86,168,255,.34); background: rgba(86,168,255,.1); color: #8ec4ff; }
.playout-controls .take-in { border-color: rgba(50,213,164,.38); background: var(--mint-soft); color: var(--mint); }
.playout-controls .take-out { border-color: rgba(255,102,128,.28); background: rgba(255,102,128,.08); color: #ff8fa2; }