@spatialwalk/avatarkit-rtc 1.0.0-beta.6 → 1.0.0-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index15.js CHANGED
@@ -1,4 +1,4 @@
1
- const jsContent = 'const VP8_FRAME_HEADER_SIZE = 10;\nconst METADATA_FIXED_HEADER_SIZE = 5;\nconst PACKET_FLAG_IDLE = 1;\nconst PACKET_FLAG_START = 2;\nconst PACKET_FLAG_END = 4;\nconst PACKET_FLAG_GZIPPED = 8;\nconst PACKET_FLAG_TRANSITION = 16;\nconst PACKET_FLAG_TRANSITION_END = 32;\nconst PACKET_FLAG_REDUNDANT = 64;\nlet receiverMetaCount = 0;\nlet lastLogTime = 0;\nlet totalFrames = 0;\nlet framesWithMeta = 0;\nlet wasIdle = false;\nlet lastReceivedTimestamp = null;\nlet framesRecovered = 0;\nlet framesLost = 0;\nconst EXPECTED_TIMESTAMP_INCREMENT = 3600;\nlet lastRenderedSeq = -1;\nlet framesOutOfOrder = 0;\nlet framesDuplicate = 0;\nlet framesDropped = 0;\nlet framesSent = 0;\nfunction parseMetadataHeader(data) {\n if (data.byteLength < METADATA_FIXED_HEADER_SIZE) return null;\n const view = new DataView(data.buffer, data.byteOffset, data.byteLength);\n const flags = data[0];\n const msgLen = view.getUint32(1, true);\n const headerSize = METADATA_FIXED_HEADER_SIZE + msgLen;\n if (data.byteLength < headerSize) return null;\n const compressedData = data.slice(METADATA_FIXED_HEADER_SIZE, METADATA_FIXED_HEADER_SIZE + msgLen);\n const hasRedundant = (flags & PACKET_FLAG_REDUNDANT) !== 0;\n return {\n meta: {\n flags,\n protobufLength: msgLen,\n protobufData: compressedData,\n // This is still compressed at this point\n hasRedundant,\n redundantLength: 0,\n // Will be determined after decompression\n redundantData: null\n // Will be extracted after decompression\n },\n headerSize\n };\n}\nfunction isIdlePacket(flags) {\n return (flags & PACKET_FLAG_IDLE) !== 0;\n}\nfunction isStartPacket(flags) {\n return (flags & PACKET_FLAG_START) !== 0;\n}\nfunction isEndPacket(flags) {\n return (flags & PACKET_FLAG_END) !== 0;\n}\nfunction isGzipped(flags) {\n return (flags & PACKET_FLAG_GZIPPED) !== 0;\n}\nfunction isTransitionPacket(flags) {\n return (flags & PACKET_FLAG_TRANSITION) !== 0;\n}\nfunction isTransitionEndPacket(flags) {\n return (flags & PACKET_FLAG_TRANSITION_END) !== 0;\n}\nasync function decompressGzip(data) {\n const ds = new DecompressionStream("gzip");\n const writer = ds.writable.getWriter();\n const copy = new Uint8Array(data);\n writer.write(copy);\n writer.close();\n const reader = ds.readable.getReader();\n const chunks = [];\n let totalLength = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n chunks.push(value);\n totalLength += value.length;\n }\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n}\nlet totalCompressedBytes = 0;\nlet totalUncompressedBytes = 0;\nfunction parseALRPayload(decompressed, hasRedundant) {\n if (decompressed.byteLength < 4) return null;\n const view = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength);\n let offset = 0;\n const frameSeq = view.getUint32(offset, true);\n offset += 4;\n if (!hasRedundant) {\n const currentData2 = decompressed.slice(offset);\n return { frameSeq, currentData: currentData2, prev1Data: null, prev2Data: null };\n }\n if (decompressed.byteLength < offset + 4) return null;\n const currentLen = view.getUint32(offset, true);\n offset += 4;\n if (decompressed.byteLength < offset + currentLen) return null;\n const currentData = decompressed.slice(offset, offset + currentLen);\n offset += currentLen;\n let prev1Data = null;\n if (decompressed.byteLength >= offset + 4) {\n const prev1Len = view.getUint32(offset, true);\n offset += 4;\n if (prev1Len > 0 && decompressed.byteLength >= offset + prev1Len) {\n prev1Data = decompressed.slice(offset, offset + prev1Len);\n offset += prev1Len;\n }\n }\n let prev2Data = null;\n if (decompressed.byteLength >= offset + 4) {\n const prev2Len = view.getUint32(offset, true);\n offset += 4;\n if (prev2Len > 0 && decompressed.byteLength >= offset + prev2Len) {\n prev2Data = decompressed.slice(offset, offset + prev2Len);\n }\n }\n return { frameSeq, currentData, prev1Data, prev2Data };\n}\nfunction sendAnimationToMainThread(protobufData, flags, frameSeq, isRecovered = false) {\n const isIdle = isIdlePacket(flags);\n const isStart = isStartPacket(flags);\n const isEnd = isEndPacket(flags);\n if (frameSeq <= lastRenderedSeq && lastRenderedSeq !== -1 && !isStart) {\n if (frameSeq === lastRenderedSeq) {\n framesDuplicate++;\n } else {\n framesOutOfOrder++;\n }\n return false;\n }\n if (lastRenderedSeq !== -1 && frameSeq > lastRenderedSeq + 1 && !isStart) {\n const gap = frameSeq - lastRenderedSeq - 1;\n framesDropped += gap;\n }\n framesSent++;\n lastRenderedSeq = frameSeq;\n const protobufBuffer = new ArrayBuffer(protobufData.byteLength);\n new Uint8Array(protobufBuffer).set(protobufData);\n self.postMessage({\n type: "animation",\n flags,\n isIdle,\n isStart,\n isEnd,\n isRecovered,\n frameSeq,\n protobufData: protobufBuffer\n }, { transfer: [protobufBuffer] });\n return true;\n}\nfunction receiverTransform(frame, _controller) {\n totalFrames++;\n const data = new Uint8Array(frame.data);\n const currentTimestamp = frame.timestamp;\n if (data.length <= VP8_FRAME_HEADER_SIZE) {\n return;\n }\n const animData = data.subarray(VP8_FRAME_HEADER_SIZE);\n const parsed = parseMetadataHeader(animData);\n if (parsed) {\n const { meta } = parsed;\n framesWithMeta++;\n receiverMetaCount++;\n const isIdle = isIdlePacket(meta.flags);\n const isStart = isStartPacket(meta.flags);\n const isEnd = isEndPacket(meta.flags);\n if (lastReceivedTimestamp !== null && !isIdle && !isStart) {\n const timestampDelta = currentTimestamp - lastReceivedTimestamp;\n if (timestampDelta > EXPECTED_TIMESTAMP_INCREMENT * 1.5) {\n const missedFrames = Math.round(timestampDelta / EXPECTED_TIMESTAMP_INCREMENT) - 1;\n framesLost += missedFrames;\n if (meta.hasRedundant && isGzipped(meta.flags)) {\n totalCompressedBytes += meta.protobufData.byteLength;\n decompressGzip(meta.protobufData).then((decompressed) => {\n totalUncompressedBytes += decompressed.byteLength;\n const parsed2 = parseALRPayload(decompressed, true);\n if (parsed2) {\n const currentSeq = parsed2.frameSeq;\n const framesToRecover = [];\n if (missedFrames >= 2 && parsed2.prev2Data) {\n framesToRecover.push({ data: parsed2.prev2Data, seq: currentSeq - 2 });\n }\n if (missedFrames >= 1 && parsed2.prev1Data) {\n framesToRecover.push({ data: parsed2.prev1Data, seq: currentSeq - 1 });\n }\n const recovered = framesToRecover.length;\n if (recovered > 0) {\n framesRecovered += recovered;\n for (const frame2 of framesToRecover) {\n sendAnimationToMainThread(frame2.data, meta.flags & ~PACKET_FLAG_REDUNDANT, frame2.seq, true);\n }\n }\n sendAnimationToMainThread(parsed2.currentData, meta.flags & ~PACKET_FLAG_REDUNDANT, currentSeq, false);\n }\n }).catch((err) => {\n console.error(`[Animation Worker] ALR decompression error:`, err);\n });\n lastReceivedTimestamp = currentTimestamp;\n return;\n }\n }\n }\n if (!isIdle) {\n lastReceivedTimestamp = currentTimestamp;\n }\n if (isStart) {\n lastReceivedTimestamp = currentTimestamp;\n framesRecovered = 0;\n framesLost = 0;\n lastRenderedSeq = -1;\n framesOutOfOrder = 0;\n framesDuplicate = 0;\n framesDropped = 0;\n framesSent = 0;\n }\n const isTransition = isTransitionPacket(meta.flags);\n if (isIdle) {\n if (!wasIdle) {\n self.postMessage({ type: "idleStart" });\n wasIdle = true;\n }\n } else if (isTransition && meta.protobufLength > 0) {\n wasIdle = false;\n const gzipped = isGzipped(meta.flags);\n if (gzipped) {\n totalCompressedBytes += meta.protobufData.byteLength;\n decompressGzip(meta.protobufData).then((decompressed) => {\n totalUncompressedBytes += decompressed.byteLength;\n const protobufBuffer = new ArrayBuffer(decompressed.byteLength);\n new Uint8Array(protobufBuffer).set(decompressed);\n self.postMessage({\n type: "transition",\n flags: meta.flags,\n protobufData: protobufBuffer\n }, { transfer: [protobufBuffer] });\n }).catch((err) => {\n console.error(`[Animation Worker] Gzip decompress error (transition):`, err);\n });\n } else {\n const protobufBuffer = new ArrayBuffer(meta.protobufData.byteLength);\n new Uint8Array(protobufBuffer).set(meta.protobufData);\n self.postMessage({\n type: "transition",\n flags: meta.flags,\n protobufData: protobufBuffer\n }, { transfer: [protobufBuffer] });\n }\n } else if (isTransitionEndPacket(meta.flags) && meta.protobufLength > 0) {\n const gzipped = isGzipped(meta.flags);\n if (gzipped) {\n totalCompressedBytes += meta.protobufData.byteLength;\n decompressGzip(meta.protobufData).then((decompressed) => {\n totalUncompressedBytes += decompressed.byteLength;\n const protobufBuffer = new ArrayBuffer(decompressed.byteLength);\n new Uint8Array(protobufBuffer).set(decompressed);\n self.postMessage({\n type: "transitionEnd",\n flags: meta.flags,\n protobufData: protobufBuffer\n }, { transfer: [protobufBuffer] });\n }).catch((err) => {\n console.error(`[Animation Worker] Gzip decompress error (transitionEnd):`, err);\n });\n } else {\n const protobufBuffer = new ArrayBuffer(meta.protobufData.byteLength);\n new Uint8Array(protobufBuffer).set(meta.protobufData);\n self.postMessage({\n type: "transitionEnd",\n flags: meta.flags,\n protobufData: protobufBuffer\n }, { transfer: [protobufBuffer] });\n }\n } else if (meta.protobufLength > 0) {\n if (wasIdle) {\n wasIdle = false;\n }\n const gzipped = isGzipped(meta.flags);\n if (gzipped) {\n totalCompressedBytes += meta.protobufData.byteLength;\n decompressGzip(meta.protobufData).then((decompressed) => {\n totalUncompressedBytes += decompressed.byteLength;\n const parsed2 = parseALRPayload(decompressed, meta.hasRedundant);\n if (parsed2) {\n sendAnimationToMainThread(parsed2.currentData, meta.flags & ~PACKET_FLAG_REDUNDANT, parsed2.frameSeq, false);\n }\n }).catch(() => {\n });\n } else {\n const protobufBuffer = new ArrayBuffer(meta.protobufData.byteLength);\n new Uint8Array(protobufBuffer).set(meta.protobufData);\n self.postMessage({\n type: "animation",\n flags: meta.flags,\n isIdle: false,\n isStart,\n isEnd,\n frameSeq: -1,\n // Unknown sequence\n protobufData: protobufBuffer\n }, { transfer: [protobufBuffer] });\n }\n }\n const now = performance.now();\n if (now - lastLogTime > 1e3) {\n lastLogTime = now;\n self.postMessage({\n type: "metadata",\n protobufLength: meta.protobufLength,\n framesPerSec: receiverMetaCount,\n totalFrames,\n framesWithMeta,\n framesLost,\n framesRecovered,\n framesOutOfOrder,\n framesDuplicate,\n framesDropped,\n framesSent,\n lastRenderedSeq\n });\n receiverMetaCount = 0;\n }\n }\n}\nself.onrtctransform = (event) => {\n const transformer = event.transformer;\n const options = transformer.options;\n try {\n if (options.operation === "receiver") {\n receiverMetaCount = 0;\n lastLogTime = 0;\n totalFrames = 0;\n framesWithMeta = 0;\n wasIdle = false;\n lastReceivedTimestamp = null;\n framesRecovered = 0;\n framesLost = 0;\n lastRenderedSeq = -1;\n framesOutOfOrder = 0;\n framesDuplicate = 0;\n framesDropped = 0;\n framesSent = 0;\n transformer.readable.pipeThrough(new TransformStream({ transform: receiverTransform })).pipeTo(transformer.writable).catch((err) => {\n console.error("[Animation Worker] Pipeline error:", err);\n self.postMessage({ type: "error", error: `Animation receiver pipe error: ${err}` });\n });\n self.postMessage({ type: "ready", operation: "receiver" });\n }\n } catch (err) {\n console.error("[Animation Worker] Setup error:", err);\n self.postMessage({ type: "error", error: `Animation transform setup error: ${err}` });\n }\n};\nself.onmessage = (event) => {\n const { type } = event.data;\n if (type === "init") {\n self.postMessage({ type: "initialized" });\n }\n};\n//# sourceMappingURL=animation-worker-CUXZycUw.js.map\n';
1
+ const jsContent = 'const VP8_FRAME_HEADER_SIZE = 10;\nconst METADATA_FIXED_HEADER_SIZE = 5;\nconst PACKET_FLAG_IDLE = 1;\nconst PACKET_FLAG_START = 2;\nconst PACKET_FLAG_END = 4;\nconst PACKET_FLAG_GZIPPED = 8;\nconst PACKET_FLAG_TRANSITION = 16;\nconst PACKET_FLAG_TRANSITION_END = 32;\nconst PACKET_FLAG_REDUNDANT = 64;\nlet receiverMetaCount = 0;\nlet lastLogTime = 0;\nlet totalFrames = 0;\nlet framesWithMeta = 0;\nlet wasIdle = false;\nlet isInStartTransition = false;\nlet isInEndTransition = false;\nlet lastReceivedTimestamp = null;\nlet framesRecovered = 0;\nlet framesLost = 0;\nconst EXPECTED_TIMESTAMP_INCREMENT = 3600;\nlet lastRenderedSeq = -1;\nlet framesOutOfOrder = 0;\nlet framesDuplicate = 0;\nlet framesDropped = 0;\nlet framesSent = 0;\nfunction parseMetadataHeader(data) {\n if (data.byteLength < METADATA_FIXED_HEADER_SIZE) return null;\n const view = new DataView(data.buffer, data.byteOffset, data.byteLength);\n const flags = data[0];\n const msgLen = view.getUint32(1, true);\n const headerSize = METADATA_FIXED_HEADER_SIZE + msgLen;\n if (data.byteLength < headerSize) return null;\n const compressedData = data.slice(METADATA_FIXED_HEADER_SIZE, METADATA_FIXED_HEADER_SIZE + msgLen);\n const hasRedundant = (flags & PACKET_FLAG_REDUNDANT) !== 0;\n return {\n meta: {\n flags,\n protobufLength: msgLen,\n protobufData: compressedData,\n // This is still compressed at this point\n hasRedundant,\n redundantLength: 0,\n // Will be determined after decompression\n redundantData: null\n // Will be extracted after decompression\n },\n headerSize\n };\n}\nfunction isIdlePacket(flags) {\n return (flags & PACKET_FLAG_IDLE) !== 0;\n}\nfunction isStartPacket(flags) {\n return (flags & PACKET_FLAG_START) !== 0;\n}\nfunction isEndPacket(flags) {\n return (flags & PACKET_FLAG_END) !== 0;\n}\nfunction isGzipped(flags) {\n return (flags & PACKET_FLAG_GZIPPED) !== 0;\n}\nfunction isTransitionPacket(flags) {\n return (flags & PACKET_FLAG_TRANSITION) !== 0;\n}\nfunction isTransitionEndPacket(flags) {\n return (flags & PACKET_FLAG_TRANSITION_END) !== 0;\n}\nasync function decompressGzip(data) {\n const ds = new DecompressionStream("gzip");\n const writer = ds.writable.getWriter();\n const copy = new Uint8Array(data);\n writer.write(copy);\n writer.close();\n const reader = ds.readable.getReader();\n const chunks = [];\n let totalLength = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n chunks.push(value);\n totalLength += value.length;\n }\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.length;\n }\n return result;\n}\nlet totalCompressedBytes = 0;\nlet totalUncompressedBytes = 0;\nfunction parseALRPayload(decompressed, hasRedundant) {\n if (decompressed.byteLength < 4) return null;\n const view = new DataView(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength);\n let offset = 0;\n const frameSeq = view.getUint32(offset, true);\n offset += 4;\n if (!hasRedundant) {\n const currentData2 = decompressed.slice(offset);\n return { frameSeq, currentData: currentData2, prev1Data: null, prev2Data: null };\n }\n if (decompressed.byteLength < offset + 4) return null;\n const currentLen = view.getUint32(offset, true);\n offset += 4;\n if (decompressed.byteLength < offset + currentLen) return null;\n const currentData = decompressed.slice(offset, offset + currentLen);\n offset += currentLen;\n let prev1Data = null;\n if (decompressed.byteLength >= offset + 4) {\n const prev1Len = view.getUint32(offset, true);\n offset += 4;\n if (prev1Len > 0 && decompressed.byteLength >= offset + prev1Len) {\n prev1Data = decompressed.slice(offset, offset + prev1Len);\n offset += prev1Len;\n }\n }\n let prev2Data = null;\n if (decompressed.byteLength >= offset + 4) {\n const prev2Len = view.getUint32(offset, true);\n offset += 4;\n if (prev2Len > 0 && decompressed.byteLength >= offset + prev2Len) {\n prev2Data = decompressed.slice(offset, offset + prev2Len);\n }\n }\n return { frameSeq, currentData, prev1Data, prev2Data };\n}\nfunction sendAnimationToMainThread(protobufData, flags, frameSeq, isRecovered = false) {\n const isIdle = isIdlePacket(flags);\n const isStart = isStartPacket(flags);\n const isEnd = isEndPacket(flags);\n if (frameSeq <= lastRenderedSeq && lastRenderedSeq !== -1 && !isStart) {\n if (frameSeq === lastRenderedSeq) {\n framesDuplicate++;\n } else {\n framesOutOfOrder++;\n }\n return false;\n }\n if (lastRenderedSeq !== -1 && frameSeq > lastRenderedSeq + 1 && !isStart) {\n const gap = frameSeq - lastRenderedSeq - 1;\n framesDropped += gap;\n }\n framesSent++;\n lastRenderedSeq = frameSeq;\n const protobufBuffer = new ArrayBuffer(protobufData.byteLength);\n new Uint8Array(protobufBuffer).set(protobufData);\n self.postMessage({\n type: "animation",\n flags,\n isIdle,\n isStart,\n isEnd,\n isRecovered,\n frameSeq,\n protobufData: protobufBuffer\n }, { transfer: [protobufBuffer] });\n return true;\n}\nfunction receiverTransform(frame, _controller) {\n totalFrames++;\n const data = new Uint8Array(frame.data);\n const currentTimestamp = frame.timestamp;\n if (data.length <= VP8_FRAME_HEADER_SIZE) {\n return;\n }\n const animData = data.subarray(VP8_FRAME_HEADER_SIZE);\n const parsed = parseMetadataHeader(animData);\n if (parsed) {\n const { meta } = parsed;\n framesWithMeta++;\n receiverMetaCount++;\n const isIdle = isIdlePacket(meta.flags);\n const isStart = isStartPacket(meta.flags);\n const isEnd = isEndPacket(meta.flags);\n if (lastReceivedTimestamp !== null && !isIdle && !isStart) {\n const timestampDelta = currentTimestamp - lastReceivedTimestamp;\n if (timestampDelta > EXPECTED_TIMESTAMP_INCREMENT * 1.5) {\n const missedFrames = Math.round(timestampDelta / EXPECTED_TIMESTAMP_INCREMENT) - 1;\n framesLost += missedFrames;\n if (meta.hasRedundant && isGzipped(meta.flags)) {\n totalCompressedBytes += meta.protobufData.byteLength;\n decompressGzip(meta.protobufData).then((decompressed) => {\n totalUncompressedBytes += decompressed.byteLength;\n const parsed2 = parseALRPayload(decompressed, true);\n if (parsed2) {\n const currentSeq = parsed2.frameSeq;\n const framesToRecover = [];\n if (missedFrames >= 2 && parsed2.prev2Data) {\n framesToRecover.push({ data: parsed2.prev2Data, seq: currentSeq - 2 });\n }\n if (missedFrames >= 1 && parsed2.prev1Data) {\n framesToRecover.push({ data: parsed2.prev1Data, seq: currentSeq - 1 });\n }\n const recovered = framesToRecover.length;\n if (recovered > 0) {\n framesRecovered += recovered;\n for (const frame2 of framesToRecover) {\n sendAnimationToMainThread(frame2.data, meta.flags & ~PACKET_FLAG_REDUNDANT, frame2.seq, true);\n }\n }\n sendAnimationToMainThread(parsed2.currentData, meta.flags & ~PACKET_FLAG_REDUNDANT, currentSeq, false);\n }\n }).catch((err) => {\n console.error(`[Animation Worker] ALR decompression error:`, err);\n });\n lastReceivedTimestamp = currentTimestamp;\n return;\n }\n }\n }\n if (!isIdle) {\n lastReceivedTimestamp = currentTimestamp;\n }\n if (isStart) {\n lastReceivedTimestamp = currentTimestamp;\n framesRecovered = 0;\n framesLost = 0;\n lastRenderedSeq = -1;\n framesOutOfOrder = 0;\n framesDuplicate = 0;\n framesDropped = 0;\n framesSent = 0;\n }\n const isTransition = isTransitionPacket(meta.flags);\n const isTransitionEnd = isTransitionEndPacket(meta.flags);\n if (isIdle) {\n if (!wasIdle) {\n self.postMessage({ type: "idleStart" });\n wasIdle = true;\n }\n isInStartTransition = false;\n isInEndTransition = false;\n } else if (isTransition && meta.protobufLength > 0) {\n wasIdle = false;\n if (!isInStartTransition) {\n isInStartTransition = true;\n isInEndTransition = false;\n const gzipped = isGzipped(meta.flags);\n if (gzipped) {\n totalCompressedBytes += meta.protobufData.byteLength;\n decompressGzip(meta.protobufData).then((decompressed) => {\n totalUncompressedBytes += decompressed.byteLength;\n const protobufBuffer = new ArrayBuffer(decompressed.byteLength);\n new Uint8Array(protobufBuffer).set(decompressed);\n self.postMessage({\n type: "transition",\n flags: meta.flags,\n protobufData: protobufBuffer\n }, { transfer: [protobufBuffer] });\n }).catch((err) => {\n console.error(`[Animation Worker] Gzip decompress error (transition):`, err);\n });\n } else {\n const protobufBuffer = new ArrayBuffer(meta.protobufData.byteLength);\n new Uint8Array(protobufBuffer).set(meta.protobufData);\n self.postMessage({\n type: "transition",\n flags: meta.flags,\n protobufData: protobufBuffer\n }, { transfer: [protobufBuffer] });\n }\n }\n } else if (isTransitionEnd && meta.protobufLength > 0) {\n if (!isInEndTransition) {\n isInEndTransition = true;\n isInStartTransition = false;\n const gzipped = isGzipped(meta.flags);\n if (gzipped) {\n totalCompressedBytes += meta.protobufData.byteLength;\n decompressGzip(meta.protobufData).then((decompressed) => {\n totalUncompressedBytes += decompressed.byteLength;\n const protobufBuffer = new ArrayBuffer(decompressed.byteLength);\n new Uint8Array(protobufBuffer).set(decompressed);\n self.postMessage({\n type: "transitionEnd",\n flags: meta.flags,\n protobufData: protobufBuffer\n }, { transfer: [protobufBuffer] });\n }).catch((err) => {\n console.error(`[Animation Worker] Gzip decompress error (transitionEnd):`, err);\n });\n } else {\n const protobufBuffer = new ArrayBuffer(meta.protobufData.byteLength);\n new Uint8Array(protobufBuffer).set(meta.protobufData);\n self.postMessage({\n type: "transitionEnd",\n flags: meta.flags,\n protobufData: protobufBuffer\n }, { transfer: [protobufBuffer] });\n }\n }\n } else if (meta.protobufLength > 0) {\n if (wasIdle) {\n wasIdle = false;\n }\n isInStartTransition = false;\n isInEndTransition = false;\n const gzipped = isGzipped(meta.flags);\n if (gzipped) {\n totalCompressedBytes += meta.protobufData.byteLength;\n decompressGzip(meta.protobufData).then((decompressed) => {\n totalUncompressedBytes += decompressed.byteLength;\n const parsed2 = parseALRPayload(decompressed, meta.hasRedundant);\n if (parsed2) {\n sendAnimationToMainThread(parsed2.currentData, meta.flags & ~PACKET_FLAG_REDUNDANT, parsed2.frameSeq, false);\n }\n }).catch(() => {\n });\n } else {\n const protobufBuffer = new ArrayBuffer(meta.protobufData.byteLength);\n new Uint8Array(protobufBuffer).set(meta.protobufData);\n self.postMessage({\n type: "animation",\n flags: meta.flags,\n isIdle: false,\n isStart,\n isEnd,\n frameSeq: -1,\n // Unknown sequence\n protobufData: protobufBuffer\n }, { transfer: [protobufBuffer] });\n }\n }\n const now = performance.now();\n if (now - lastLogTime > 1e3) {\n lastLogTime = now;\n self.postMessage({\n type: "metadata",\n protobufLength: meta.protobufLength,\n framesPerSec: receiverMetaCount,\n totalFrames,\n framesWithMeta,\n framesLost,\n framesRecovered,\n framesOutOfOrder,\n framesDuplicate,\n framesDropped,\n framesSent,\n lastRenderedSeq\n });\n receiverMetaCount = 0;\n }\n }\n}\nself.onrtctransform = (event) => {\n const transformer = event.transformer;\n const options = transformer.options;\n try {\n if (options.operation === "receiver") {\n receiverMetaCount = 0;\n lastLogTime = 0;\n totalFrames = 0;\n framesWithMeta = 0;\n wasIdle = false;\n isInStartTransition = false;\n isInEndTransition = false;\n lastReceivedTimestamp = null;\n framesRecovered = 0;\n framesLost = 0;\n lastRenderedSeq = -1;\n framesOutOfOrder = 0;\n framesDuplicate = 0;\n framesDropped = 0;\n framesSent = 0;\n transformer.readable.pipeThrough(new TransformStream({ transform: receiverTransform })).pipeTo(transformer.writable).catch((err) => {\n console.error("[Animation Worker] Pipeline error:", err);\n self.postMessage({ type: "error", error: `Animation receiver pipe error: ${err}` });\n });\n self.postMessage({ type: "ready", operation: "receiver" });\n }\n } catch (err) {\n console.error("[Animation Worker] Setup error:", err);\n self.postMessage({ type: "error", error: `Animation transform setup error: ${err}` });\n }\n};\nself.onmessage = (event) => {\n const { type } = event.data;\n if (type === "init") {\n self.postMessage({ type: "initialized" });\n }\n};\n//# sourceMappingURL=animation-worker-CdhDm7lL.js.map\n';
2
2
  const blob = typeof self !== "undefined" && self.Blob && new Blob(["URL.revokeObjectURL(import.meta.url);", jsContent], { type: "text/javascript;charset=utf-8" });
