UNPKG

motiontext-renderer

Version:

Web-based animated caption/subtitle renderer with plugin system

1,579 lines (1,578 loc) 137 kB
class DefineResolver { constructor(defines = {}) { this.visited = /* @__PURE__ */ new Set(); this.resolutionPath = []; this.defines = defines; } /** * 시나리오 전체를 해석하여 Define 참조를 실제 값으로 치환 * @param scenario v2.0 시나리오 객체 * @returns Define 참조가 해석된 시나리오 객체 */ resolveScenario(scenario) { this.visited.clear(); this.resolutionPath.length = 0; const combinedDefines = { ...this.defines, ...scenario.define }; const tempResolver = new DefineResolver(combinedDefines); const resolved = tempResolver.resolveObject(scenario); return resolved; } /** * 객체를 순회하며 Define 참조를 해석 * @param obj 해석할 객체 * @param keyPath 현재 키 경로 (에러 메시지용) * @returns Define 참조가 해석된 객체 */ resolveObject(obj, keyPath = "") { if (obj === null || obj === void 0) { return obj; } if (typeof obj === "string") { return this.resolveDefineReference(obj, keyPath); } if (Array.isArray(obj)) { return obj.map( (item, index) => this.resolveObject(item, `${keyPath}[${index}]`) ); } if (typeof obj === "object") { const resolved = {}; for (const [key, value] of Object.entries(obj)) { const newKeyPath = keyPath ? `${keyPath}.${key}` : key; resolved[key] = this.resolveObject(value, newKeyPath); } return resolved; } return obj; } /** * Define 참조 문자열을 실제 값으로 해석 (중첩 경로 지원) * @param value 해석할 문자열 값 * @param keyPath 현재 키 경로 (에러 메시지용) * @returns 해석된 값 또는 원본 문자열 */ resolveDefineReference(value, keyPath) { if (!value.startsWith("define.")) { return value; } const fullPath = value.substring(7); const pathParts = fullPath.split("."); if (pathParts.length === 0 || !pathParts[0]) { throw new Error(`Invalid define reference: "${value}" at ${keyPath}`); } const rootKey = pathParts[0]; if (!(rootKey in this.defines)) { throw new Error( `Undefined define key: "${rootKey}" referenced at ${keyPath}` ); } const fullRefPath = `define.${fullPath}`; if (this.resolutionPath.includes(fullRefPath)) { const cyclePath = [...this.resolutionPath, fullRefPath].join(" -> "); throw new Error(`Circular reference detected in define: ${cyclePath}`); } this.visited.add(rootKey); this.resolutionPath.push(fullRefPath); try { let resolved = this.resolveObject( this.defines[rootKey], `define.${rootKey}` ); for (let i = 1; i < pathParts.length; i++) { const part = pathParts[i]; if (resolved === null || resolved === void 0) { throw new Error( `Cannot access "${part}" on ${typeof resolved} at ${keyPath}` ); } if (typeof resolved !== "object") { throw new Error( `Cannot access property "${part}" on non-object at ${keyPath}` ); } if (!(part in resolved)) { throw new Error( `Property "${part}" not found in define at ${keyPath}` ); } resolved = resolved[part]; } return resolved; } finally { this.visited.delete(rootKey); this.resolutionPath.pop(); } } /** * Define 섹션에서 사용 가능한 키 목록 반환 * @returns 정의된 키 배열 */ getAvailableKeys() { return Object.keys(this.defines); } /** * 특정 키의 타입 확인 * @param key 확인할 키 * @returns 값의 타입 */ getKeyType(key) { if (!(key in this.defines)) { return null; } const value = this.defines[key]; if (Array.isArray(value)) { return "array"; } return typeof value; } } function isWithinTimeRange(t, timeRange) { if (!Array.isArray(timeRange) || timeRange.length !== 2) { return false; } const [start, end] = timeRange; if (!Number.isFinite(t) || !Number.isFinite(start) || !Number.isFinite(end)) { return false; } return t >= start && t <= end; } function progressInTimeRange(currentTime, timeRange) { if (!Array.isArray(timeRange) || timeRange.length !== 2) { return 0; } const [start, end] = timeRange; if (!Number.isFinite(currentTime) || !Number.isFinite(start) || !Number.isFinite(end)) { return 0; } const duration = end - start; if (duration <= 0) { return 0; } const progress = (currentTime - start) / duration; return Math.max(0, Math.min(1, progress)); } function resolveOffsetToAbsolute(bound, baseStart, baseEnd, baseDuration, which) { if (typeof bound === "string") { const s = bound.trim(); if (s.endsWith("%")) { const n2 = parseFloat(s.slice(0, -1)); const pct = Number.isFinite(n2) ? n2 / 100 : 0; return which === "start" ? baseStart + baseDuration * pct : baseEnd + baseDuration * pct; } const n = parseFloat(s); if (Number.isFinite(n)) { return (which === "start" ? baseStart : baseEnd) + n; } return which === "start" ? baseStart : baseEnd; } if (typeof bound === "number") { return (which === "start" ? baseStart : baseEnd) + bound; } return which === "start" ? baseStart : baseEnd; } function computePluginWindowFromBase(baseTime, timeOffset = ["0%", "100%"]) { if (!Array.isArray(baseTime) || baseTime.length !== 2) { throw new TypeError("baseTime must be [start, end] array"); } const [bStart, bEnd] = baseTime; if (!Number.isFinite(bStart) || !Number.isFinite(bEnd)) { throw new TypeError("baseTime values must be finite numbers"); } const bDur = bEnd - bStart; const [o0, o1] = Array.isArray(timeOffset) ? timeOffset : ["0%", "100%"]; const abs0 = resolveOffsetToAbsolute(o0, bStart, bEnd, bDur, "start"); const abs1 = resolveOffsetToAbsolute(o1, bStart, bEnd, bDur, "end"); return [abs0, abs1]; } function validateTimeRange(timeRange) { if (!Array.isArray(timeRange)) { throw new TypeError("timeRange must be an array"); } if (timeRange.length !== 2) { throw new TypeError("timeRange must have exactly 2 elements [start, end]"); } const [start, end] = timeRange; if (!Number.isFinite(start)) { throw new TypeError("timeRange start must be a finite number"); } if (!Number.isFinite(end)) { throw new TypeError("timeRange end must be a finite number"); } if (start > end) { throw new RangeError( `timeRange start (${start}) must not be greater than end (${end})` ); } } class ValidationError extends Error { constructor(message, path) { super(path ? `[${path}] ${message}` : message); this.path = path; this.name = "ValidationError"; } } function validateScenario(scenario) { if (scenario.version !== "2.0") { throw new ValidationError( `Invalid version: expected "2.0", got "${scenario.version}"` ); } validateRequiredFields(scenario); validateTimeArrays(scenario); validateNodeIds(scenario); validateTrackReferences(scenario); validateTimeLogic(scenario); } function validateRequiredFields(scenario) { if (!scenario.timebase) { throw new ValidationError("Missing required field: timebase"); } if (!scenario.stage) { throw new ValidationError("Missing required field: stage"); } if (!Array.isArray(scenario.tracks)) { throw new ValidationError("tracks must be an array"); } if (!Array.isArray(scenario.cues)) { throw new ValidationError("cues must be an array"); } if (scenario.timebase.unit !== "seconds") { throw new ValidationError( `timebase.unit must be "seconds", got "${scenario.timebase.unit}"` ); } if (scenario.timebase.fps !== void 0) { if (typeof scenario.timebase.fps !== "number" || scenario.timebase.fps <= 0) { throw new ValidationError("timebase.fps must be a positive number"); } } const validAspects = ["16:9", "9:16", "auto"]; if (!validAspects.includes(scenario.stage.baseAspect)) { throw new ValidationError( `stage.baseAspect must be one of: ${validAspects.join(", ")}` ); } } function validateTimeArrays(scenario) { for (let i = 0; i < scenario.cues.length; i++) { const cue = scenario.cues[i]; const path = `cues[${i}]`; if (cue.domLifetime) { try { validateTimeRange(cue.domLifetime); } catch (error) { throw new ValidationError( `${path}.domLifetime: ${error instanceof Error ? error.message : "Invalid time range"}` ); } } validateNodeTimeArrays(cue.root, `${path}.root`); } } function validateNodeTimeArrays(node, path) { if (node.displayTime) { try { validateTimeRange(node.displayTime); } catch (error) { throw new ValidationError( `${path}.displayTime: ${error instanceof Error ? error.message : "Invalid time range"}` ); } } if (node.pluginChain) { for (let i = 0; i < node.pluginChain.length; i++) { const plugin = node.pluginChain[i]; const pluginPath = `${path}.pluginChain[${i}]`; if (typeof plugin !== "string" && plugin.time_offset) { const off = plugin.time_offset; if (!Array.isArray(off) || off.length !== 2) { throw new ValidationError( `${pluginPath}.time_offset must be [start, end] array` ); } for (let k = 0; k < 2; k++) { const v = off[k]; const ok = typeof v === "number" && Number.isFinite(v) || typeof v === "string" && /^\s*-?\d+(?:\.\d+)?%?\s*$/.test(v); if (!ok) { throw new ValidationError( `${pluginPath}.time_offset[${k}] must be a number (seconds) or percentage string like '50%'` ); } } } } } if (node.eType === "group" && node.children) { for (let i = 0; i < node.children.length; i++) { validateNodeTimeArrays(node.children[i], `${path}.children[${i}]`); } } } function validateNodeIds(scenario) { const allNodeIds = /* @__PURE__ */ new Set(); const nodeIdPaths = /* @__PURE__ */ new Map(); for (let i = 0; i < scenario.cues.length; i++) { const cue = scenario.cues[i]; const path = `cues[${i}]`; if (!cue.id || typeof cue.id !== "string") { throw new ValidationError( `${path}.id: Cue ID is required and must be a non-empty string` ); } if (allNodeIds.has(cue.id)) { throw new ValidationError( `Duplicate cue ID: "${cue.id}" (conflicts with ${nodeIdPaths.get(cue.id)})` ); } allNodeIds.add(cue.id); nodeIdPaths.set(cue.id, path); validateNodeIdRecursive(cue.root, `${path}.root`, allNodeIds, nodeIdPaths); } } function validateNodeIdRecursive(node, path, allIds, idPaths) { if (!node.id || typeof node.id !== "string") { throw new ValidationError( `${path}.id: Node ID is required and must be a non-empty string` ); } if (allIds.has(node.id)) { throw new ValidationError( `Duplicate node ID: "${node.id}" (conflicts with ${idPaths.get(node.id)})` ); } allIds.add(node.id); idPaths.set(node.id, path); if (node.eType === "group" && node.children) { for (let i = 0; i < node.children.length; i++) { validateNodeIdRecursive( node.children[i], `${path}.children[${i}]`, allIds, idPaths ); } } } function validateTrackReferences(scenario) { const trackIds = new Set(scenario.tracks.map((track) => track.id)); const trackIdCounts = /* @__PURE__ */ new Map(); for (const track of scenario.tracks) { trackIdCounts.set(track.id, (trackIdCounts.get(track.id) || 0) + 1); } for (const [id, count] of trackIdCounts) { if (count > 1) { throw new ValidationError(`Duplicate track ID: "${id}"`); } } for (let i = 0; i < scenario.cues.length; i++) { const cue = scenario.cues[i]; const path = `cues[${i}]`; if (!cue.track || typeof cue.track !== "string") { throw new ValidationError( `${path}.track: Track reference is required and must be a string` ); } if (!trackIds.has(cue.track)) { throw new ValidationError( `${path}.track: Unknown track ID "${cue.track}"` ); } } } function validateTimeLogic(scenario) { for (let i = 0; i < scenario.cues.length; i++) { const cue = scenario.cues[i]; const path = `cues[${i}]`; if (cue.domLifetime) { const [domStart, domEnd] = cue.domLifetime; validateDomLifetimeCoversNodes( cue.root, domStart, domEnd, `${path}.root` ); } } } function validateDomLifetimeCoversNodes(node, domStart, domEnd, path) { if (node.displayTime) { const [nodeStart, nodeEnd] = node.displayTime; if (nodeStart < domStart) { console.warn( `Warning: ${path}.displayTime starts (${nodeStart}) before domLifetime (${domStart}). This may cause late DOM mounting.` ); } if (nodeEnd > domEnd) { console.warn( `Warning: ${path}.displayTime ends (${nodeEnd}) after domLifetime (${domEnd}). This may cause early DOM unmounting.` ); } } if (node.eType === "group" && node.children) { for (let i = 0; i < node.children.length; i++) { validateDomLifetimeCoversNodes( node.children[i], domStart, domEnd, `${path}.children[${i}]` ); } } } const INHERITANCE_RULES = { displayTime: { priority: ["direct", "parent", "system"], systemDefault: [-Infinity, Infinity] }, // layout 제거 - 각 요소의 고유한 위치/크기이므로 상속하지 않음 // 선택적 상속 가능한 layout 속성들 anchor: { priority: ["direct", "parent", "track"], // anchor만 선택적 상속 systemDefault: void 0 }, safeAreaClamp: { priority: ["direct", "parent"], systemDefault: void 0 }, style: { priority: ["direct", "parent", "track"], merge: true, // 텍스트 스타일은 병합 systemDefault: void 0 }, boxStyle: { priority: ["direct", "track"], // 부모에서 상속받지 않음, 그룹 노드만 트랙에서 상속 merge: true, // 박스 스타일은 병합 systemDefault: void 0 }, pluginChain: { priority: ["direct"], // 각 노드별 고유 효과 systemDefault: void 0 }, effectScope: { priority: ["direct", "parent"], systemDefault: void 0 } }; const DEFAULT_INHERITANCE_RULE = { priority: ["direct", "parent", "track", "system"], systemDefault: void 0 }; function applyInheritance(scenario) { const trackDefaults = /* @__PURE__ */ new Map(); for (const track of scenario.tracks) { trackDefaults.set(track.id, track); } const debugMode = globalThis.__MTX_DEBUG_MODE__ || false; const inheritedCues = scenario.cues.map((cue, index) => { const track = trackDefaults.get(cue.track); if (debugMode) { console.log(`[Inheritance] Processing cue ${index}: ${cue.id}`, { displayTime: cue.displayTime, domLifetime: cue.domLifetime, track: track == null ? void 0 : track.id }); } const inheritedCue = { ...cue, // domLifetime 상속 (Cue 레벨에서는 부모가 없으므로 트랙 기본값만) domLifetime: cue.domLifetime || getDefaultDomLifetime(cue.root), // 루트 노드에 상속 적용 (Cue의 displayTime을 컨텍스트로 전달) root: applyNodeInheritance( cue.root, null, track, cue.displayTime ) }; if (debugMode) { console.log(`[Inheritance] Cue ${cue.id} inheritance completed`, { originalDisplayTime: cue.displayTime, inheritedRoot: inheritedCue.root }); } return inheritedCue; }); return { ...scenario, cues: inheritedCues }; } function applyNodeInheritance(node, parent, track, cueDisplayTime) { var _a; const debugMode = globalThis.__MTX_DEBUG_MODE__ || false; const inheritedNode = { ...node }; if (debugMode) { console.log(`[Inheritance] Processing node: ${node.id || "unknown"}`, { nodeType: node.eType, hasDisplayTime: !!node.displayTime, parentDisplayTime: parent == null ? void 0 : parent.displayTime, cueDisplayTime, fields: Object.keys(node) }); } for (const fieldName of Object.keys(INHERITANCE_RULES)) { const originalValue = inheritedNode[fieldName]; inheritedNode[fieldName] = inheritField( fieldName, node, parent, track, cueDisplayTime ); if (debugMode && fieldName === "displayTime") { console.log(`[Inheritance] ${fieldName} inheritance:`, { nodeId: node.id || "unknown", original: originalValue, inherited: inheritedNode[fieldName], parentValue: parent == null ? void 0 : parent[fieldName], cueValue: cueDisplayTime }); } } if (node.eType === "group") { const groupNode = inheritedNode; const legacyStyle = groupNode.style; if (legacyStyle && !groupNode.boxStyle) { const { backgroundColor, boxBg, border, padding, borderRadius, opacity, ...textStyle } = legacyStyle; if (backgroundColor || boxBg || border || padding || borderRadius || opacity !== void 0) { groupNode.style = textStyle; groupNode.boxStyle = { backgroundColor, boxBg, border, padding, borderRadius, opacity }; } } } if (node.eType === "group") { const groupNode = inheritedNode; return { ...groupNode, children: (_a = groupNode.children) == null ? void 0 : _a.map( (child) => applyNodeInheritance( child, inheritedNode, track, cueDisplayTime ) ) }; } return inheritedNode; } function inheritField(fieldName, node, parent, track, cueDisplayTime) { var _a; const rule = INHERITANCE_RULES[fieldName] || DEFAULT_INHERITANCE_RULE; if (fieldName === "pluginChain") { return node[fieldName] || rule.systemDefault; } const values = []; for (const priority of rule.priority) { let value = void 0; switch (priority) { case "direct": value = node[fieldName]; break; case "parent": value = parent ? parent[fieldName] : void 0; break; case "track": if (fieldName === "style") { value = track == null ? void 0 : track.defaultStyle; } else if (fieldName === "boxStyle") { if (node.eType === "group") { value = track == null ? void 0 : track.defaultBoxStyle; } } else if (fieldName === "anchor" && ((_a = track == null ? void 0 : track.defaultConstraints) == null ? void 0 : _a.anchor)) { value = track.defaultConstraints.anchor; } break; case "system": if (fieldName === "displayTime" && cueDisplayTime) { value = cueDisplayTime; } else { value = rule.systemDefault; } break; } if (value !== void 0) { values.push(value); } } if (values.length === 0) { return rule.systemDefault; } if (rule.merge) { if (fieldName === "style") { return mergeStyles(...values.filter((v) => v !== void 0)); } else if (fieldName === "boxStyle") { return mergeBoxStyles(...values.filter((v) => v !== void 0)); } } return values[0]; } function mergeStyles(...styles) { const merged = {}; for (let i = styles.length - 1; i >= 0; i--) { const style = styles[i]; if (style && typeof style === "object") { Object.assign(merged, style); } } return merged; } function mergeBoxStyles(...styles) { const merged = {}; for (let i = styles.length - 1; i >= 0; i--) { const style = styles[i]; if (style && typeof style === "object") { Object.assign(merged, style); } } return merged; } function getDefaultDomLifetime(rootNode) { const allDisplayTimes = []; collectDisplayTimes(rootNode, allDisplayTimes); if (allDisplayTimes.length === 0) { return [-1, Infinity]; } const starts = allDisplayTimes.map(([start]) => start).filter((t) => Number.isFinite(t)); const ends = allDisplayTimes.map(([, end]) => end).filter((t) => Number.isFinite(t)); const minStart = starts.length > 0 ? Math.min(...starts) : 0; const maxEnd = ends.length > 0 ? Math.max(...ends) : 10; return [minStart - 0.5, maxEnd + 0.5]; } function collectDisplayTimes(node, collected) { if (node.displayTime) { const timeRange = node.displayTime; if (Number.isFinite(timeRange[0]) && Number.isFinite(timeRange[1])) { collected.push(timeRange); } } if (node.eType === "group" && node.children) { for (const child of node.children) { collectDisplayTimes(child, collected); } } } function parseScenario(input) { if (!input || typeof input !== "object") { throw new Error("Scenario must be a valid JSON object"); } const raw = input; if (raw.version !== "2.0") { const version = raw.version || "unknown"; throw new Error( `Only v2.0 scenarios are supported, got version "${version}". Use migration tools to convert v1.3 scenarios to v2.0.` ); } const resolver = new DefineResolver(raw.define); const resolvedRaw = resolver.resolveScenario(raw); const resolved = resolvedRaw; validateScenario(resolved); const inherited = applyInheritance(resolved); return inherited; } class DevPluginRegistry { constructor() { this.map = /* @__PURE__ */ new Map(); } register(p) { this.map.set(p.name, p); this.map.set(`${p.name}@${p.version}`, p); } resolve(name) { return this.map.get(name); } has(name) { return this.map.has(name); } entries() { return Array.from(new Set(this.map.values())); } } const devRegistry = new DevPluginRegistry(); function applyTextStyle(el, containerHeight, style, trackDefault) { const s = { ...trackDefault || {}, ...style || {} }; if (s.color) el.style.color = String(s.color); if (s.textShadow) el.style.textShadow = String(s.textShadow); if (s.fontFamily) el.style.fontFamily = String(s.fontFamily); if (s.fontWeight) el.style.fontWeight = String(s.fontWeight); if (s.lineHeight != null) el.style.lineHeight = String(s.lineHeight); if (s.fontSize) el.style.fontSize = String(s.fontSize); else if (typeof s.fontSizeRel === "number") { const px = Math.max(1, Math.round(containerHeight * s.fontSizeRel)); el.style.fontSize = `${px}px`; } if (s.stroke && typeof s.stroke.widthRel === "number") { const px = Math.max(1, Math.round(containerHeight * s.stroke.widthRel)); el.style.webkitTextStrokeWidth = `${px}px`; el.style.webkitTextStrokeColor = String(s.stroke.color || "#000"); el.style.webkitTextFillColor = el.style.color || "#fff"; } else if (!s.textShadow) { el.style.textShadow = "0 0 2px #000, 0 0 4px #000, 0 0 8px #000"; } if (s.align) el.style.textAlign = s.align; if (s.whiteSpace) el.style.whiteSpace = s.whiteSpace === "wrap" ? "normal" : s.whiteSpace; } function applyGroupStyle(el, containerHeight, boxStyle, layout) { if (!boxStyle) return; if (boxStyle.backgroundColor) { el.style.backgroundColor = String(boxStyle.backgroundColor); } else if (boxStyle.boxBg) { el.style.backgroundColor = String(boxStyle.boxBg); } if (boxStyle.border) { const wpx = Math.max( 0, Math.round(containerHeight * (boxStyle.border.widthRel || 0)) ); el.style.borderStyle = "solid"; el.style.borderWidth = `${wpx}px`; el.style.borderColor = String(boxStyle.border.color || "#000"); if (boxStyle.border.radiusRel != null) { const rpx = Math.max( 0, Math.round(containerHeight * boxStyle.border.radiusRel) ); el.style.borderRadius = `${rpx}px`; } } if (boxStyle.borderRadius) { if (typeof boxStyle.borderRadius === "string") { el.style.borderRadius = boxStyle.borderRadius; } else { const rpx = Math.max( 0, Math.round(containerHeight * boxStyle.borderRadius) ); el.style.borderRadius = `${rpx}px`; } } if (boxStyle.padding) { if (typeof boxStyle.padding === "string") { el.style.padding = boxStyle.padding; } else { const px = Math.max( 0, Math.round(containerHeight * (boxStyle.padding.x || 0)) ); const py = Math.max( 0, Math.round(containerHeight * (boxStyle.padding.y || 0)) ); el.style.padding = `${py}px ${px}px`; } } else if (layout == null ? void 0 : layout.padding) { const px = Math.max( 0, Math.round(containerHeight * (layout.padding.x || 0)) ); const py = Math.max( 0, Math.round(containerHeight * (layout.padding.y || 0)) ); el.style.padding = `${py}px ${px}px`; } if (boxStyle.opacity != null) { el.style.opacity = String(boxStyle.opacity); } } class DefaultScenarioInfo { constructor(scenario) { this.scenario = scenario; } get version() { return this.scenario.version; } } class DefaultAssetManager { constructor(baseUrl) { this.baseUrl = baseUrl; } getUrl(path) { return new URL(path, this.baseUrl).toString(); } async loadFont(fontSpec) { const fontFace = new FontFace( fontSpec.family, `url("${this.getUrl(fontSpec.src)}")`, { weight: fontSpec.weight || "normal", style: fontSpec.style || "normal" } ); await fontFace.load(); document.fonts.add(fontFace); } async preloadImage(url) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = this.getUrl(url); }); } async preloadAudio(url) { return new Promise((resolve, reject) => { const audio = new Audio(); audio.oncanplaythrough = () => resolve(audio); audio.onerror = reject; audio.src = this.getUrl(url); audio.load(); }); } } const defaultUtils = { interpolate(from, to, progress, easing = "linear") { const easingFn = this.easing[easing] || this.easing.linear; const t = easingFn(Math.max(0, Math.min(1, progress))); if (typeof from === "number" && typeof to === "number") { return from + (to - from) * t; } if (typeof from === "string" && typeof to === "string") { if (from.startsWith("#") || to.startsWith("#") || from.startsWith("rgb") || to.startsWith("rgb")) { return this.color.interpolate(from, to, t); } } if (Array.isArray(from) && Array.isArray(to)) { return from.map( (f, i) => this.interpolate(f, to[i] || f, progress, easing) ); } if (typeof from === "object" && typeof to === "object") { const result = {}; for (const key in from) { result[key] = this.interpolate( from[key], to[key] || from[key], progress, easing ); } return result; } return t < 0.5 ? from : to; }, easing: { linear: (t) => t, easeIn: (t) => t * t, easeOut: (t) => t * (2 - t), easeInOut: (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t, backOut: (t) => { const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); } }, color: { parse(color) { if (color.startsWith("#")) { const hex = color.slice(1); if (hex.length === 3) { return { r: parseInt(hex[0] + hex[0], 16), g: parseInt(hex[1] + hex[1], 16), b: parseInt(hex[2] + hex[2], 16), a: 1 }; } else if (hex.length === 6) { return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16), a: 1 }; } } const rgbMatch = color.match( /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/ ); if (rgbMatch) { return { r: parseInt(rgbMatch[1], 10), g: parseInt(rgbMatch[2], 10), b: parseInt(rgbMatch[3], 10), a: rgbMatch[4] ? parseFloat(rgbMatch[4]) : 1 }; } return null; }, format(rgba) { if (rgba.a === 1) { return `rgb(${Math.round(rgba.r)}, ${Math.round(rgba.g)}, ${Math.round(rgba.b)})`; } else { return `rgba(${Math.round(rgba.r)}, ${Math.round(rgba.g)}, ${Math.round(rgba.b)}, ${rgba.a})`; } }, interpolate(from, to, progress) { const fromColor = this.parse(from); const toColor = this.parse(to); if (!fromColor || !toColor) { return progress < 0.5 ? from : to; } return this.format({ r: fromColor.r + (toColor.r - fromColor.r) * progress, g: fromColor.g + (toColor.g - fromColor.g) * progress, b: fromColor.b + (toColor.b - fromColor.b) * progress, a: fromColor.a + (toColor.a - fromColor.a) * progress }); } } }; function createPluginContextV3(config) { const scenarioInfo = new DefaultScenarioInfo(config.scenario); const assetManager = new DefaultAssetManager(config.baseUrl); return { container: config.container, scenario: scenarioInfo, assets: assetManager, channels: config.channels, portal: config.portal, audio: config.audio, renderer: config.renderer, utils: defaultUtils, onSeek: config.onSeek, ...config.peerDeps }; } function anchorTranslate(anchor) { switch (anchor) { case "tl": return { tx: 0, ty: 0 }; case "tc": return { tx: -50, ty: 0 }; case "tr": return { tx: -100, ty: 0 }; case "cl": return { tx: 0, ty: -50 }; case "cc": return { tx: -50, ty: -50 }; case "cr": return { tx: -100, ty: -50 }; case "bl": return { tx: 0, ty: -100 }; case "bc": return { tx: -50, ty: -100 }; case "br": return { tx: -100, ty: -100 }; default: return { tx: -50, ty: -50 }; } } function anchorFraction(anchor) { switch (anchor) { case "tl": return { ax: 0, ay: 0 }; case "tc": return { ax: 0.5, ay: 0 }; case "tr": return { ax: 1, ay: 0 }; case "cl": return { ax: 0, ay: 0.5 }; case "cc": return { ax: 0.5, ay: 0.5 }; case "cr": return { ax: 1, ay: 0.5 }; case "bl": return { ax: 0, ay: 1 }; case "bc": return { ax: 0.5, ay: 1 }; case "br": return { ax: 1, ay: 1 }; default: return { ax: 0.5, ay: 0.5 }; } } function getDefaultTrackConstraints(trackType) { switch (trackType) { case "subtitle": return { mode: "flow", direction: "vertical", maxWidth: 0.8, // 80% of stage width maxHeight: 0.4, // 40% of stage height minWidth: 0.1, minHeight: 0.05, gap: 0.02, // 2% gap between subtitle lines padding: { x: 0.02, y: 0.015 }, // internal padding anchor: "bc", // bottom-center default for subtitles constraintMode: "flexible", // allow some flexibility breakoutEnabled: false, // subtitles shouldn't escape bounds safeArea: { bottom: 0.075, left: 0.05, right: 0.05 } // safe area for subtitles }; case "free": return { mode: "absolute", direction: "vertical", maxWidth: 1, // full stage width maxHeight: 1, // full stage height minWidth: 0, minHeight: 0, gap: 0, padding: { x: 0, y: 0 }, anchor: "cc", // center-center default for free elements constraintMode: "breakout", // allow breaking constraints breakoutEnabled: true, // free elements can escape via portal safeArea: void 0 // no safe area restrictions }; default: return getDefaultTrackConstraints("free"); } } function mergeConstraints(parentConstraints, childConstraints) { if (!parentConstraints && !childConstraints) { return getDefaultTrackConstraints("free"); } if (!parentConstraints) { return { ...childConstraints }; } if (!childConstraints) { return { ...parentConstraints }; } return { mode: childConstraints.mode ?? parentConstraints.mode, direction: childConstraints.direction ?? parentConstraints.direction, maxWidth: childConstraints.maxWidth ?? parentConstraints.maxWidth, maxHeight: childConstraints.maxHeight ?? parentConstraints.maxHeight, minWidth: childConstraints.minWidth ?? parentConstraints.minWidth, minHeight: childConstraints.minHeight ?? parentConstraints.minHeight, gap: childConstraints.gap ?? parentConstraints.gap, padding: childConstraints.padding ?? parentConstraints.padding, anchor: childConstraints.anchor ?? parentConstraints.anchor, constraintMode: childConstraints.constraintMode ?? parentConstraints.constraintMode, breakoutEnabled: childConstraints.breakoutEnabled ?? parentConstraints.breakoutEnabled, safeArea: childConstraints.safeArea ?? parentConstraints.safeArea }; } function shouldBreakout(constraints, hasEffectScope) { return constraints.constraintMode === "breakout" || !!constraints.breakoutEnabled && hasEffectScope; } const clamp01 = (v) => v < 0 ? 0 : v > 1 ? 1 : v; const maxN = (a, b) => a == null ? b ?? 0 : b == null ? a : Math.max(a, b); function applyNormalizedPosition(el, layout, defaultAnchor = "cc", opts = {}) { var _a, _b, _c, _d; if (!layout || !layout.position) return; let { x, y } = layout.position; if (typeof x !== "number" || typeof y !== "number") return; if (layout.safeAreaClamp) { const ss = opts.stageSafe ?? {}; const ts = opts.trackSafe ?? {}; const effLeft = maxN(ss.left, ts.left); const effRight = maxN(ss.right, ts.right); const effTop = maxN(ss.top, ts.top); const effBottom = maxN(ss.bottom, ts.bottom); const xmin = clamp01(effLeft ?? 0); const xmax = 1 - clamp01(effRight ?? 0); const ymin = clamp01(effTop ?? 0); const ymax = 1 - clamp01(effBottom ?? 0); const parent = el.parentElement; const pw = (parent == null ? void 0 : parent.clientWidth) ?? 0; const ph = (parent == null ? void 0 : parent.clientHeight) ?? 0; let wN = typeof ((_a = layout.size) == null ? void 0 : _a.width) === "number" ? layout.size.width : 0; let hN = typeof ((_b = layout.size) == null ? void 0 : _b.height) === "number" ? layout.size.height : 0; if ((!wN || !hN) && el.isConnected && pw > 0 && ph > 0) { const bw = el.offsetWidth; const bh = el.offsetHeight; if (!wN && bw > 0) wN = bw / pw; if (!hN && bh > 0) hN = bh / ph; } const { ax, ay } = anchorFraction( layout.anchor || defaultAnchor ); if (wN > 0) { const xmin2 = xmin + (1 - ax) * wN; const xmax2 = xmax - ax * wN; x = Math.min(Math.max(x, xmin2), xmax2); } else { x = Math.min(Math.max(x, xmin), xmax); } if (hN > 0) { const ymin2 = ymin + (1 - ay) * hN; const ymax2 = ymax - ay * hN; y = Math.min(Math.max(y, ymin2), ymax2); } else { y = Math.min(Math.max(y, ymin), ymax); } } el.style.position = "absolute"; el.style.left = `${Math.round(x * 1e4) / 100}%`; el.style.top = `${Math.round(y * 1e4) / 100}%`; if (layout.size) { if (layout.size.width != null) { el.style.width = typeof layout.size.width === "number" ? `${layout.size.width * 100}%` : "auto"; } if (layout.size.height != null) { el.style.height = typeof layout.size.height === "number" ? `${layout.size.height * 100}%` : "auto"; } } if (layout.overflow) el.style.overflow = layout.overflow; if (layout.transformOrigin) el.style.transformOrigin = layout.transformOrigin; const anchor = layout.anchor || defaultAnchor; const { tx, ty } = anchorTranslate(anchor); const parts = [`translate(${tx}%, ${ty}%)`]; const tr = layout.transform; if (tr == null ? void 0 : tr.translate) { const dx = tr.translate.x ?? 0; const dy = tr.translate.y ?? 0; if (dx || dy) parts.push(`translate(${dx * 100}%, ${dy * 100}%)`); } if (tr == null ? void 0 : tr.scale) { const sx = tr.scale.x ?? 1; const sy = tr.scale.y ?? 1; if (sx !== 1 || sy !== 1) parts.push(`scale(${sx}, ${sy})`); } if (((_c = tr == null ? void 0 : tr.rotate) == null ? void 0 : _c.deg) != null) { parts.push(`rotate(${tr.rotate.deg}deg)`); } if (tr == null ? void 0 : tr.skew) { const xDeg = tr.skew.xDeg ?? 0; const yDeg = tr.skew.yDeg ?? 0; if (xDeg || yDeg) parts.push(`skew(${xDeg}deg, ${yDeg}deg)`); } const ov = layout.override; if ((ov == null ? void 0 : ov.mode) === "absolute") { if (ov.offset) { const ox = ov.offset.x ?? 0; const oy = ov.offset.y ?? 0; if (ox || oy) parts.push(`translate(${ox * 100}%, ${oy * 100}%)`); } if (ov.transform) { const ot = ov.transform; if (ot.translate) { const dx = ot.translate.x ?? 0; const dy = ot.translate.y ?? 0; if (dx || dy) parts.push(`translate(${dx * 100}%, ${dy * 100}%)`); } if (ot.scale) { const sx = ot.scale.x ?? 1; const sy = ot.scale.y ?? 1; if (sx !== 1 || sy !== 1) parts.push(`scale(${sx}, ${sy})`); } if (((_d = ot.rotate) == null ? void 0 : _d.deg) != null) parts.push(`rotate(${ot.rotate.deg}deg)`); if (ot.skew) { const sx = ot.skew.xDeg ?? 0, sy = ot.skew.yDeg ?? 0; if (sx || sy) parts.push(`skew(${sx}deg, ${sy}deg)`); } } } el.style.transform = parts.join(" "); } function applyLayoutWithConstraints(el, layout, constraints, defaultAnchor = "cc", opts = {}) { const effectiveConstraints = constraints ? mergeConstraints(opts.parentConstraints, constraints) : opts.parentConstraints; if (effectiveConstraints && shouldBreakout(effectiveConstraints, opts.hasEffectScope || false)) { applyNormalizedPosition(el, layout, defaultAnchor, opts); return; } const mode = (layout == null ? void 0 : layout.mode) || (effectiveConstraints == null ? void 0 : effectiveConstraints.mode) || "absolute"; switch (mode) { case "flow": applyFlowContainerWithConstraints( el, layout, effectiveConstraints, defaultAnchor, opts ); break; case "grid": applyGridContainerWithConstraints( el, layout, effectiveConstraints, defaultAnchor, opts ); break; case "absolute": default: applyNormalizedPosition(el, layout, defaultAnchor, opts); break; } if (layout == null ? void 0 : layout.childrenLayout) { const cl = layout.childrenLayout; const wantWrap = ((cl == null ? void 0 : cl.wrap) ?? (cl == null ? void 0 : cl.direction) === "horizontal") && !!el.parentElement; if (wantWrap) { const pw = el.parentElement.clientWidth || 0; const safe = (constraints == null ? void 0 : constraints.safeArea) || {}; const safeL = Math.max(0, Number(safe.left || 0)); const safeR = Math.max(0, Number(safe.right || 0)); const widthFactorFromSafe = Math.max(0, 1 - (safeL + safeR)); const widthFactorFromConstraint = (constraints == null ? void 0 : constraints.maxWidth) ?? 1; const widthFactorFromLayoutRel = typeof cl.maxWidthRel === "number" ? Math.max(0, cl.maxWidthRel) : 1; const widthFactorFromPercent = typeof cl.maxWidth === "string" && /%$/.test(cl.maxWidth) ? Math.max(0, Math.min(1, parseFloat(cl.maxWidth) / 100)) : 1; const ratioLimit = Math.min( widthFactorFromSafe, widthFactorFromConstraint, widthFactorFromLayoutRel, widthFactorFromPercent ); const ratioPx = Math.round(pw * ratioLimit); const absPx = typeof cl.maxWidth === "number" && Number.isFinite(cl.maxWidth) ? Math.max(0, Math.round(cl.maxWidth)) : Number.POSITIVE_INFINITY; const mw = Math.min(ratioPx, absPx); el.style.maxWidth = `${mw}px`; el.style.width = "auto"; } applyChildrenLayout(el, layout.childrenLayout); } } function applyFlowContainerWithConstraints(el, layout, constraints, defaultAnchor = "cc", opts = {}) { applyNormalizedPosition(el, layout, defaultAnchor, opts); el.style.display = "flex"; const direction = (constraints == null ? void 0 : constraints.direction) || "vertical"; el.style.flexDirection = direction === "vertical" ? "column" : "row"; const anchor = (layout == null ? void 0 : layout.anchor) || (constraints == null ? void 0 : constraints.anchor) || defaultAnchor; const alignMap = { tl: "flex-start", tc: "center", tr: "flex-end", cl: "flex-start", cc: "center", cr: "flex-end", bl: "flex-start", bc: "center", br: "flex-end" }; el.style.alignItems = alignMap[anchor] || "center"; const justifyMap = { tl: "flex-start", tc: "flex-start", tr: "flex-start", cl: "center", cc: "center", cr: "center", bl: "flex-end", bc: "flex-end", br: "flex-end" }; el.style.justifyContent = justifyMap[anchor] || "center"; const gap = (constraints == null ? void 0 : constraints.gap) || (layout == null ? void 0 : layout.gapRel) || 0; if (gap && el.parentElement) { const ph = el.parentElement.clientHeight || 0; const gapProp = direction === "vertical" ? "rowGap" : "columnGap"; el.style[gapProp] = `${Math.round(ph * gap)}px`; } applyConstraintSizing(el, constraints); } function applyGridContainerWithConstraints(el, layout, constraints, defaultAnchor = "cc", opts = {}) { applyNormalizedPosition(el, layout, defaultAnchor, opts); el.style.display = "grid"; el.style.gridTemplateColumns = "repeat(auto-fit, minmax(0, 1fr))"; const gap = (constraints == null ? void 0 : constraints.gap) || (layout == null ? void 0 : layout.gapRel) || 0; if (gap && el.parentElement) { const ph = el.parentElement.clientHeight || 0; const gapPx = Math.round(ph * gap); el.style.rowGap = `${gapPx}px`; el.style.columnGap = `${gapPx}px`; } const anchor = (layout == null ? void 0 : layout.anchor) || (constraints == null ? void 0 : constraints.anchor) || defaultAnchor; const hmap = { tl: "start", tc: "center", tr: "end", cl: "start", cc: "center", cr: "end", bl: "start", bc: "center", br: "end" }; el.style.justifyItems = hmap[anchor] || "center"; applyConstraintSizing(el, constraints); } function applyConstraintSizing(el, constraints) { if (!constraints) return; if (constraints.maxWidth !== void 0) { el.style.maxWidth = `${constraints.maxWidth * 100}%`; } if (constraints.maxHeight !== void 0) { el.style.maxHeight = `${constraints.maxHeight * 100}%`; } if (constraints.minWidth !== void 0) { el.style.minWidth = `${constraints.minWidth * 100}%`; } if (constraints.minHeight !== void 0) { el.style.minHeight = `${constraints.minHeight * 100}%`; } if (constraints.padding) { const px = constraints.padding.x || 0; const py = constraints.padding.y || 0; if (el.parentElement) { const pw = el.parentElement.clientWidth || 0; const ph = el.parentElement.clientHeight || 0; el.style.padding = `${Math.round(ph * py)}px ${Math.round(pw * px)}px`; } } } function applyChildrenLayout(el, childrenLayout) { try { el.__mtxChildrenLayout = childrenLayout; } catch { } const cw = childrenLayout; const { mode = "flow", direction = "horizontal", gap = 0, align = "center", justify = "center" } = childrenLayout; const wrap = (cw == null ? void 0 : cw.wrap) ?? direction === "horizontal"; switch (mode) { case "flow": { el.style.display = "flex"; el.style.flexDirection = direction === "horizontal" ? "row" : "column"; el.style.flexWrap = wrap && direction === "horizontal" ? "wrap" : "nowrap"; const alignItems = align === "start" ? "flex-start" : align === "end" ? "flex-end" : "center"; const justifyContent = justify === "start" ? "flex-start" : justify === "end" ? "flex-end" : justify === "space-between" ? "space-between" : "center"; el.style.alignItems = alignItems; el.style.justifyContent = justifyContent; if (gap && el.parentElement) { const containerSize = direction === "horizontal" ? el.parentElement.clientWidth || 0 : el.parentElement.clientHeight || 0; const gapPx = Math.round(containerSize * gap); const gapProp = direction === "horizontal" ? "columnGap" : "rowGap"; el.style[gapProp] = ""; const children = Array.from(el.children); for (let i = 0; i < children.length; i++) { const c = children[i]; c.style.padding = "0px"; c.style.flex = "0 0 auto"; if (direction === "horizontal") { c.style.marginLeft = "0px"; c.style.marginRight = `${gapPx}px`; c.style.marginTop = ""; } else { c.style.marginTop = i === 0 ? "0" : `${gapPx}px`; c.style.marginLeft = ""; c.style.marginRight = ""; } } } break; } case "grid": { el.style.display = "grid"; el.style.gridTemplateColumns = "repeat(auto-fit, minmax(0, 1fr))"; if (gap && el.parentElement) { const containerSize = el.parentElement.clientHeight || 0; const gapPx = Math.round(containerSize * gap); el.style.rowGap = `${gapPx}px`; el.style.columnGap = `${gapPx}px`; } break; } } } let globalDebugEnabled = false; function setGlobalDebug(enabled) { globalDebugEnabled = enabled; } function createLogger(scope, enabled = null) { let localEnabled = enabled; const prefix = (level) => `[${scope}]` + (level === "debug" ? "" : ` ${level.toUpperCase()}`); const isEnabled = (level) => { if (level === "warn" || level === "error") return true; const flag = localEnabled === null ? globalDebugEnabled : localEnabled; return !!flag; }; return { debug: (...args) => { if (isEnabled("debug")) console.log(prefix("debug"), ...args); }, info: (...args) => { if (isEnabled("info")) console.info(prefix("info"), ...args); }, warn: (...args) => { if (isEnabled("warn")) console.warn(prefix("warn"), ...args); }, error: (...args) => { if (isEnabled("error")) console.error(prefix("error"), ...args); }, setEnabled: (enabledVal) => { localEnabled = enabledVal; }, enable: () => { localEnabled = true; }, disable: () => { localEnabled = false; } }; } class TimelineControllerV2 { constructor(options = {}) { this.media = null; this.tickCallbacks = /* @__PURE__ */ new Set(); this.rafId = null; this.videoFrameId = null; this.startTime = 0; this.pausedTime = 0; this.logger = createLogger("TimelineV2", null); this.supportsVideoFrame = "requestVideoFrameCallback" in HTMLVideoElement.prototype; this.onVideoPlay = () => { if (this.options.autoStart) { this.play(); } }; this.onVideoPause = () => { this.pause(); }; this.onVideoSeeked = () => { if (this.media) { this.seek(this.media.currentTime); } }; this.onVideoRateChange = () => { if (this.media) { this.setRate(this.media.playbackRate); } }; this.options = { autoStart: true, snapToFrame: false, fps: 30, syncTolerance: 0.1, debugMode: false, ...options }; this.state = { isPlaying: false, currentTime: 0, playbackRate: 1, frameCount: 0, lastVideoTime: 0, driftCorrection: 0 }; this.logger.setEnabled(this.options.debugMode); this.logger.debug("Initialized with options:", this.options); this.logger.debug( "requestVideoFrameCallback supported:", this.supportsVideoFrame ); } /** * 비디오 미디어 연결 * @param video - 동기화할 비디오 요소 */ attachMedia(video) { this.detachMedia(); this.media = video; this.setupMediaListeners(); if (this.options.autoStart && !video.paused) { this.ensurePlaying(); } this.state.currentTime = video.currentTime; this.state.playbackRate = video.playbackRate; if (this.options.debugMode) { this.logger.debug("Media attached:", { duration: video.duration, currentTime: video.currentTime, playbackRate: video.playbackRate }); } } /** * 비디오 미디어 연결 해제 */ detachMedia() { if (!this.media) return; this.stop(); this.removeMediaListeners(); this.media = null; this.logger.debug("Media detached"); } /** * 타임라인 재생 시작 */ play() { if (this.state.isPlaying) return; this.state.isPlaying = true; this.startTime = performance.now() - (this.pausedTime || 0); if (this.supportsVideoFrame && this.media) { this.startVideoFrameLoop(); } else { this.startRafLoop(); } if (this.options.debugMode) { console.log("[TimelineV2] Play started"); } } /** * 타임라인 일시정지 */ pause() { if (!this.state.isPlaying) return; this.state.isPlaying = false; this.pausedTime = performance.now() - this.startTime; this.stopFrameLoop(); if (this.options.debugMode) { console.log("[TimelineV2] Paused at:", this.state.currentTime); } } /** * 타임라인 정지 (처음으로 되돌림) */ stop() { this.pause(); this.state.currentTime = 0; this.state.frameCount = 0; this.pausedTime = 0; this.state.driftCorrection = 0; if (this.options.debugMode) { console.log("[TimelineV2] Stopped"); } } /** * 특정 시간으로 이동 * @param time - 이동할 시간 (초) */ seek(time) { if (!Number.isFinite(time) || time < 0) return; this.state.currentTime = time; this.pausedTime = 0; this.startTime = performance.now(); if (this.media && Math.abs(this.media.currentTime - time) > 0.1) { this.media.currentTime = time; } this.notifyTick(); if (this.options.debugMode) { console.log("[TimelineV2] Seeked to:", time); } } /** * 재생 속도 설정 * @param rate - 재생 속도 (1.0 = 정상) */ setRate(rate) {