motiontext-renderer
Version:
Web-based animated caption/subtitle renderer with plugin system
1,579 lines (1,578 loc) • 137 kB
JavaScript
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) {