3
3
  function WorkerWrapper(options) {
4
4
  let objURL;
package/dist/index2.js CHANGED
@@ -29,6 +29,10 @@ class AvatarPlayer {
29
29
  __publicField(this, "lastConnectionConfig", null);
30
30
  /** @internal */
31
31
  __publicField(this, "isReconnecting", false);
32
+ /** @internal */
33
+ __publicField(this, "hasActiveAnimationSession", false);
34
+ /** @internal */
35
+ __publicField(this, "isTrackingPrimed", false);
32
36
  this.provider = provider;
33
37
  this.avatarView = avatarView;
34
38
  if ((options == null ? void 0 : options.logLevel) !== void 0) {
@@ -39,6 +43,8 @@ class AvatarPlayer {
39
43
  enableJitterBuffer: options == null ? void 0 : options.enableJitterBuffer,
40
44
  maxBufferDelayMs: options == null ? void 0 : options.maxBufferDelayMs,
41
45
  onStreamStalled: () => {
46
+ this.hasActiveAnimationSession = false;
47
+ this.isTrackingPrimed = false;
42
48
  this.emit("stalled");
43
49
  }
44
50
  });
@@ -315,8 +321,23 @@ class AvatarPlayer {
315
321
  async setupAnimationCallbacks() {
316
322
  const callbacks = {
317
323
  onAnimationData: (protobufData, metadata) => {
318
- if (metadata.isStart) {
319
- this.animationHandler.resetTracking();
324
+ if (!this.hasActiveAnimationSession) {
325
+ if (!this.isTrackingPrimed) {
326
+ this.animationHandler.resetTracking();
327
+ }
328
+ this.hasActiveAnimationSession = true;
329
+ this.isTrackingPrimed = false;
330
+ if (!metadata.isStart) {
331
+ logger.info(
332
+ "AvatarPlayer",
333
+ `Session start inferred from frame seq=${metadata.frameSeq ?? "n/a"}`
334
+ );
335
+ }
336
+ } else if (metadata.isStart) {
337
+ logger.info(
338
+ "AvatarPlayer",
339
+ `Ignoring duplicate session start frame seq=${metadata.frameSeq ?? "n/a"}`
340
+ );
320
341
  }
321
342
  this.animationHandler.handleAnimationData(
322
343
  protobufData,
@@ -335,6 +356,15 @@ class AvatarPlayer {
335
356
  onSessionEnd: () => {
336
357
  },
337
358
  onIdleStart: () => {
359
+ if (this.animationHandler.isInTransition()) {
360
+ this.hasActiveAnimationSession = false;
361
+ this.isTrackingPrimed = false;
362
+ logger.info("AvatarPlayer", "Deferring idleStart while transition is active");
363
+ return;
364
+ }
365
+ this.hasActiveAnimationSession = false;
366
+ this.animationHandler.resetTracking();
367
+ this.isTrackingPrimed = true;
338
368
  this.animationHandler.startIdle();
339
369
  },
340
370
  onStreamStats: (stats) => {
@@ -358,6 +388,8 @@ class AvatarPlayer {
358
388
  });
359
389
  this.provider.on("disconnected", () => {
360
390
  this._isConnected = false;
391
+ this.hasActiveAnimationSession = false;
392
+ this.isTrackingPrimed = false;
361
393
  this.animationHandler.startIdle();
362
394
  });
363
395
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index2.js","sources":["../src/core/AvatarPlayer.ts"],"sourcesContent":["/**\n * AvatarPlayer - Unified entry point for RTC avatar streaming.\n *\n * This class provides a unified API that shields applications from\n * differences between different RTC providers (LiveKit, Agora, etc.)\n * and integrates seamlessly with @spatialwalk/avatarkit for rendering.\n *\n * @packageDocumentation\n */\n\nimport type { RTCProvider } from './RTCProvider';\nimport type { RTCConnectionConfig } from '../types';\nimport { AnimationHandler } from './AnimationHandler';\nimport type { AnimationTrackCallbacks, RTCProviderEvents } from './types';\nimport { configureLogger, logger, type LogLevel } from '../utils';\n\n// Import AvatarView type from avatarkit\nimport type { AvatarView, KeyframeData } from '@spatialwalk/avatarkit';\n\n/**\n * Options for configuring AvatarPlayer.\n */\nexport interface AvatarPlayerOptions {\n /**\n * Log level for SDK output.\n * - 'info': Show all logs (debug mode)\n * - 'warning': Show warnings and errors (default)\n * - 'error': Show only errors\n * - 'none': Disable all logs\n */\n logLevel?: LogLevel;\n\n /**\n * Enable jitter buffer for smoother animation playback.\n * When enabled, frames are buffered and rendered in sequence order at a steady 25fps,\n * absorbing network jitter and out-of-order delivery.\n * Default: true\n */\n enableJitterBuffer?: boolean;\n\n /**\n * Maximum delay (in ms) a frame can sit in the jitter buffer before being rendered.\n * Only used when enableJitterBuffer is true.\n * Default: 80 (2 frames at 25fps)\n */\n maxBufferDelayMs?: number;\n}\n\n/**\n * Unified Avatar Player.\n *\n * Main entry point for applications to interact with RTC avatar streaming.\n * Handles connection management, audio publishing, animation rendering,\n * and coordinates with avatarkit for display.\n *\n * @example\n * ```typescript\n * import { AvatarPlayer, LiveKitProvider } from '@spatialwalk/avatarkit-rtc';\n * import { AvatarView, Avatar } from '@spatialwalk/avatarkit';\n *\n * // Create avatar and view (from avatarkit)\n * const avatar = await Avatar.create(characterId);\n * const avatarView = new AvatarView(avatar, container);\n *\n * // Create player\n * const provider = new LiveKitProvider();\n * const player = new AvatarPlayer(provider, avatarView);\n *\n * // Connect\n * await player.connect({ url: 'wss://...', token: '...' });\n *\n * // Option 1: Use microphone (most common)\n * await player.startPublishing();\n * await player.stopPublishing();\n *\n * // Option 2: Use custom audio source\n * const stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n * await player.publishAudio(stream.getAudioTracks()[0]);\n * await player.unpublishAudio();\n * stream.getTracks().forEach(t => t.stop());\n *\n * // Disconnect\n * await player.disconnect();\n * ```\n */\nexport class AvatarPlayer {\n /** @internal */\n private provider: RTCProvider;\n\n /** @internal */\n private avatarView: AvatarView;\n\n /** @internal */\n private animationHandler: AnimationHandler;\n\n /** @internal */\n private _isConnected = false;\n\n /** @internal */\n private localAudioTrack: MediaStreamTrack | null = null;\n\n /** @internal */\n private microphoneStream: MediaStream | null = null;\n\n /** @internal */\n private eventHandlers: Map<string, Set<Function>> = new Map();\n\n /** @internal */\n private lastConnectionConfig: RTCConnectionConfig | null = null;\n\n /** @internal */\n private isReconnecting = false;\n\n /**\n * Create a new AvatarPlayer instance.\n * @param provider - RTC provider implementation (e.g., LiveKitProvider)\n * @param avatarView - AvatarView instance from @spatialwalk/avatarkit\n * @param options - Optional configuration for transitions\n */\n constructor(\n provider: RTCProvider,\n avatarView: AvatarView,\n options?: AvatarPlayerOptions\n ) {\n this.provider = provider;\n this.avatarView = avatarView;\n\n // Configure logging if specified\n if (options?.logLevel !== undefined) {\n configureLogger({ level: options.logLevel });\n }\n\n // Create internal renderer adapter\n const renderer = this.createRendererAdapter();\n\n this.animationHandler = new AnimationHandler(renderer, {\n enableJitterBuffer: options?.enableJitterBuffer,\n maxBufferDelayMs: options?.maxBufferDelayMs,\n onStreamStalled: () => {\n // AnimationHandler already switches to idle state\n // Just notify external listeners, let them decide what to do (e.g., call reconnect())\n this.emit('stalled');\n },\n });\n\n // Setup provider event handlers\n this.setupProviderEvents();\n }\n\n /**\n * Check if currently connected to RTC server.\n */\n get isConnected(): boolean {\n return this._isConnected;\n }\n\n /**\n * Connect to RTC server.\n * @param config - Connection configuration (URL, token, room name, etc.)\n * @throws Error if already connected or connection fails\n */\n async connect(config: RTCConnectionConfig): Promise<void> {\n if (this._isConnected) {\n throw new Error('Already connected. Please disconnect first.');\n }\n\n // Save config for potential reconnection\n this.lastConnectionConfig = config;\n\n // Setup animation callbacks before connecting\n await this.setupAnimationCallbacks();\n await this.provider.connect(config);\n this._isConnected = true;\n }\n\n /**\n * Disconnect from RTC server.\n * Stops all tracks and cleans up resources.\n */\n async disconnect(): Promise<void> {\n if (!this._isConnected) {\n return;\n }\n\n // Stop microphone if active\n if (this.microphoneStream) {\n await this.stopPublishing();\n } else if (this.localAudioTrack) {\n // Stop custom audio track\n await this.unpublishAudio();\n }\n\n await this.provider.disconnect();\n this.animationHandler.dispose();\n this._isConnected = false;\n }\n\n /**\n * Publish an audio track to the RTC server.\n * \n * The audio source is controlled externally - you can pass any audio track:\n * - Microphone: `getUserMedia({ audio: true })`\n * - Browser audio: `audioElement.captureStream()`\n * - Web Audio API: `audioContext.createMediaStreamDestination().stream`\n * - Screen share audio: `getDisplayMedia({ audio: true })`\n * \n * @param track - MediaStreamTrack to publish (must be an audio track)\n * @throws Error if not connected or track is invalid\n * \n * @example\n * ```typescript\n * // Microphone\n * const stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n * await player.publishAudio(stream.getAudioTracks()[0]);\n * \n * // Browser audio element\n * const audioEl = document.querySelector('audio');\n * const stream = audioEl.captureStream();\n * await player.publishAudio(stream.getAudioTracks()[0]);\n * ```\n */\n async publishAudio(track: MediaStreamTrack): Promise<void> {\n if (!this._isConnected) {\n throw new Error('Not connected. Please call connect() first.');\n }\n\n if (track.kind !== 'audio') {\n throw new Error('Invalid track: expected audio track');\n }\n\n // Unpublish existing track if any\n if (this.localAudioTrack) {\n await this.unpublishAudio();\n }\n\n this.localAudioTrack = track;\n await this.provider.publishAudioTrack(track);\n }\n\n /**\n * Stop publishing audio.\n * Note: This does NOT stop the track - the caller is responsible for managing the track lifecycle.\n */\n async unpublishAudio(): Promise<void> {\n if (!this.localAudioTrack) {\n return;\n }\n\n await this.provider.unpublishAudioTrack();\n this.localAudioTrack = null;\n }\n\n /**\n * Start publishing microphone audio.\n * \n * This requests microphone permission and publishes it to the RTC session.\n * For custom audio sources (e.g., audio files), use `publishAudio(track)` instead.\n * \n * @throws Error if not connected, permission denied, or no microphone found\n * \n * @example\n * ```typescript\n * await player.startPublishing();\n * // ... later\n * await player.stopPublishing();\n * ```\n */\n async startPublishing(): Promise<void> {\n if (!this._isConnected) {\n throw new Error('Not connected. Please call connect() first.');\n }\n\n if (this.microphoneStream) {\n return; // Already using microphone\n }\n\n // Request microphone permission\n this.microphoneStream = await navigator.mediaDevices.getUserMedia({ audio: true });\n const track = this.microphoneStream.getAudioTracks()[0];\n \n await this.publishAudio(track);\n }\n\n /**\n * Stop publishing microphone audio and release the microphone.\n * \n * This stops the microphone track and releases the device.\n * If you used `publishAudio()` with a custom track, use `unpublishAudio()` instead.\n */\n async stopPublishing(): Promise<void> {\n if (!this.microphoneStream) {\n return;\n }\n\n await this.unpublishAudio();\n\n // Stop and release microphone\n this.microphoneStream.getTracks().forEach((track) => track.stop());\n this.microphoneStream = null;\n }\n\n /**\n * Get current connection state.\n * @returns Connection state string (e.g., 'connected', 'disconnected')\n */\n getConnectionState(): string {\n return this.provider.getConnectionState();\n }\n\n /**\n * Get the native RTC client object.\n * \n * Returns the underlying RTC client for advanced use cases:\n * - LiveKit: Returns the Room instance\n * - Agora: Returns the IAgoraRTCClient instance\n * \n * @returns The native RTC client object, or null if not connected\n * \n * @example\n * ```typescript\n * // Access LiveKit Room\n * const room = player.getNativeClient() as Room;\n * console.log('Participants:', room?.remoteParticipants.size);\n * \n * // Access Agora Client\n * const client = player.getNativeClient() as IAgoraRTCClient;\n * console.log('State:', client?.connectionState);\n * ```\n */\n getNativeClient(): unknown {\n return this.provider.getNativeClient();\n }\n\n /**\n * Add event listener.\n * @param event - Event name ('connected', 'disconnected', 'error', 'stalled', etc.)\n * @param handler - Event handler function\n */\n on(event: string, handler: Function): void {\n if (event === 'stalled') {\n // Handle stalled event locally\n if (!this.eventHandlers.has(event)) {\n this.eventHandlers.set(event, new Set());\n }\n this.eventHandlers.get(event)!.add(handler);\n } else {\n // Forward other events to provider\n this.provider.on(event as any, handler as any);\n }\n }\n\n /**\n * Remove event listener.\n * @param event - Event name\n * @param handler - Event handler function (must be same reference as added)\n */\n off(event: string, handler: Function): void {\n if (event === 'stalled') {\n this.eventHandlers.get(event)?.delete(handler);\n } else {\n this.provider.off(event as any, handler as any);\n }\n }\n\n /**\n * Emit an event to local listeners.\n * @internal\n */\n private emit(event: string, ...args: unknown[]): void {\n const handlers = this.eventHandlers.get(event);\n if (handlers) {\n handlers.forEach((handler) => {\n try {\n handler(...args);\n } catch (e) {\n logger.error('AvatarPlayer', `Error in ${event} handler:`, e);\n }\n });\n }\n }\n\n /**\n * Reconnect to RTC server using the last connection configuration.\n * Useful for recovering from connection issues or stream stalls.\n *\n * @example\n * ```typescript\n * player.on('stalled', async () => {\n * console.log('Stream stalled, attempting reconnection...');\n * await player.reconnect();\n * });\n * ```\n *\n * @throws Error if no previous connection config exists or reconnection fails\n */\n async reconnect(): Promise<void> {\n if (this.isReconnecting) {\n logger.warn('AvatarPlayer', 'Already attempting reconnection, skipping');\n return;\n }\n\n if (!this.lastConnectionConfig) {\n throw new Error('Cannot reconnect: no previous connection. Call connect() first.');\n }\n\n this.isReconnecting = true;\n logger.info('AvatarPlayer', 'Attempting reconnection...');\n\n try {\n // Disconnect first if connected\n if (this._isConnected) {\n await this.disconnect();\n }\n\n // Wait a short moment before reconnecting\n await new Promise((resolve) => setTimeout(resolve, 500));\n\n // Reconnect\n await this.connect(this.lastConnectionConfig);\n logger.info('AvatarPlayer', 'Reconnection successful');\n } catch (error) {\n logger.error('AvatarPlayer', 'Reconnection failed:', error);\n throw error;\n } finally {\n this.isReconnecting = false;\n }\n }\n\n /**\n * Create internal renderer adapter that bridges AnimationHandler to AvatarView.\n * @internal\n */\n private createRendererAdapter() {\n const avatarView = this.avatarView;\n\n return {\n renderFrame(flame: KeyframeData | undefined, startIdle?: boolean): void {\n if (startIdle) {\n // Enable idle rendering mode\n avatarView.renderFlame(undefined as unknown as KeyframeData, true);\n } else if (flame) {\n // Render the animation frame\n avatarView.renderFlame(flame);\n }\n },\n\n async generateTransitionFromIdle(\n targetFrame: KeyframeData,\n frameCount: number\n ): Promise<KeyframeData[]> {\n // Use avatarkit's generateTransition with from=undefined (current idle frame)\n return avatarView.generateTransition({\n to: targetFrame,\n frameCount,\n useLinear: true, // Linear interpolation for idle->speaking\n });\n },\n\n isReady(): boolean {\n return true; // AvatarView is always ready once created\n },\n };\n }\n\n /**\n * Setup animation track callbacks.\n * @internal\n */\n private async setupAnimationCallbacks(): Promise<void> {\n const callbacks: AnimationTrackCallbacks = {\n onAnimationData: (protobufData, metadata) => {\n // Reset tracking on session start\n if (metadata.isStart) {\n this.animationHandler.resetTracking();\n }\n\n this.animationHandler.handleAnimationData(\n protobufData,\n metadata.frameSeq,\n metadata.isRecovered\n );\n },\n onTransition: (protobufData, transitionFrameCount) => {\n this.animationHandler.handleTransitionData(protobufData, transitionFrameCount);\n },\n onTransitionEnd: (protobufData, transitionFrameCount) => {\n this.animationHandler.handleTransitionToIdle(protobufData, transitionFrameCount);\n },\n onSessionStart: () => {\n // Session started - can be used for UI feedback\n },\n onSessionEnd: () => {\n // Session ended - can be used for UI feedback\n },\n onIdleStart: () => {\n this.animationHandler.startIdle();\n },\n onStreamStats: (stats) => {\n // Log packet loss/recovery stats\n if (stats.framesLost > 0 || stats.framesRecovered > 0 || stats.framesDropped > 0) {\n logger.warn(\n 'AvatarPlayer',\n `Stream stats: lost=${stats.framesLost}, recovered=${stats.framesRecovered}, dropped=${stats.framesDropped}, fps=${stats.framesPerSec}`\n );\n }\n },\n };\n\n await this.provider.subscribeAnimationTrack(callbacks);\n }\n\n /**\n * Setup provider event handlers.\n * @internal\n */\n private setupProviderEvents(): void {\n this.provider.on('connected', () => {\n this._isConnected = true;\n });\n\n this.provider.on('disconnected', () => {\n this._isConnected = false;\n // Return to idle animation when disconnected\n this.animationHandler.startIdle();\n });\n }\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index2.js","sources":["../src/core/AvatarPlayer.ts"],"sourcesContent":["/**\n * AvatarPlayer - Unified entry point for RTC avatar streaming.\n *\n * This class provides a unified API that shields applications from\n * differences between different RTC providers (LiveKit, Agora, etc.)\n * and integrates seamlessly with @spatialwalk/avatarkit for rendering.\n *\n * @packageDocumentation\n */\n\nimport type { RTCProvider } from './RTCProvider';\nimport type { RTCConnectionConfig } from '../types';\nimport { AnimationHandler } from './AnimationHandler';\nimport type { AnimationTrackCallbacks, RTCProviderEvents } from './types';\nimport { configureLogger, logger, type LogLevel } from '../utils';\n\n// Import AvatarView type from avatarkit\nimport type { AvatarView, KeyframeData } from '@spatialwalk/avatarkit';\n\n/**\n * Options for configuring AvatarPlayer.\n */\nexport interface AvatarPlayerOptions {\n /**\n * Log level for SDK output.\n * - 'info': Show all logs (debug mode)\n * - 'warning': Show warnings and errors (default)\n * - 'error': Show only errors\n * - 'none': Disable all logs\n */\n logLevel?: LogLevel;\n\n /**\n * Enable jitter buffer for smoother animation playback.\n * When enabled, frames are buffered and rendered in sequence order at a steady 25fps,\n * absorbing network jitter and out-of-order delivery.\n * Default: true\n */\n enableJitterBuffer?: boolean;\n\n /**\n * Maximum delay (in ms) a frame can sit in the jitter buffer before being rendered.\n * Only used when enableJitterBuffer is true.\n * Default: 80 (2 frames at 25fps)\n */\n maxBufferDelayMs?: number;\n}\n\n/**\n * Unified Avatar Player.\n *\n * Main entry point for applications to interact with RTC avatar streaming.\n * Handles connection management, audio publishing, animation rendering,\n * and coordinates with avatarkit for display.\n *\n * @example\n * ```typescript\n * import { AvatarPlayer, LiveKitProvider } from '@spatialwalk/avatarkit-rtc';\n * import { AvatarView, Avatar } from '@spatialwalk/avatarkit';\n *\n * // Create avatar and view (from avatarkit)\n * const avatar = await Avatar.create(characterId);\n * const avatarView = new AvatarView(avatar, container);\n *\n * // Create player\n * const provider = new LiveKitProvider();\n * const player = new AvatarPlayer(provider, avatarView);\n *\n * // Connect\n * await player.connect({ url: 'wss://...', token: '...' });\n *\n * // Option 1: Use microphone (most common)\n * await player.startPublishing();\n * await player.stopPublishing();\n *\n * // Option 2: Use custom audio source\n * const stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n * await player.publishAudio(stream.getAudioTracks()[0]);\n * await player.unpublishAudio();\n * stream.getTracks().forEach(t => t.stop());\n *\n * // Disconnect\n * await player.disconnect();\n * ```\n */\nexport class AvatarPlayer {\n /** @internal */\n private provider: RTCProvider;\n\n /** @internal */\n private avatarView: AvatarView;\n\n /** @internal */\n private animationHandler: AnimationHandler;\n\n /** @internal */\n private _isConnected = false;\n\n /** @internal */\n private localAudioTrack: MediaStreamTrack | null = null;\n\n /** @internal */\n private microphoneStream: MediaStream | null = null;\n\n /** @internal */\n private eventHandlers: Map<string, Set<Function>> = new Map();\n\n /** @internal */\n private lastConnectionConfig: RTCConnectionConfig | null = null;\n\n /** @internal */\n private isReconnecting = false;\n\n /** @internal */\n private hasActiveAnimationSession = false;\n\n /** @internal */\n private isTrackingPrimed = false;\n\n /**\n * Create a new AvatarPlayer instance.\n * @param provider - RTC provider implementation (e.g., LiveKitProvider)\n * @param avatarView - AvatarView instance from @spatialwalk/avatarkit\n * @param options - Optional configuration for transitions\n */\n constructor(\n provider: RTCProvider,\n avatarView: AvatarView,\n options?: AvatarPlayerOptions\n ) {\n this.provider = provider;\n this.avatarView = avatarView;\n\n // Configure logging if specified\n if (options?.logLevel !== undefined) {\n configureLogger({ level: options.logLevel });\n }\n\n // Create internal renderer adapter\n const renderer = this.createRendererAdapter();\n\n this.animationHandler = new AnimationHandler(renderer, {\n enableJitterBuffer: options?.enableJitterBuffer,\n maxBufferDelayMs: options?.maxBufferDelayMs,\n onStreamStalled: () => {\n // AnimationHandler already switches to idle state\n // Just notify external listeners, let them decide what to do (e.g., call reconnect())\n this.hasActiveAnimationSession = false;\n this.isTrackingPrimed = false;\n this.emit('stalled');\n },\n });\n\n // Setup provider event handlers\n this.setupProviderEvents();\n }\n\n /**\n * Check if currently connected to RTC server.\n */\n get isConnected(): boolean {\n return this._isConnected;\n }\n\n /**\n * Connect to RTC server.\n * @param config - Connection configuration (URL, token, room name, etc.)\n * @throws Error if already connected or connection fails\n */\n async connect(config: RTCConnectionConfig): Promise<void> {\n if (this._isConnected) {\n throw new Error('Already connected. Please disconnect first.');\n }\n\n // Save config for potential reconnection\n this.lastConnectionConfig = config;\n\n // Setup animation callbacks before connecting\n await this.setupAnimationCallbacks();\n await this.provider.connect(config);\n this._isConnected = true;\n }\n\n /**\n * Disconnect from RTC server.\n * Stops all tracks and cleans up resources.\n */\n async disconnect(): Promise<void> {\n if (!this._isConnected) {\n return;\n }\n\n // Stop microphone if active\n if (this.microphoneStream) {\n await this.stopPublishing();\n } else if (this.localAudioTrack) {\n // Stop custom audio track\n await this.unpublishAudio();\n }\n\n await this.provider.disconnect();\n this.animationHandler.dispose();\n this._isConnected = false;\n }\n\n /**\n * Publish an audio track to the RTC server.\n * \n * The audio source is controlled externally - you can pass any audio track:\n * - Microphone: `getUserMedia({ audio: true })`\n * - Browser audio: `audioElement.captureStream()`\n * - Web Audio API: `audioContext.createMediaStreamDestination().stream`\n * - Screen share audio: `getDisplayMedia({ audio: true })`\n * \n * @param track - MediaStreamTrack to publish (must be an audio track)\n * @throws Error if not connected or track is invalid\n * \n * @example\n * ```typescript\n * // Microphone\n * const stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n * await player.publishAudio(stream.getAudioTracks()[0]);\n * \n * // Browser audio element\n * const audioEl = document.querySelector('audio');\n * const stream = audioEl.captureStream();\n * await player.publishAudio(stream.getAudioTracks()[0]);\n * ```\n */\n async publishAudio(track: MediaStreamTrack): Promise<void> {\n if (!this._isConnected) {\n throw new Error('Not connected. Please call connect() first.');\n }\n\n if (track.kind !== 'audio') {\n throw new Error('Invalid track: expected audio track');\n }\n\n // Unpublish existing track if any\n if (this.localAudioTrack) {\n await this.unpublishAudio();\n }\n\n this.localAudioTrack = track;\n await this.provider.publishAudioTrack(track);\n }\n\n /**\n * Stop publishing audio.\n * Note: This does NOT stop the track - the caller is responsible for managing the track lifecycle.\n */\n async unpublishAudio(): Promise<void> {\n if (!this.localAudioTrack) {\n return;\n }\n\n await this.provider.unpublishAudioTrack();\n this.localAudioTrack = null;\n }\n\n /**\n * Start publishing microphone audio.\n * \n * This requests microphone permission and publishes it to the RTC session.\n * For custom audio sources (e.g., audio files), use `publishAudio(track)` instead.\n * \n * @throws Error if not connected, permission denied, or no microphone found\n * \n * @example\n * ```typescript\n * await player.startPublishing();\n * // ... later\n * await player.stopPublishing();\n * ```\n */\n async startPublishing(): Promise<void> {\n if (!this._isConnected) {\n throw new Error('Not connected. Please call connect() first.');\n }\n\n if (this.microphoneStream) {\n return; // Already using microphone\n }\n\n // Request microphone permission\n this.microphoneStream = await navigator.mediaDevices.getUserMedia({ audio: true });\n const track = this.microphoneStream.getAudioTracks()[0];\n \n await this.publishAudio(track);\n }\n\n /**\n * Stop publishing microphone audio and release the microphone.\n * \n * This stops the microphone track and releases the device.\n * If you used `publishAudio()` with a custom track, use `unpublishAudio()` instead.\n */\n async stopPublishing(): Promise<void> {\n if (!this.microphoneStream) {\n return;\n }\n\n await this.unpublishAudio();\n\n // Stop and release microphone\n this.microphoneStream.getTracks().forEach((track) => track.stop());\n this.microphoneStream = null;\n }\n\n /**\n * Get current connection state.\n * @returns Connection state string (e.g., 'connected', 'disconnected')\n */\n getConnectionState(): string {\n return this.provider.getConnectionState();\n }\n\n /**\n * Get the native RTC client object.\n * \n * Returns the underlying RTC client for advanced use cases:\n * - LiveKit: Returns the Room instance\n * - Agora: Returns the IAgoraRTCClient instance\n * \n * @returns The native RTC client object, or null if not connected\n * \n * @example\n * ```typescript\n * // Access LiveKit Room\n * const room = player.getNativeClient() as Room;\n * console.log('Participants:', room?.remoteParticipants.size);\n * \n * // Access Agora Client\n * const client = player.getNativeClient() as IAgoraRTCClient;\n * console.log('State:', client?.connectionState);\n * ```\n */\n getNativeClient(): unknown {\n return this.provider.getNativeClient();\n }\n\n /**\n * Add event listener.\n * @param event - Event name ('connected', 'disconnected', 'error', 'stalled', etc.)\n * @param handler - Event handler function\n */\n on(event: string, handler: Function): void {\n if (event === 'stalled') {\n // Handle stalled event locally\n if (!this.eventHandlers.has(event)) {\n this.eventHandlers.set(event, new Set());\n }\n this.eventHandlers.get(event)!.add(handler);\n } else {\n // Forward other events to provider\n this.provider.on(event as any, handler as any);\n }\n }\n\n /**\n * Remove event listener.\n * @param event - Event name\n * @param handler - Event handler function (must be same reference as added)\n */\n off(event: string, handler: Function): void {\n if (event === 'stalled') {\n this.eventHandlers.get(event)?.delete(handler);\n } else {\n this.provider.off(event as any, handler as any);\n }\n }\n\n /**\n * Emit an event to local listeners.\n * @internal\n */\n private emit(event: string, ...args: unknown[]): void {\n const handlers = this.eventHandlers.get(event);\n if (handlers) {\n handlers.forEach((handler) => {\n try {\n handler(...args);\n } catch (e) {\n logger.error('AvatarPlayer', `Error in ${event} handler:`, e);\n }\n });\n }\n }\n\n /**\n * Reconnect to RTC server using the last connection configuration.\n * Useful for recovering from connection issues or stream stalls.\n *\n * @example\n * ```typescript\n * player.on('stalled', async () => {\n * console.log('Stream stalled, attempting reconnection...');\n * await player.reconnect();\n * });\n * ```\n *\n * @throws Error if no previous connection config exists or reconnection fails\n */\n async reconnect(): Promise<void> {\n if (this.isReconnecting) {\n logger.warn('AvatarPlayer', 'Already attempting reconnection, skipping');\n return;\n }\n\n if (!this.lastConnectionConfig) {\n throw new Error('Cannot reconnect: no previous connection. Call connect() first.');\n }\n\n this.isReconnecting = true;\n logger.info('AvatarPlayer', 'Attempting reconnection...');\n\n try {\n // Disconnect first if connected\n if (this._isConnected) {\n await this.disconnect();\n }\n\n // Wait a short moment before reconnecting\n await new Promise((resolve) => setTimeout(resolve, 500));\n\n // Reconnect\n await this.connect(this.lastConnectionConfig);\n logger.info('AvatarPlayer', 'Reconnection successful');\n } catch (error) {\n logger.error('AvatarPlayer', 'Reconnection failed:', error);\n throw error;\n } finally {\n this.isReconnecting = false;\n }\n }\n\n /**\n * Create internal renderer adapter that bridges AnimationHandler to AvatarView.\n * @internal\n */\n private createRendererAdapter() {\n const avatarView = this.avatarView;\n\n return {\n renderFrame(flame: KeyframeData | undefined, startIdle?: boolean): void {\n if (startIdle) {\n // Enable idle rendering mode\n avatarView.renderFlame(undefined as unknown as KeyframeData, true);\n } else if (flame) {\n // Render the animation frame\n avatarView.renderFlame(flame);\n }\n },\n\n async generateTransitionFromIdle(\n targetFrame: KeyframeData,\n frameCount: number\n ): Promise<KeyframeData[]> {\n // Use avatarkit's generateTransition with from=undefined (current idle frame)\n return avatarView.generateTransition({\n to: targetFrame,\n frameCount,\n useLinear: true, // Linear interpolation for idle->speaking\n });\n },\n\n isReady(): boolean {\n return true; // AvatarView is always ready once created\n },\n };\n }\n\n /**\n * Setup animation track callbacks.\n * @internal\n */\n private async setupAnimationCallbacks(): Promise<void> {\n const callbacks: AnimationTrackCallbacks = {\n onAnimationData: (protobufData, metadata) => {\n // Reset tracking only once per session to avoid duplicate start-packet resets.\n if (!this.hasActiveAnimationSession) {\n if (!this.isTrackingPrimed) {\n this.animationHandler.resetTracking();\n }\n\n this.hasActiveAnimationSession = true;\n this.isTrackingPrimed = false;\n\n if (!metadata.isStart) {\n logger.info(\n 'AvatarPlayer',\n `Session start inferred from frame seq=${metadata.frameSeq ?? 'n/a'}`\n );\n }\n } else if (metadata.isStart) {\n logger.info(\n 'AvatarPlayer',\n `Ignoring duplicate session start frame seq=${metadata.frameSeq ?? 'n/a'}`\n );\n }\n\n this.animationHandler.handleAnimationData(\n protobufData,\n metadata.frameSeq,\n metadata.isRecovered\n );\n },\n onTransition: (protobufData, transitionFrameCount) => {\n this.animationHandler.handleTransitionData(protobufData, transitionFrameCount);\n },\n onTransitionEnd: (protobufData, transitionFrameCount) => {\n this.animationHandler.handleTransitionToIdle(protobufData, transitionFrameCount);\n },\n onSessionStart: () => {\n // Session started - can be used for UI feedback\n },\n onSessionEnd: () => {\n // Session ended - can be used for UI feedback\n },\n onIdleStart: () => {\n // Idle packets can race with start-transition packets; avoid rendering idle mid-transition.\n if (this.animationHandler.isInTransition()) {\n // Keep session bookkeeping in sync even if visual idle is deferred.\n this.hasActiveAnimationSession = false;\n this.isTrackingPrimed = false;\n logger.info('AvatarPlayer', 'Deferring idleStart while transition is active');\n return;\n }\n\n this.hasActiveAnimationSession = false;\n this.animationHandler.resetTracking();\n this.isTrackingPrimed = true;\n this.animationHandler.startIdle();\n },\n onStreamStats: (stats) => {\n // Log packet loss/recovery stats\n if (stats.framesLost > 0 || stats.framesRecovered > 0 || stats.framesDropped > 0) {\n logger.warn(\n 'AvatarPlayer',\n `Stream stats: lost=${stats.framesLost}, recovered=${stats.framesRecovered}, dropped=${stats.framesDropped}, fps=${stats.framesPerSec}`\n );\n }\n },\n };\n\n await this.provider.subscribeAnimationTrack(callbacks);\n }\n\n /**\n * Setup provider event handlers.\n * @internal\n */\n private setupProviderEvents(): void {\n this.provider.on('connected', () => {\n this._isConnected = true;\n });\n\n this.provider.on('disconnected', () => {\n this._isConnected = false;\n this.hasActiveAnimationSession = false;\n this.isTrackingPrimed = false;\n // Return to idle animation when disconnected\n this.animationHandler.startIdle();\n });\n }\n}\n"],"names":[],"mappings":";;;;;AAqFO,MAAM,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAwCxB,YACE,UACA,YACA,SACA;AA1CM;AAAA;AAGA;AAAA;AAGA;AAAA;AAGA;AAAA,wCAAe;AAGf;AAAA,2CAA2C;AAG3C;AAAA,4CAAuC;AAGvC;AAAA,6DAAgD,IAAA;AAGhD;AAAA,gDAAmD;AAGnD;AAAA,0CAAiB;AAGjB;AAAA,qDAA4B;AAG5B;AAAA,4CAAmB;AAazB,SAAK,WAAW;AAChB,SAAK,aAAa;AAGlB,SAAI,mCAAS,cAAa,QAAW;AACnC,sBAAgB,EAAE,OAAO,QAAQ,SAAA,CAAU;AAAA,IAC7C;AAGA,UAAM,WAAW,KAAK,sBAAA;AAEtB,SAAK,mBAAmB,IAAI,iBAAiB,UAAU;AAAA,MACrD,oBAAoB,mCAAS;AAAA,MAC7B,kBAAkB,mCAAS;AAAA,MAC3B,iBAAiB,MAAM;AAGrB,aAAK,4BAA4B;AACjC,aAAK,mBAAmB;AACxB,aAAK,KAAK,SAAS;AAAA,MACrB;AAAA,IAAA,CACD;AAGD,SAAK,oBAAA;AAAA,EACP;AAAA;AAAA;AAAA;AAAA,EAKA,IAAI,cAAuB;AACzB,WAAO,KAAK;AAAA,EACd;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,QAAQ,QAA4C;AACxD,QAAI,KAAK,cAAc;AACrB,YAAM,IAAI,MAAM,6CAA6C;AAAA,IAC/D;AAGA,SAAK,uBAAuB;AAG5B,UAAM,KAAK,wBAAA;AACX,UAAM,KAAK,SAAS,QAAQ,MAAM;AAClC,SAAK,eAAe;AAAA,EACtB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,aAA4B;AAChC,QAAI,CAAC,KAAK,cAAc;AACtB;AAAA,IACF;AAGA,QAAI,KAAK,kBAAkB;AACzB,YAAM,KAAK,eAAA;AAAA,IACb,WAAW,KAAK,iBAAiB;AAE/B,YAAM,KAAK,eAAA;AAAA,IACb;AAEA,UAAM,KAAK,SAAS,WAAA;AACpB,SAAK,iBAAiB,QAAA;AACtB,SAAK,eAAe;AAAA,EACtB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EA0BA,MAAM,aAAa,OAAwC;AACzD,QAAI,CAAC,KAAK,cAAc;AACtB,YAAM,IAAI,MAAM,6CAA6C;AAAA,IAC/D;AAEA,QAAI,MAAM,SAAS,SAAS;AAC1B,YAAM,IAAI,MAAM,qCAAqC;AAAA,IACvD;AAGA,QAAI,KAAK,iBAAiB;AACxB,YAAM,KAAK,eAAA;AAAA,IACb;AAEA,SAAK,kBAAkB;AACvB,UAAM,KAAK,SAAS,kBAAkB,KAAK;AAAA,EAC7C;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,iBAAgC;AACpC,QAAI,CAAC,KAAK,iBAAiB;AACzB;AAAA,IACF;AAEA,UAAM,KAAK,SAAS,oBAAA;AACpB,SAAK,kBAAkB;AAAA,EACzB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAiBA,MAAM,kBAAiC;AACrC,QAAI,CAAC,KAAK,cAAc;AACtB,YAAM,IAAI,MAAM,6CAA6C;AAAA,IAC/D;AAEA,QAAI,KAAK,kBAAkB;AACzB;AAAA,IACF;AAGA,SAAK,mBAAmB,MAAM,UAAU,aAAa,aAAa,EAAE,OAAO,MAAM;AACjF,UAAM,QAAQ,KAAK,iBAAiB,eAAA,EAAiB,CAAC;AAEtD,UAAM,KAAK,aAAa,KAAK;AAAA,EAC/B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,iBAAgC;AACpC,QAAI,CAAC,KAAK,kBAAkB;AAC1B;AAAA,IACF;AAEA,UAAM,KAAK,eAAA;AAGX,SAAK,iBAAiB,YAAY,QAAQ,CAAC,UAAU,MAAM,MAAM;AACjE,SAAK,mBAAmB;AAAA,EAC1B;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,qBAA6B;AAC3B,WAAO,KAAK,SAAS,mBAAA;AAAA,EACvB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAsBA,kBAA2B;AACzB,WAAO,KAAK,SAAS,gBAAA;AAAA,EACvB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,GAAG,OAAe,SAAyB;AACzC,QAAI,UAAU,WAAW;AAEvB,UAAI,CAAC,KAAK,cAAc,IAAI,KAAK,GAAG;AAClC,aAAK,cAAc,IAAI,OAAO,oBAAI,KAAK;AAAA,MACzC;AACA,WAAK,cAAc,IAAI,KAAK,EAAG,IAAI,OAAO;AAAA,IAC5C,OAAO;AAEL,WAAK,SAAS,GAAG,OAAc,OAAc;AAAA,IAC/C;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,IAAI,OAAe,SAAyB;;AAC1C,QAAI,UAAU,WAAW;AACvB,iBAAK,cAAc,IAAI,KAAK,MAA5B,mBAA+B,OAAO;AAAA,IACxC,OAAO;AACL,WAAK,SAAS,IAAI,OAAc,OAAc;AAAA,IAChD;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMQ,KAAK,UAAkB,MAAuB;AACpD,UAAM,WAAW,KAAK,cAAc,IAAI,KAAK;AAC7C,QAAI,UAAU;AACZ,eAAS,QAAQ,CAAC,YAAY;AAC5B,YAAI;AACF,kBAAQ,GAAG,IAAI;AAAA,QACjB,SAAS,GAAG;AACV,iBAAO,MAAM,gBAAgB,YAAY,KAAK,aAAa,CAAC;AAAA,QAC9D;AAAA,MACF,CAAC;AAAA,IACH;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAgBA,MAAM,YAA2B;AAC/B,QAAI,KAAK,gBAAgB;AACvB,aAAO,KAAK,gBAAgB,2CAA2C;AACvE;AAAA,IACF;AAEA,QAAI,CAAC,KAAK,sBAAsB;AAC9B,YAAM,IAAI,MAAM,iEAAiE;AAAA,IACnF;AAEA,SAAK,iBAAiB;AACtB,WAAO,KAAK,gBAAgB,4BAA4B;AAExD,QAAI;AAEF,UAAI,KAAK,cAAc;AACrB,cAAM,KAAK,WAAA;AAAA,MACb;AAGA,YAAM,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,GAAG,CAAC;AAGvD,YAAM,KAAK,QAAQ,KAAK,oBAAoB;AAC5C,aAAO,KAAK,gBAAgB,yBAAyB;AAAA,IACvD,SAAS,OAAO;AACd,aAAO,MAAM,gBAAgB,wBAAwB,KAAK;AAC1D,YAAM;AAAA,IACR,UAAA;AACE,WAAK,iBAAiB;AAAA,IACxB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMQ,wBAAwB;AAC9B,UAAM,aAAa,KAAK;AAExB,WAAO;AAAA,MACL,YAAY,OAAiC,WAA2B;AACtE,YAAI,WAAW;AAEb,qBAAW,YAAY,QAAsC,IAAI;AAAA,QACnE,WAAW,OAAO;AAEhB,qBAAW,YAAY,KAAK;AAAA,QAC9B;AAAA,MACF;AAAA,MAEA,MAAM,2BACJ,aACA,YACyB;AAEzB,eAAO,WAAW,mBAAmB;AAAA,UACnC,IAAI;AAAA,UACJ;AAAA,UACA,WAAW;AAAA;AAAA,QAAA,CACZ;AAAA,MACH;AAAA,MAEA,UAAmB;AACjB,eAAO;AAAA,MACT;AAAA,IAAA;AAAA,EAEJ;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAc,0BAAyC;AACrD,UAAM,YAAqC;AAAA,MACzC,iBAAiB,CAAC,cAAc,aAAa;AAE3C,YAAI,CAAC,KAAK,2BAA2B;AACnC,cAAI,CAAC,KAAK,kBAAkB;AAC1B,iBAAK,iBAAiB,cAAA;AAAA,UACxB;AAEA,eAAK,4BAA4B;AACjC,eAAK,mBAAmB;AAExB,cAAI,CAAC,SAAS,SAAS;AACrB,mBAAO;AAAA,cACL;AAAA,cACA,yCAAyC,SAAS,YAAY,KAAK;AAAA,YAAA;AAAA,UAEvE;AAAA,QACF,WAAW,SAAS,SAAS;AAC3B,iBAAO;AAAA,YACL;AAAA,YACA,8CAA8C,SAAS,YAAY,KAAK;AAAA,UAAA;AAAA,QAE5E;AAEA,aAAK,iBAAiB;AAAA,UACpB;AAAA,UACA,SAAS;AAAA,UACT,SAAS;AAAA,QAAA;AAAA,MAEb;AAAA,MACA,cAAc,CAAC,cAAc,yBAAyB;AACpD,aAAK,iBAAiB,qBAAqB,cAAc,oBAAoB;AAAA,MAC/E;AAAA,MACA,iBAAiB,CAAC,cAAc,yBAAyB;AACvD,aAAK,iBAAiB,uBAAuB,cAAc,oBAAoB;AAAA,MACjF;AAAA,MACA,gBAAgB,MAAM;AAAA,MAEtB;AAAA,MACA,cAAc,MAAM;AAAA,MAEpB;AAAA,MACA,aAAa,MAAM;AAEjB,YAAI,KAAK,iBAAiB,kBAAkB;AAE1C,eAAK,4BAA4B;AACjC,eAAK,mBAAmB;AACxB,iBAAO,KAAK,gBAAgB,gDAAgD;AAC5E;AAAA,QACF;AAEA,aAAK,4BAA4B;AACjC,aAAK,iBAAiB,cAAA;AACtB,aAAK,mBAAmB;AACxB,aAAK,iBAAiB,UAAA;AAAA,MACxB;AAAA,MACA,eAAe,CAAC,UAAU;AAExB,YAAI,MAAM,aAAa,KAAK,MAAM,kBAAkB,KAAK,MAAM,gBAAgB,GAAG;AAChF,iBAAO;AAAA,YACL;AAAA,YACA,sBAAsB,MAAM,UAAU,eAAe,MAAM,eAAe,aAAa,MAAM,aAAa,SAAS,MAAM,YAAY;AAAA,UAAA;AAAA,QAEzI;AAAA,MACF;AAAA,IAAA;AAGF,UAAM,KAAK,SAAS,wBAAwB,SAAS;AAAA,EACvD;AAAA;AAAA;AAAA;AAAA;AAAA,EAMQ,sBAA4B;AAClC,SAAK,SAAS,GAAG,aAAa,MAAM;AAClC,WAAK,eAAe;AAAA,IACtB,CAAC;AAED,SAAK,SAAS,GAAG,gBAAgB,MAAM;AACrC,WAAK,eAAe;AACpB,WAAK,4BAA4B;AACjC,WAAK,mBAAmB;AAExB,WAAK,iBAAiB,UAAA;AAAA,IACxB,CAAC;AAAA,EACH;AACF;"}
package/dist/index3.js CHANGED
@@ -3,8 +3,8 @@ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { en
3
3
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
4
  import { BaseProvider } from "./index10.js";
5
5
  import { isLiveKitConfig } from "./index5.js";
6
- import { VP8Extractor } from "./index12.js";
7
- import { getInsertableStreamsMethod } from "./index13.js";
6
+ import { VP8Extractor } from "./index11.js";
7
+ import { getInsertableStreamsMethod } from "./index12.js";
8
8
  import { logger } from "./index7.js";
9
9
  const globalLiveKitProviderRegistry = /* @__PURE__ */ new Set();
10
10
  let rtcPeerConnectionPatched = false;
package/dist/index4.js CHANGED
@@ -3,7 +3,7 @@ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { en
3
3
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
4
  import { BaseProvider } from "./index10.js";
5
5
  import { isAgoraConfig, ConnectionState } from "./index5.js";
6
- import { SEIExtractor } from "./index11.js";
6
+ import { SEIExtractor } from "./index13.js";
7
7
  import { logger } from "./index7.js";
8
8
  class AgoraProvider extends BaseProvider {
9
9
  constructor(_options = {}) {
package/dist/index6.js CHANGED
@@ -116,11 +116,11 @@ const _AnimationHandler = class _AnimationHandler {
116
116
  if (!keyframes || keyframes.length === 0) {
117
117
  return;
118
118
  }
119
+ this.ensureSessionActive(frameSeq);
119
120
  if (this.config.enableJitterBuffer && frameSeq !== void 0) {
120
121
  this.bufferFrame(keyframes[0], frameSeq);
121
122
  return;
122
123
  }
123
- this.renderedFrameCount++;
124
124
  if (frameSeq !== void 0 && this.lastRenderedFrameSeq !== -1) {
125
125
  if (frameSeq < this.lastRenderedFrameSeq) {
126
126
  logger.warn(
@@ -141,7 +141,9 @@ const _AnimationHandler = class _AnimationHandler {
141
141
  if (frameSeq !== void 0) {
142
142
  this.lastRenderedFrameSeq = frameSeq;
143
143
  }
144
+ this.renderedFrameCount++;
144
145
  this.renderer.renderFrame(keyframes[0]);
146
+ this.logRenderedFrame("direct", frameSeq, isRecovered);
145
147
  this.playbackFrameTimestamps.push(performance.now());
146
148
  this.playbackFrameCount++;
147
149
  if (frameSeq !== void 0) {
@@ -159,6 +161,10 @@ const _AnimationHandler = class _AnimationHandler {
159
161
  * @internal
160
162
  */
161
163
  async handleTransitionData(protobufData, frameCount) {
164
+ logger.info(
165
+ "AnimationHandler",
166
+ `Start transition packet received (bytes=${protobufData.byteLength}, requestedFrames=${frameCount ?? this.config.transitionStartFrameCount}, hasHandledStart=${this.hasHandledTransitionStart}, isInSession=${this.isInSession}, isPlayingTransition=${this.isPlayingTransition}, isGeneratingStart=${this.isGeneratingStartTransition}, lastRenderedSeq=${this.lastRenderedFrameSeq}, bufferState=${this.bufferState}, buffered=${this.frameBuffer.size})`
167
+ );
162
168
  if (this.hasHandledTransitionStart) {
163
169
  return;
164
170
  }
@@ -172,6 +178,14 @@ const _AnimationHandler = class _AnimationHandler {
172
178
  logger.warn("AnimationHandler", "Renderer not ready for transition");
173
179
  return;
174
180
  }
181
+ if (this.isInSession && (this.lastRenderedFrameSeq >= 0 || this.frameBuffer.size > 0 || this.bufferState !== "direct")) {
182
+ this.hasHandledTransitionStart = true;
183
+ logger.warn(
184
+ "AnimationHandler",
185
+ `Ignoring late transition packet after playback start (lastRenderedSeq=${this.lastRenderedFrameSeq}, bufferState=${this.bufferState}, buffered=${this.frameBuffer.size})`
186
+ );
187
+ return;
188
+ }
175
189
  const keyframes = this.decoder(protobufData);
176
190
  if (!keyframes || keyframes.length === 0) {
177
191
  logger.warn("AnimationHandler", "No target keyframe in transition data");
@@ -179,11 +193,7 @@ const _AnimationHandler = class _AnimationHandler {
179
193
  }
180
194
  this.hasHandledTransitionStart = true;
181
195
  this.hasHandledTransitionEnd = false;
182
- this.isInSession = true;
183
- this.lastFrameReceivedTime = Date.now();
184
- this.hasReportedStall = false;
185
- this.startWatchdog();
186
- this.startPlaybackStats();
196
+ this.ensureSessionActive();
187
197
  const targetFrame = keyframes[0];
188
198
  const frames = frameCount ?? this.config.transitionStartFrameCount;
189
199
  logger.info("AnimationHandler", `Generating ${frames} transition frames to target`);
@@ -202,6 +212,7 @@ const _AnimationHandler = class _AnimationHandler {
202
212
  } catch (error) {
203
213
  logger.error("AnimationHandler", "Failed to generate transition:", error);
204
214
  this.renderer.renderFrame(targetFrame);
215
+ this.logRenderedFrame("transition-fallback");
205
216
  } finally {
206
217
  this.isGeneratingStartTransition = false;
207
218
  }
@@ -214,6 +225,10 @@ const _AnimationHandler = class _AnimationHandler {
214
225
  * @internal
215
226
  */
216
227
  async handleTransitionToIdle(protobufData, frameCount) {
228
+ if (!this.isInSession) {
229
+ logger.info("AnimationHandler", "Ignoring transition end packet with no active session");
230
+ return;
231
+ }
217
232
  if (this.hasHandledTransitionEnd) {
218
233
  return;
219
234
  }
@@ -226,15 +241,18 @@ const _AnimationHandler = class _AnimationHandler {
226
241
  if (!this.renderer.isReady()) {
227
242
  logger.warn("AnimationHandler", "Renderer not ready for transition to idle");
228
243
  this.renderer.renderFrame(void 0, true);
244
+ this.logRenderedFrame("idle");
229
245
  return;
230
246
  }
231
247
  const keyframes = this.decoder(protobufData);
232
248
  if (!keyframes || keyframes.length === 0) {
233
249
  logger.warn("AnimationHandler", "No last keyframe in transition end data, starting idle directly");
234
250
  this.renderer.renderFrame(void 0, true);
251
+ this.logRenderedFrame("idle");
235
252
  return;
236
253
  }
237
254
  this.hasHandledTransitionEnd = true;
255
+ this.flushBuffer();
238
256
  const lastFrame = keyframes[0];
239
257
  const frames = frameCount ?? this.config.transitionEndFrameCount;
240
258
  logger.info("AnimationHandler", `Generating ${frames} reverse transition frames to idle`);
@@ -254,6 +272,7 @@ const _AnimationHandler = class _AnimationHandler {
254
272
  } catch (error) {
255
273
  logger.error("AnimationHandler", "Failed to generate reverse transition:", error);
256
274
  this.renderer.renderFrame(void 0, true);
275
+ this.logRenderedFrame("idle");
257
276
  } finally {
258
277
  this.isGeneratingEndTransition = false;
259
278
  }
@@ -266,6 +285,24 @@ const _AnimationHandler = class _AnimationHandler {
266
285
  this.isInSession = false;
267
286
  this.hasReportedStall = false;
268
287
  this.renderer.renderFrame(void 0, true);
288
+ this.logRenderedFrame("idle");
289
+ }
290
+ /**
291
+ * Ensure session-level timers/stats are active.
292
+ * @internal
293
+ */
294
+ ensureSessionActive(frameSeq) {
295
+ if (this.isInSession) {
296
+ return;
297
+ }
298
+ this.isInSession = true;
299
+ this.lastFrameReceivedTime = Date.now();
300
+ this.hasReportedStall = false;
301
+ this.startWatchdog();
302
+ this.startPlaybackStats();
303
+ if (frameSeq !== void 0) {
304
+ logger.info("AnimationHandler", `Session started from animation frame seq=${frameSeq}`);
305
+ }
269
306
  }
270
307
  /**
271
308
  * Reset animation frame tracking (call on session start).
@@ -286,7 +323,7 @@ const _AnimationHandler = class _AnimationHandler {
286
323
  * @internal
287
324
  */
288
325
  isInTransition() {
289
- return this.isPlayingTransition;
326
+ return this.isPlayingTransition || this.isGeneratingStartTransition || this.isGeneratingEndTransition;
290
327
  }
291
328
  /**
292
329
  * Stop transition playback.
@@ -438,6 +475,20 @@ const _AnimationHandler = class _AnimationHandler {
438
475
  * @internal
439
476
  */
440
477
  bufferFrame(flame, seq) {
478
+ if (this.lastRenderedFrameSeq >= 0 && seq <= this.lastRenderedFrameSeq) {
479
+ logger.warn(
480
+ "AnimationHandler",
481
+ `Jitter buffer: dropping stale frame seq=${seq} (lastRendered=${this.lastRenderedFrameSeq})`
482
+ );
483
+ return;
484
+ }
485
+ if (this.bufferNextSeq >= 0 && seq < this.bufferNextSeq) {
486
+ logger.warn(
487
+ "AnimationHandler",
488
+ `Jitter buffer: dropping late frame seq=${seq} (nextExpected=${this.bufferNextSeq})`
489
+ );
490
+ return;
491
+ }
441
492
  if (this.frameBuffer.has(seq)) {
442
493
  return;
443
494
  }
@@ -448,6 +499,10 @@ const _AnimationHandler = class _AnimationHandler {
448
499
  if (k < oldestSeq) oldestSeq = k;
449
500
  }
450
501
  this.frameBuffer.delete(oldestSeq);
502
+ logger.warn(
503
+ "AnimationHandler",
504
+ `Jitter buffer: overflow, dropping seq=${oldestSeq} (nextExpected=${this.bufferNextSeq})`
505
+ );
451
506
  }
452
507
  switch (this.bufferState) {
453
508
  case "direct":
@@ -470,6 +525,45 @@ const _AnimationHandler = class _AnimationHandler {
470
525
  break;
471
526
  }
472
527
  }
528
+ /**
529
+ * Drop buffered frames that are now too old to ever be rendered in-order.
530
+ * @internal
531
+ */
532
+ dropStaleBufferedFrames() {
533
+ if (this.frameBuffer.size === 0) {
534
+ return;
535
+ }
536
+ const minAllowedSeq = Math.max(this.bufferNextSeq, this.lastRenderedFrameSeq + 1);
537
+ if (minAllowedSeq < 0) {
538
+ return;
539
+ }
540
+ let dropped = 0;
541
+ for (const seq of Array.from(this.frameBuffer.keys())) {
542
+ if (seq < minAllowedSeq) {
543
+ this.frameBuffer.delete(seq);
544
+ dropped++;
545
+ }
546
+ }
547
+ if (dropped > 0) {
548
+ logger.warn(
549
+ "AnimationHandler",
550
+ `Jitter buffer: dropped ${dropped} stale frame(s) older than seq=${minAllowedSeq}`
551
+ );
552
+ }
553
+ }
554
+ /**
555
+ * Find the lowest buffered sequence at or after minSeq.
556
+ * @internal
557
+ */
558
+ findLowestBufferedSeqAtOrAfter(minSeq) {
559
+ let candidate = Infinity;
560
+ for (const seq of this.frameBuffer.keys()) {
561
+ if (seq >= minSeq && seq < candidate) {
562
+ candidate = seq;
563
+ }
564
+ }
565
+ return candidate === Infinity ? null : candidate;
566
+ }
473
567
  /**
474
568
  * Begin draining the buffer at 25fps.
475
569
  * @internal
@@ -497,28 +591,31 @@ const _AnimationHandler = class _AnimationHandler {
497
591
  if (this.bufferState !== "draining") {
498
592
  return;
499
593
  }
594
+ this.dropStaleBufferedFrames();
500
595
  const frame = this.frameBuffer.get(this.bufferNextSeq);
501
596
  if (frame) {
502
597
  this.renderBufferedFrame(frame);
503
598
  this.frameBuffer.delete(this.bufferNextSeq);
504
599
  this.bufferNextSeq++;
505
600
  } else if (this.frameBuffer.size > 0) {
506
- let oldestSeq = Infinity;
507
- for (const k of this.frameBuffer.keys()) {
508
- if (k < oldestSeq) oldestSeq = k;
601
+ const nextSeq = this.findLowestBufferedSeqAtOrAfter(this.bufferNextSeq);
602
+ if (nextSeq === null) {
603
+ this.bufferState = "starved";
604
+ logger.warn("AnimationHandler", "Jitter buffer: no in-order frames available, pausing drain");
605
+ return;
509
606
  }
510
- const oldestFrame = this.frameBuffer.get(oldestSeq);
511
- const waitTime = performance.now() - oldestFrame.receivedAt;
607
+ const nextFrame = this.frameBuffer.get(nextSeq);
608
+ const waitTime = performance.now() - nextFrame.receivedAt;
512
609
  if (waitTime > this.config.maxBufferDelayMs) {
513
- const gap = oldestSeq - this.bufferNextSeq;
610
+ const gap = Math.max(0, nextSeq - this.bufferNextSeq);
514
611
  this.playbackGapCount += gap;
515
612
  logger.warn(
516
613
  "AnimationHandler",
517
- `Jitter buffer: skipping ${gap} frame(s) from seq ${this.bufferNextSeq} to ${oldestSeq}`
614
+ `Jitter buffer: skipping ${gap} frame(s) from seq ${this.bufferNextSeq} to ${nextSeq} after ${waitTime.toFixed(1)}ms`
518
615
  );
519
- this.renderBufferedFrame(oldestFrame);
520
- this.frameBuffer.delete(oldestSeq);
521
- this.bufferNextSeq = oldestSeq + 1;
616
+ this.renderBufferedFrame(nextFrame);
617
+ this.frameBuffer.delete(nextSeq);
618
+ this.bufferNextSeq = nextSeq + 1;
522
619
  }
523
620
  } else {
524
621
  this.bufferState = "starved";
@@ -536,9 +633,17 @@ const _AnimationHandler = class _AnimationHandler {
536
633
  * @internal
537
634
  */
538
635
  renderBufferedFrame(frame) {
636
+ if (this.lastRenderedFrameSeq >= 0 && frame.seq <= this.lastRenderedFrameSeq) {
637
+ logger.warn(
638
+ "AnimationHandler",
639
+ `Jitter buffer: refusing out-of-order render seq=${frame.seq} (lastRendered=${this.lastRenderedFrameSeq})`
640
+ );
641
+ return;
642
+ }
539
643
  this.renderer.renderFrame(frame.flame);
540
644
  this.lastRenderedFrameSeq = frame.seq;
541
645
  this.renderedFrameCount++;
646
+ this.logRenderedFrame("buffer", frame.seq);
542
647
  this.playbackFrameTimestamps.push(performance.now());
543
648
  this.playbackFrameCount++;
544
649
  }
@@ -575,6 +680,7 @@ const _AnimationHandler = class _AnimationHandler {
575
680
  if (wasTransitioningToIdle) {
576
681
  logger.info("AnimationHandler", "Starting idle animation after transition");
577
682
  this.renderer.renderFrame(void 0, true);
683
+ this.logRenderedFrame("idle");
578
684
  }
579
685
  return;
580
686
  }
@@ -585,6 +691,7 @@ const _AnimationHandler = class _AnimationHandler {
585
691
  }
586
692
  const frame = this.transitionFrames[this.transitionFrameIndex];
587
693
  this.renderer.renderFrame(frame);
694
+ this.logRenderedFrame("transition");
588
695
  this.transitionFrameIndex++;
589
696
  this.transitionTimeoutId = setTimeout(() => {
590
697
  if (this.isPlayingTransition) {
@@ -592,6 +699,16 @@ const _AnimationHandler = class _AnimationHandler {
592
699
  }
593
700
  }, 40);
594
701
  }
702
+ /**
703
+ * Emit a per-frame render log for debugging ordering issues.
704
+ * @internal
705
+ */
706
+ logRenderedFrame(source, seq, isRecovered) {
707
+ logger.info(
708
+ "AnimationHandler",
709
+ `Rendered frame: source=${source}, seq=${seq ?? "n/a"}${isRecovered ? " [RECOVERED]" : ""}`
710
+ );
711
+ }
595
712
  };
596
713
  /** @internal */
597
714
  __publicField(_AnimationHandler, "STALL_TIMEOUT_MS", 3e3);