MSE/fMP4 完整捕获、严格分片校验、YouTube 索引输出、智能跳播与内嵌精简 MP4 合并
// ==UserScript==
// @name media-source-extract-lite
// @namespace local.media-source-extract-lite
// @version 2.2.1
// @description MSE/fMP4 完整捕获、严格分片校验、YouTube 索引输出、智能跳播与内嵌精简 MP4 合并
// @match *://*/*
// @run-at document-start
// @grant none
// ==/UserScript==
(function () {
'use strict'
if (!window.MediaSource || !window.SourceBuffer) return
if (window.__MSE_EXTRACT_LITE_INSTALLED__) return
window.__MSE_EXTRACT_LITE_INSTALLED__ = true
var MOVIE_TIMESCALE = 1000
var MAX_REMUX_BYTES = 1200 * 1024 * 1024
var EPS = 1e-7
var MIN_VIDEO_SAMPLES = 3
var MIN_VIDEO_BYTES = 32 * 1024
var MAX_AV_START_DRIFT = 30
var nativeAddSourceBuffer = MediaSource.prototype.addSourceBuffer
var nativeEndOfStream = MediaSource.prototype.endOfStream
var nativeAppendBuffer = SourceBuffer.prototype.appendBuffer
var mediaSourceSessions = new WeakMap()
var sourceBufferTracks = new WeakMap()
var sessions = []
var activeSession = null
var sessionSerial = 0
var trackSerial = 0
var selectedSessionId = 0
var ui = null
var uiRefreshPending = false
var installedAt = Date.now()
var addSourceBufferCalls = 0
var appendBufferCalls = 0
var directMediaItems = []
var selectedDirectMediaId = ''
var directMediaSignature = ''
var directScanStarted = false
var directScanDirty = true
var directLastScanAt = 0
var DIRECT_SCAN_MIN_INTERVAL = 250
var DIRECT_SCAN_IDLE_INTERVAL = 2000
var directSelectionTouched = false
var mediaElementIds = new WeakMap()
var mediaElementSerial = 0
var smartJumpEnabled = false
var smartJumpLastAt = 0
var smartJumpCount = 0
var smartJumpLastTarget = 0
var SMART_JUMP_INTERVAL = 1500
var SMART_JUMP_COOLDOWN = 2500
var SMART_JUMP_MIN_LEAD = 12
var SMART_JUMP_MIN_DISTANCE = 7
var SMART_JUMP_BACKOFF = 3
var SMART_COMPLETE_TOLERANCE = 1.5
var SMART_JUMP_RESET_DELAY = 2200
var SMART_JUMP_FRONTIER_REGRESSION = 3
var smartJumpTrackedMedia = null
var smartJumpTrackedSrc = ''
var smartJumpTrackedDuration = 0
var smartJumpTrackedWidth = 0
var smartJumpTrackedHeight = 0
var smartJumpTrackedFrontier = 0
var smartJumpSuspendUntil = 0
var smartJumpResetReason = ''
var smartJumpResetAt = 0
var smartJumpInternalSeekUntil = 0
var smartJumpPendingConfigAt = 0
var smartJumpPendingConfigReason = ''
var runtimeTimer = 0
var runtimeLastDirectScan = 0
var runtimeLastStatus = 0
var runtimeLastRate = 0
var runtimeLastSmartJump = 0
// MP4 解析器已精简内嵌,不再依赖 CDN、Trusted Types、CSP 或动态执行。
function now() {
return Date.now()
}
// 所有高频捕获事件只安排一次 UI 刷新,避免同一事件循环重复扫描 DOM 和重建面板。
function requestUIRefresh() {
if (uiRefreshPending) return
uiRefreshPending = true
var run = function () {
uiRefreshPending = false
refreshUI()
}
if (typeof requestAnimationFrame === 'function' && !document.hidden) requestAnimationFrame(run)
else setTimeout(run, 0)
}
function safeNumber(value, fallback) {
return typeof value === 'number' && isFinite(value) ? value : fallback
}
function safeTitle() {
var title = document.title || 'media'
try {
if (window.top && window.top.document && window.top.document.title) {
title = window.top.document.title
}
} catch (e) {}
return sanitizeFilename(title || 'media')
}
function sanitizeFilename(name) {
return String(name || 'media')
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_')
.replace(/[. ]+$/g, '')
.slice(0, 180) || 'media'
}
function normalizeDirectMediaUrl(value) {
if (typeof value !== 'string') return ''
var url = value.trim()
.replace(/\\u002F/gi, '/')
.replace(/\\u0026/gi, '&')
.replace(/\\\//g, '/')
if (!url || /^(?:blob|data|javascript|mediastream):/i.test(url)) return ''
try {
url = new URL(url, location.href).href
} catch (e) {
return ''
}
return /^https?:\/\//i.test(url) ? url : ''
}
function isManifestMediaUrl(url) {
if (!url) return false
try {
var parsed = new URL(url, location.href)
var path = parsed.pathname.toLowerCase()
if (/\.(?:m3u8|mpd)$/.test(path)) return true
var type = String(parsed.searchParams.get('mime_type') || parsed.searchParams.get('type') || '').toLowerCase()
return type.indexOf('mpegurl') !== -1 || type.indexOf('dash') !== -1
} catch (e) {
return /\.(?:m3u8|mpd)(?:$|[?#])/i.test(String(url))
}
}
function mediaElementId(element) {
var id = mediaElementIds.get(element)
if (!id) {
id = ++mediaElementSerial
mediaElementIds.set(element, id)
}
return id
}
function mediaElementVisible(element) {
try {
var style = getComputedStyle(element)
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false
var rect = element.getBoundingClientRect()
return rect.width > 1 && rect.height > 1
} catch (e) {
return false
}
}
function mediaUrlExtension(url, element) {
var path = ''
try { path = new URL(url).pathname } catch (e) {}
var match = path.match(/\.([a-z0-9]{2,5})$/i)
var ext = match ? match[1].toLowerCase() : ''
if (/^(?:mp4|m4v|webm|mov|mkv|mp3|m4a|aac|ogg|wav)$/.test(ext)) return ext
var type = String(element && element.getAttribute && element.getAttribute('type') || '').toLowerCase()
if (type.indexOf('webm') !== -1) return 'webm'
if (type.indexOf('mpeg') !== -1 && element && element.tagName === 'AUDIO') return 'mp3'
if (element && element.tagName === 'AUDIO') return 'm4a'
return 'mp4'
}
function mediaTimeLabel(seconds) {
seconds = Number(seconds)
if (!isFinite(seconds) || seconds <= 0) return ''
var total = Math.round(seconds)
var h = Math.floor(total / 3600)
var m = Math.floor((total % 3600) / 60)
var sec = total % 60
return h ? h + ':' + String(m).padStart(2, '0') + ':' + String(sec).padStart(2, '0') : m + ':' + String(sec).padStart(2, '0')
}
function directMediaLabel(item) {
var parts = []
parts.push(item.kind === 'audio' ? '辅助音频 ' + item.elementId : '视频 ' + item.elementId)
if (item.width && item.height) parts.push(item.width + '×' + item.height)
var duration = mediaTimeLabel(item.duration)
if (duration) parts.push(duration)
if (item.playing) parts.push('播放中')
else if (item.ended) parts.push('已结束')
else parts.push('已暂停')
parts.push('直连 ' + item.extension.toUpperCase())
return parts.join(' · ')
}
function scanDirectMediaElements(force) {
if (!document.querySelectorAll) return false
var scanNow = now()
if (!force && scanNow - directLastScanAt < DIRECT_SCAN_MIN_INTERVAL) return false
if (!force && !directScanDirty && scanNow - directLastScanAt < DIRECT_SCAN_IDLE_INTERVAL) return false
directLastScanAt = scanNow
directScanDirty = false
var nodes
try { nodes = document.querySelectorAll('video, audio') } catch (e) { return false }
var items = []
var byUrl = Object.create(null)
for (var i = 0; i < nodes.length; i++) {
var element = nodes[i]
var raw = ''
try { raw = element.currentSrc || element.src || element.getAttribute('src') || '' } catch (e2) {}
var url = normalizeDirectMediaUrl(raw)
if (!url || isManifestMediaUrl(url)) continue
var id = mediaElementId(element)
var audioDecodedBytes = safeNumber(element.webkitAudioDecodedByteCount, 0)
var videoDecodedBytes = safeNumber(element.webkitVideoDecodedByteCount, 0)
var mediaKind = element.tagName === 'AUDIO' ? 'audio' : 'video'
// 某些播放器使用隐藏 VIDEO 元素承载纯音频;按实际解码信息修正类型。
if (mediaKind === 'video' &&
Number(element.videoWidth || 0) === 0 &&
Number(element.videoHeight || 0) === 0 &&
videoDecodedBytes === 0 && audioDecodedBytes > 0) {
mediaKind = 'audio'
}
var visible = mediaElementVisible(element)
var item = {
id: 'direct:' + id + ':' + url,
elementId: id,
element: element,
url: url,
kind: mediaKind,
width: Number(element.videoWidth || 0),
height: Number(element.videoHeight || 0),
duration: Number(element.duration || 0),
playing: !element.paused && !element.ended && Number(element.readyState || 0) > 1,
ended: !!element.ended,
visible: visible,
audioDecodedBytes: audioDecodedBytes,
videoDecodedBytes: videoDecodedBytes,
primaryVideo: element.tagName === 'VIDEO' && (
Number(element.videoWidth || 0) > 0 ||
Number(element.videoHeight || 0) > 0 ||
videoDecodedBytes > 0 ||
visible
),
extension: mediaUrlExtension(url, element),
title: safeTitle()
}
item.label = directMediaLabel(item)
var old = byUrl[url]
if (!old ||
(item.playing ? 8 : 0) + (item.visible ? 4 : 0) + item.width * item.height / 1000000 >
(old.playing ? 8 : 0) + (old.visible ? 4 : 0) + old.width * old.height / 1000000) {
byUrl[url] = item
}
}
Object.keys(byUrl).forEach(function (url) { items.push(byUrl[url]) })
items.sort(function (a, b) {
if (a.playing !== b.playing) return a.playing ? -1 : 1
if (a.visible !== b.visible) return a.visible ? -1 : 1
if (a.kind !== b.kind) return a.kind === 'video' ? -1 : 1
var areaA = a.width * a.height
var areaB = b.width * b.height
if (areaA !== areaB) return areaB - areaA
return a.elementId - b.elementId
})
var signature = items.map(function (item) {
return item.id + '|' + item.label
}).join('\n')
if (signature === directMediaSignature) return false
directMediaSignature = signature
directMediaItems = items
var selectedExists = directMediaItems.some(function (item) { return item.id === selectedDirectMediaId })
if (!selectedExists) selectedDirectMediaId = ''
// 自动模式只选择真正的视频元素,绝不把独立音轨当成完整视频。
// 一旦 MSE 已经收到媒体数据,自动直连选择立即让位给 MSE。
if (!directSelectionTouched) {
var mseSession = targetSession()
if (sessionHasMediaData(mseSession)) {
selectedDirectMediaId = ''
} else {
var selectedAuto = null
for (var ai = 0; ai < directMediaItems.length; ai++) {
if (directMediaItems[ai].kind === 'video' && directMediaItems[ai].primaryVideo) {
selectedAuto = directMediaItems[ai]
break
}
}
selectedDirectMediaId = selectedAuto ? selectedAuto.id : ''
}
}
return true
}
function selectedDirectMedia() {
scanDirectMediaElements()
for (var i = 0; i < directMediaItems.length; i++) {
if (directMediaItems[i].id === selectedDirectMediaId) return directMediaItems[i]
}
return null
}
function sessionHasMediaData(session) {
return !!(session && session.totalBytes > 0 && sessionOperationCount(session) > 0)
}
function effectiveDirectMedia() {
var direct = selectedDirectMedia()
if (!direct) return null
if (directSelectionTouched) return direct
if (direct.kind === 'audio') return null
if (sessionHasMediaData(targetSession())) return null
return direct
}
function markDirectMediaDirty() {
directScanDirty = true
requestUIRefresh()
}
function scheduleDirectScan() {
if (directScanStarted) return
directScanStarted = true
function scanAndRefresh() {
if (scanDirectMediaElements(true)) requestUIRefresh()
}
;[0, 100, 300, 800, 1500, 3000].forEach(function (delay) {
setTimeout(scanAndRefresh, delay)
})
document.addEventListener('play', markDirectMediaDirty, true)
document.addEventListener('pause', markDirectMediaDirty, true)
document.addEventListener('loadedmetadata', markDirectMediaDirty, true)
document.addEventListener('durationchange', markDirectMediaDirty, true)
document.addEventListener('emptied', markDirectMediaDirty, true)
document.addEventListener('DOMContentLoaded', markDirectMediaDirty, { once: true })
}
function cloneBufferSource(source) {
if (source instanceof ArrayBuffer) {
return source.slice(0)
}
if (ArrayBuffer.isView(source)) {
return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength)
}
throw new TypeError('appendBuffer 参数不是 ArrayBuffer 或 ArrayBufferView')
}
function snapshotRanges(ranges) {
var result = []
try {
for (var i = 0; i < ranges.length; i++) {
result.push([ranges.start(i), ranges.end(i)])
}
} catch (e) {}
return result
}
function getSession(mediaSource) {
var session = mediaSourceSessions.get(mediaSource)
if (session) return session
session = {
id: ++sessionSerial,
mediaSource: mediaSource,
title: safeTitle(),
createdAt: now(),
endedAt: 0,
sequence: 0,
tracks: [],
totalBytes: 0,
warnings: []
}
mediaSourceSessions.set(mediaSource, session)
sessions.push(session)
activeSession = session
requestUIRefresh()
return session
}
function getTrackKind(mime) {
mime = String(mime || '').toLowerCase()
var hasAudioCodec = /(?:mp4a|opus|vorbis|ac-3|ec-3|flac)/.test(mime)
var hasVideoCodec = /(?:avc1|avc3|hvc1|hev1|vp09|vp9|av01)/.test(mime)
if (mime.indexOf('audio/') === 0) return 'audio'
// 少数播放器会把纯音频 fMP4 声明为 video/mp4,需根据 codecs 修正。
if (hasAudioCodec && !hasVideoCodec) return 'audio'
if (mime.indexOf('video/') === 0 || hasVideoCodec) return 'video'
return 'unknown'
}
function isMp4Mime(mime) {
return /\b(?:video|audio)\/mp4\b/i.test(String(mime || ''))
}
function isWebMMime(mime) {
return /\b(?:video|audio)\/webm\b/i.test(String(mime || ''))
}
function addWarning(session, text) {
if (session.warnings.indexOf(text) === -1) session.warnings.push(text)
}
function createCaptureTrack(session, sourceBuffer, mime) {
var track = {
id: ++trackSerial,
session: session,
sourceBuffer: sourceBuffer,
initialMime: String(mime || ''),
currentMime: String(mime || ''),
kindHint: getTrackKind(mime),
operations: [],
events: [],
totalBytes: 0,
lastTimestampOffset: null,
initSegmentCount: 0,
pendingOperations: [],
appendCallNo: 0,
failedCallCount: 0,
createdAt: now()
}
session.tracks.push(track)
return track
}
function bufferSourceMetadata(source) {
if (source instanceof ArrayBuffer) {
return {
constructorName: 'ArrayBuffer',
byteOffset: 0,
byteLength: source.byteLength,
bufferByteLength: source.byteLength
}
}
if (ArrayBuffer.isView(source)) {
return {
constructorName: source && source.constructor && source.constructor.name
? String(source.constructor.name)
: 'ArrayBufferView',
byteOffset: source.byteOffset,
byteLength: source.byteLength,
bufferByteLength: source.buffer.byteLength
}
}
return {
constructorName: typeof source,
byteOffset: 0,
byteLength: 0,
bufferByteLength: 0
}
}
function makeCaptureIntegrityError(message) {
var error = new Error(message)
error.captureIntegrity = true
return error
}
function isMp4InitSegment(buffer) {
var bytes
try { bytes = new Uint8Array(buffer) } catch (e) { return false }
var view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
var end = Math.min(bytes.byteLength, 256 * 1024)
var p = 0
while (p + 8 <= end) {
var size = view.getUint32(p, false)
var type = String.fromCharCode(bytes[p + 4], bytes[p + 5], bytes[p + 6], bytes[p + 7])
var header = 8
if (size === 1) {
if (p + 16 > end) return false
size = view.getUint32(p + 8, false) * 4294967296 + view.getUint32(p + 12, false)
header = 16
} else if (size === 0) {
size = bytes.byteLength - p
}
if (!isFinite(size) || size < header || p + size > bytes.byteLength) return false
if (type === 'moov') return true
p += size
}
return false
}
function removePendingOperation(track, operation) {
var index = track.pendingOperations.indexOf(operation)
if (index !== -1) track.pendingOperations.splice(index, 1)
}
function finishPendingAppend(track, sourceBuffer, state) {
var operation = track.pendingOperations.shift()
if (!operation) return
operation.completedAt = now()
operation.bufferedAfter = snapshotRanges(sourceBuffer.buffered)
operation.state = state || 'ok'
if (operation.state === 'ok') {
track.operations.push(operation)
track.totalBytes += operation.byteLength
track.session.totalBytes += operation.byteLength
if (!directSelectionTouched) selectedDirectMediaId = ''
} else {
track.failedCallCount++
track.events.push({
seq: ++track.session.sequence,
type: 'append-' + operation.state,
callNo: operation.callNo,
byteLength: operation.byteLength,
at: operation.completedAt
})
operation.data = null
}
activeSession = track.session
requestUIRefresh()
}
function captureAppend(track, sourceBuffer, originalAppend, input) {
appendBufferCalls++
// 必须在调用原生 appendBuffer 之前同步复制。页面可能随后复用、转移或释放输入缓冲区。
var metadata = bufferSourceMetadata(input)
var copied = cloneBufferSource(input)
if (isMp4InitSegment(copied)) {
track.initSegmentCount++
track.events.push({
seq: ++track.session.sequence,
type: 'init',
at: now(),
index: track.initSegmentCount
})
if (track.initSegmentCount > 1 && smartJumpEnabled) {
markSmartJumpConfigChange('检测到新的 MP4 初始化段')
}
}
var timestampOffset = safeNumber(sourceBuffer.timestampOffset, 0)
var operation = {
seq: ++track.session.sequence,
callNo: ++track.appendCallNo,
type: 'append',
state: 'pending',
data: copied,
byteLength: copied.byteLength,
constructorName: metadata.constructorName,
sourceByteOffset: metadata.byteOffset,
sourceByteLength: metadata.byteLength,
sourceBufferByteLength: metadata.bufferByteLength,
mime: track.currentMime,
mode: sourceBuffer.mode === 'sequence' ? 'sequence' : 'segments',
timestampOffset: timestampOffset,
timestampOffsetChanged: track.lastTimestampOffset === null ||
Math.abs(timestampOffset - track.lastTimestampOffset) > EPS,
appendWindowStart: safeNumber(sourceBuffer.appendWindowStart, 0),
appendWindowEnd: safeNumber(sourceBuffer.appendWindowEnd, Infinity),
bufferedBefore: snapshotRanges(sourceBuffer.buffered),
bufferedAfter: [],
startedAt: now(),
completedAt: 0
}
track.lastTimestampOffset = timestampOffset
track.pendingOperations.push(operation)
try {
return originalAppend.call(sourceBuffer, input)
} catch (error) {
removePendingOperation(track, operation)
track.failedCallCount++
operation.state = 'thrown'
operation.completedAt = now()
operation.data = null
track.events.push({
seq: ++track.session.sequence,
type: 'append-thrown',
callNo: operation.callNo,
byteLength: operation.byteLength,
message: String(error && error.message || error),
at: operation.completedAt
})
requestUIRefresh()
throw error
}
}
function hookSourceBuffer(track) {
var sourceBuffer = track.sourceBuffer
// update 在 updateend 之前触发。必须在这里结算当前操作,避免页面在自己的 update
// 监听器中立即追加下一块时,旧 pending 状态导致新块被绕过。
sourceBuffer.addEventListener('update', function () {
finishPendingAppend(track, sourceBuffer, 'ok')
})
sourceBuffer.addEventListener('error', function () {
finishPendingAppend(track, sourceBuffer, 'error')
})
sourceBuffer.addEventListener('abort', function () {
finishPendingAppend(track, sourceBuffer, 'aborted')
})
sourceBuffer.addEventListener('updateend', function () {
requestUIRefresh()
})
if (typeof sourceBuffer.changeType === 'function') {
var originalChangeType = sourceBuffer.changeType
sourceBuffer.changeType = function (mime) {
var result = originalChangeType.call(sourceBuffer, mime)
track.currentMime = String(mime || '')
track.events.push({
seq: ++track.session.sequence,
type: 'changeType',
mime: track.currentMime,
at: now()
})
addWarning(track.session, '检测到 SourceBuffer.changeType;导出时会严格隔离不同解码配置,避免强行混合后黑屏。')
if (smartJumpEnabled) {
resetSmartJumpProgress('检测到 SourceBuffer.changeType,智能跳播已按新媒体状态重置', smartMediaElement(), SMART_JUMP_RESET_DELAY)
}
requestUIRefresh()
return result
}
}
if (typeof sourceBuffer.remove === 'function') {
var originalRemove = sourceBuffer.remove
sourceBuffer.remove = function (start, end) {
track.events.push({
seq: ++track.session.sequence,
type: 'remove',
start: Number(start),
end: Number(end),
at: now()
})
return originalRemove.call(sourceBuffer, start, end)
}
}
if (typeof sourceBuffer.abort === 'function') {
var originalAbort = sourceBuffer.abort
sourceBuffer.abort = function () {
track.events.push({
seq: ++track.session.sequence,
type: 'abort-call',
at: now()
})
markSmartJumpConfigChange('SourceBuffer 已中止并准备重建缓冲')
return originalAbort.call(sourceBuffer)
}
}
}
// 原型级 Hook 可覆盖 SourceBuffer.prototype.appendBuffer.call(...) 等调用形式,
// 不再依赖给单个实例赋值后页面始终通过该实例属性调用。
SourceBuffer.prototype.appendBuffer = function (input) {
var track = sourceBufferTracks.get(this)
if (!track) return nativeAppendBuffer.call(this, input)
return captureAppend(track, this, nativeAppendBuffer, input)
}
MediaSource.prototype.addSourceBuffer = function (mime) {
addSourceBufferCalls++
var sourceBuffer = nativeAddSourceBuffer.call(this, mime)
var session = getSession(this)
var track = createCaptureTrack(session, sourceBuffer, mime)
sourceBufferTracks.set(sourceBuffer, track)
hookSourceBuffer(track)
activeSession = session
ensureUI()
requestUIRefresh()
return sourceBuffer
}
MediaSource.prototype.endOfStream = function () {
var session = mediaSourceSessions.get(this)
var result = nativeEndOfStream.apply(this, arguments)
if (session) {
session.endedAt = now()
activeSession = session
requestUIRefresh()
}
return result
}
function formatBytes(bytes) {
if (!isFinite(bytes) || bytes <= 0) return '0 B'
var units = ['B', 'KB', 'MB', 'GB', 'TB']
var index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)))
return (bytes / Math.pow(1024, index)).toFixed(index < 2 ? 0 : 1) + ' ' + units[index]
}
function sessionKindHints(session) {
var result = { video: false, audio: false }
if (!session || !session.tracks) return result
for (var i = 0; i < session.tracks.length; i++) {
var kind = session.tracks[i].kindHint
if (kind === 'video') result.video = true
if (kind === 'audio') result.audio = true
}
return result
}
function sessionActivityWindow(session) {
var start = Infinity
var end = -Infinity
var count = 0
if (session && session.tracks) {
for (var i = 0; i < session.tracks.length; i++) {
var operations = session.tracks[i].operations || []
for (var j = 0; j < operations.length; j++) {
if (operations[j].state !== 'ok') continue
start = Math.min(start, operations[j].startedAt || operations[j].completedAt || session.createdAt)
end = Math.max(end, operations[j].completedAt || operations[j].startedAt || session.createdAt)
count++
}
}
}
if (!isFinite(start)) start = session ? session.createdAt : 0
if (!isFinite(end)) end = session && session.endedAt ? session.endedAt : start
return { start: start, end: end, count: count }
}
function sessionOperationTimes(session) {
var times = []
if (!session || !session.tracks) return times
for (var i = 0; i < session.tracks.length; i++) {
var operations = session.tracks[i].operations || []
for (var j = 0; j < operations.length; j++) {
if (operations[j].state !== 'ok') continue
times.push(operations[j].completedAt || operations[j].startedAt || 0)
}
}
times.sort(function (a, b) { return a - b })
return times
}
function timingAffinity(first, second) {
var a = sessionOperationTimes(first)
var b = sessionOperationTimes(second)
if (!a.length || !b.length) return 0
var small = a.length <= b.length ? a : b
var large = a.length <= b.length ? b : a
var used = new Array(large.length)
var matches = 0
var cursor = 0
var tolerance = 1800
for (var i = 0; i < small.length; i++) {
while (cursor < large.length && large[cursor] < small[i] - tolerance) cursor++
var best = -1
var bestDiff = Infinity
for (var j = Math.max(0, cursor - 1); j < large.length && large[j] <= small[i] + tolerance; j++) {
if (used[j]) continue
var diff = Math.abs(large[j] - small[i])
if (diff < bestDiff) {
best = j
bestDiff = diff
}
}
if (best >= 0) {
used[best] = true
matches++
}
}
return matches / Math.max(1, Math.min(a.length, b.length))
}
function activityOverlapRatio(first, second) {
var a = sessionActivityWindow(first)
var b = sessionActivityWindow(second)
var overlap = Math.max(0, Math.min(a.end, b.end) - Math.max(a.start, b.start))
var base = Math.max(1, Math.min(Math.max(1, a.end - a.start), Math.max(1, b.end - b.start)))
return overlap / base
}
var fastPlaybackEnabled = false
var FAST_PLAYBACK_RATE = 10
function applyPlaybackRate() {
var rate = fastPlaybackEnabled ? FAST_PLAYBACK_RATE : 1
var media = document.querySelectorAll('video, audio')
for (var i = 0; i < media.length; i++) {
try {
media[i].defaultPlaybackRate = rate
media[i].playbackRate = rate
} catch (e) {}
}
}
function updateSpeedButton() {
if (!ui || !ui.speedButton) return
ui.speedButton.textContent = fastPlaybackEnabled ? '倍速播放:10×(点击关闭)' : '倍速播放:关闭'
ui.speedButton.style.background = fastPlaybackEnabled ? '#ef6c00' : '#6d4c41'
}
function toggleFastPlayback() {
fastPlaybackEnabled = !fastPlaybackEnabled
if (fastPlaybackEnabled && smartJumpEnabled) {
smartJumpEnabled = false
clearSmartJumpProgress()
updateSmartJumpButton()
}
applyPlaybackRate()
runtimeLastRate = now()
updateSpeedButton()
requestUIRefresh()
updateMediaLoadStatus()
}
function bufferedMetrics(media) {
var result = {
valid: false,
duration: 0,
current: 0,
playedPercent: 0,
frontier: 0,
frontierPercent: 0,
coverage: 0,
coveragePercent: 0,
lead: 0,
currentRangeEnd: 0,
complete: false,
ranges: []
}
if (!media) return result
var duration = Number(media.duration)
var current = Number(media.currentTime)
if (!isFinite(duration) || duration <= 0 || !isFinite(current)) return result
result.valid = true
result.duration = duration
result.current = Math.max(0, Math.min(duration, current))
result.playedPercent = Math.max(0, Math.min(100, result.current / duration * 100))
var ranges = media.buffered
var total = 0
var frontier = 0
var currentEnd = 0
try {
for (var i = 0; i < ranges.length; i++) {
var start = Math.max(0, Number(ranges.start(i)) || 0)
var end = Math.min(duration, Number(ranges.end(i)) || 0)
if (end <= start) continue
result.ranges.push([start, end])
total += end - start
if (end > frontier) frontier = end
if (result.current >= start - 0.25 && result.current <= end + 0.25) {
if (end > currentEnd) currentEnd = end
}
}
} catch (e) {}
result.frontier = frontier
result.currentRangeEnd = currentEnd
result.coverage = Math.min(duration, total)
result.coveragePercent = Math.max(0, Math.min(100, result.coverage / duration * 100))
result.frontierPercent = Math.max(0, Math.min(100, frontier / duration * 100))
result.lead = Math.max(0, currentEnd - result.current)
result.complete = frontier >= duration - Math.max(SMART_COMPLETE_TOLERANCE, duration * 0.001)
return result
}
function smartMediaElement() {
var direct = effectiveDirectMedia()
if (direct && direct.kind === 'video' && direct.element && direct.element.isConnected !== false) {
return direct.element
}
var videos
try { videos = document.querySelectorAll('video') } catch (e) { return null }
var best = null
var bestScore = -Infinity
for (var i = 0; i < videos.length; i++) {
var video = videos[i]
var metrics = bufferedMetrics(video)
if (!metrics.valid) continue
var score = 0
if (!video.paused && !video.ended) score += 100
if (mediaElementVisible(video)) score += 30
if (video.readyState >= 2) score += 10
score += Math.min(20, Number(video.videoWidth || 0) * Number(video.videoHeight || 0) / 100000)
score += metrics.frontierPercent / 20
if (score > bestScore) {
best = video
bestScore = score
}
}
return best
}
function updateSmartJumpButton() {
if (!ui || !ui.smartJumpButton) return
ui.smartJumpButton.textContent = smartJumpEnabled
? '智能跳播:开启(已跳 ' + smartJumpCount + ' 次)'
: '智能跳播:关闭'
ui.smartJumpButton.style.background = smartJumpEnabled ? '#00897b' : '#455a64'
}
function smartMediaSrc(media) {
if (!media) return ''
try { return String(media.currentSrc || media.src || '') } catch (e) { return '' }
}
function markSmartJumpConfigChange(reason) {
smartJumpPendingConfigAt = now()
smartJumpPendingConfigReason = String(reason || '媒体配置可能已变化')
}
function resetSmartJumpProgress(reason, media, delay) {
var t = Date.now()
smartJumpLastAt = 0
smartJumpCount = 0
smartJumpLastTarget = 0
smartJumpSuspendUntil = t + Math.max(0, Number(delay == null ? SMART_JUMP_RESET_DELAY : delay) || 0)
smartJumpResetReason = String(reason || '媒体状态已变化')
smartJumpResetAt = t
smartJumpTrackedMedia = media || null
smartJumpTrackedSrc = smartMediaSrc(media)
smartJumpTrackedDuration = media ? safeNumber(Number(media.duration), 0) : 0
smartJumpTrackedWidth = media ? Number(media.videoWidth || 0) : 0
smartJumpTrackedHeight = media ? Number(media.videoHeight || 0) : 0
smartJumpTrackedFrontier = 0
smartJumpPendingConfigAt = 0
smartJumpPendingConfigReason = ''
updateSmartJumpButton()
updateMediaLoadStatus()
}
function clearSmartJumpProgress() {
smartJumpLastAt = 0
smartJumpCount = 0
smartJumpLastTarget = 0
smartJumpSuspendUntil = 0
smartJumpResetReason = ''
smartJumpResetAt = 0
smartJumpTrackedMedia = null
smartJumpTrackedSrc = ''
smartJumpTrackedDuration = 0
smartJumpTrackedWidth = 0
smartJumpTrackedHeight = 0
smartJumpTrackedFrontier = 0
smartJumpInternalSeekUntil = 0
smartJumpPendingConfigAt = 0
smartJumpPendingConfigReason = ''
}
function syncSmartJumpMedia(media, metrics) {
if (!media) return false
var src = smartMediaSrc(media)
var duration = safeNumber(Number(media.duration), 0)
var width = Number(media.videoWidth || 0)
var height = Number(media.videoHeight || 0)
var reason = ''
if (smartJumpTrackedMedia && smartJumpTrackedMedia !== media) {
reason = '已切换到新的播放对象'
} else if (smartJumpTrackedSrc && src && smartJumpTrackedSrc !== src) {
reason = '媒体地址已变化,可能已切换画质'
} else if (smartJumpTrackedWidth && smartJumpTrackedHeight && width && height &&
(smartJumpTrackedWidth !== width || smartJumpTrackedHeight !== height)) {
// 分辨率变化也可能只是插播广告,单独出现时不再重置跳播。
markSmartJumpConfigChange('分辨率由 ' + smartJumpTrackedWidth + '×' + smartJumpTrackedHeight +
' 变化为 ' + width + '×' + height)
} else if (smartJumpTrackedDuration && duration &&
Math.abs(smartJumpTrackedDuration - duration) > Math.max(2, smartJumpTrackedDuration * 0.02)) {
reason = '视频时长发生变化,已重新建立跳播状态'
} else if (metrics && smartJumpTrackedFrontier > 0 && metrics.frontier > 0 &&
metrics.frontier + SMART_JUMP_FRONTIER_REGRESSION < smartJumpTrackedFrontier && !media.seeking) {
reason = smartJumpPendingConfigAt && now() - smartJumpPendingConfigAt < 10000
? smartJumpPendingConfigReason + ',且缓冲区已重建'
: '缓冲区已重建,可能已切换画质'
}
if (reason) {
resetSmartJumpProgress(reason, media, SMART_JUMP_RESET_DELAY)
smartJumpTrackedFrontier = metrics ? metrics.frontier : 0
return true
}
smartJumpTrackedMedia = media
if (src) smartJumpTrackedSrc = src
if (duration) smartJumpTrackedDuration = duration
if (width && height) {
smartJumpTrackedWidth = width
smartJumpTrackedHeight = height
}
if (metrics) smartJumpTrackedFrontier = metrics.frontier
return false
}
function smartJumpTick() {
if (!smartJumpEnabled) return
var media = smartMediaElement()
if (!media || media.paused || media.ended || media.seeking || media.readyState < 2) {
updateMediaLoadStatus()
return
}
var metrics = bufferedMetrics(media)
if (syncSmartJumpMedia(media, metrics)) return
if (!metrics.valid || metrics.complete || !metrics.currentRangeEnd) {
updateMediaLoadStatus()
return
}
var nowTime = Date.now()
if (nowTime < smartJumpSuspendUntil) {
updateMediaLoadStatus()
return
}
if (nowTime - smartJumpLastAt < SMART_JUMP_COOLDOWN) return
if (metrics.lead < SMART_JUMP_MIN_LEAD) return
var target = Math.min(metrics.duration - 0.5, metrics.currentRangeEnd - SMART_JUMP_BACKOFF)
if (!isFinite(target) || target - metrics.current < SMART_JUMP_MIN_DISTANCE) return
try {
smartJumpInternalSeekUntil = nowTime + 900
media.currentTime = Math.max(0, target)
smartJumpLastAt = nowTime
smartJumpLastTarget = target
smartJumpCount++
updateSmartJumpButton()
updateMediaLoadStatus()
} catch (e) {}
}
function toggleSmartJump() {
smartJumpEnabled = !smartJumpEnabled
if (smartJumpEnabled && fastPlaybackEnabled) {
fastPlaybackEnabled = false
applyPlaybackRate()
updateSpeedButton()
}
if (smartJumpEnabled) {
resetSmartJumpProgress('智能跳播已启动,等待缓冲稳定', smartMediaElement(), 800)
runtimeLastSmartJump = 0
smartJumpTick()
} else {
clearSmartJumpProgress()
}
updateSmartJumpButton()
updateMediaLoadStatus()
}
function mediaStateLabel(media, metrics) {
if (!media || !metrics || !metrics.valid) return '加载状态:等待视频元数据'
if (metrics.complete) {
return '✅ 加载完成:缓冲前沿已到视频末尾' +
(media.ended ? ',播放已结束' : '')
}
if (!metrics.ranges.length) return '加载状态:尚无可用缓冲区间'
var text = '加载前沿 ' + metrics.frontierPercent.toFixed(1) + '% · 播放 ' + metrics.playedPercent.toFixed(1) + '%'
if (metrics.coveragePercent + 0.1 < metrics.frontierPercent) {
text += ' · 当前缓存覆盖 ' + metrics.coveragePercent.toFixed(1) + '%'
}
if (metrics.lead > 0.1) text += ' · 领先 ' + Math.round(metrics.lead) + ' 秒'
if (smartJumpEnabled) {
text += Date.now() < smartJumpSuspendUntil ? ' · 等待新画质缓冲稳定' : ' · 智能跳播中'
}
return text
}
function updateMediaLoadStatus() {
if (!ui || !ui.loadStatus) return
var media = smartMediaElement()
var metrics = bufferedMetrics(media)
var text
var complete = false
if (!media) {
var session = targetSession()
var mseEnded = !!(session && (session.endedAt || (session.mediaSource && session.mediaSource.readyState === 'ended')))
if (mseEnded) {
text = '✅ 捕获完成:MediaSource 已结束,所有已追加片段均已保存'
complete = true
} else {
text = '加载状态:等待可跟踪的视频元素'
}
} else {
text = mediaStateLabel(media, metrics)
complete = metrics.complete
}
if (smartJumpResetReason && smartJumpEnabled && Date.now() - smartJumpResetAt < 12000) {
text += '<br><span style="color:#ffd180">' + escapeHtml(smartJumpResetReason) + '</span>'
}
if (smartJumpLastTarget > 0 && smartJumpEnabled) {
text += '<br><span style="color:#80cbc4">最近跳至 ' + escapeHtml(mediaTimeLabel(smartJumpLastTarget)) + '</span>'
}
ui.loadStatus.innerHTML = text
ui.loadStatus.style.color = complete ? '#a5d6a7' : '#cfd8dc'
}
function runtimeTick() {
var tickNow = now()
if (tickNow - runtimeLastDirectScan >= DIRECT_SCAN_IDLE_INTERVAL) {
runtimeLastDirectScan = tickNow
if (scanDirectMediaElements(true)) requestUIRefresh()
}
if (tickNow - runtimeLastStatus >= 1000) {
runtimeLastStatus = tickNow
updateMediaLoadStatus()
}
if (fastPlaybackEnabled && tickNow - runtimeLastRate >= 1000) {
runtimeLastRate = tickNow
applyPlaybackRate()
}
if (smartJumpEnabled && tickNow - runtimeLastSmartJump >= SMART_JUMP_INTERVAL) {
runtimeLastSmartJump = tickNow
smartJumpTick()
}
}
function startRuntimeScheduler() {
if (runtimeTimer) return
runtimeTimer = setInterval(runtimeTick, 500)
}
document.addEventListener('ratechange', function (event) {
if (!fastPlaybackEnabled) return
var target = event.target
if (!target || (target.tagName !== 'VIDEO' && target.tagName !== 'AUDIO')) return
if (Math.abs(target.playbackRate - FAST_PLAYBACK_RATE) > 0.01) {
setTimeout(function () {
if (fastPlaybackEnabled) applyPlaybackRate()
}, 0)
}
}, true)
function isVideoEventTarget(target) {
return !!(target && target.tagName === 'VIDEO')
}
function resetSmartJumpFromMediaEvent(event, reason, delay) {
if (!smartJumpEnabled || !isVideoEventTarget(event.target)) return
var current = smartMediaElement()
if (current && current !== event.target) return
resetSmartJumpProgress(reason, event.target, delay)
}
document.addEventListener('loadstart', function (event) {
resetSmartJumpFromMediaEvent(event, '视频开始重新加载,智能跳播已重置', SMART_JUMP_RESET_DELAY)
}, true)
document.addEventListener('emptied', function (event) {
resetSmartJumpFromMediaEvent(event, '视频缓冲已清空,可能正在切换画质', SMART_JUMP_RESET_DELAY)
}, true)
document.addEventListener('loadedmetadata', function (event) {
if (!smartJumpEnabled || !isVideoEventTarget(event.target)) return
var current = smartMediaElement()
if (current && current !== event.target) return
syncSmartJumpMedia(event.target, bufferedMetrics(event.target))
}, true)
document.addEventListener('seeking', function (event) {
if (!smartJumpEnabled || !isVideoEventTarget(event.target)) return
if (Date.now() <= smartJumpInternalSeekUntil) return
var current = smartMediaElement()
if (current && current !== event.target) return
resetSmartJumpProgress('检测到手动拖动,智能跳播状态已重新计算', event.target, 1000)
}, true)
function sessionById(id) {
for (var i = 0; i < sessions.length; i++) {
if (sessions[i].id === id) return sessions[i]
}
return null
}
function targetSession() {
if (selectedSessionId) {
var selected = sessionById(selectedSessionId)
if (selected) return selected
selectedSessionId = 0
}
return activeSession
}
function sessionOperationCount(session) {
var count = 0
if (!session || !session.tracks) return count
for (var i = 0; i < session.tracks.length; i++) {
count += session.tracks[i].operations.length
}
return count
}
function sessionLabel(session) {
var hints = sessionKindHints(session)
var kind = hints.video && hints.audio ? '视频+音频' : (hints.video ? '视频' : (hints.audio ? '音频' : '媒体'))
var count = sessionOperationCount(session)
return '捕获流 #' + session.id + ' · ' + kind + ' · ' + formatBytes(session.totalBytes) + ' · ' + count + ' 次追加'
}
function refreshSessionSelect() {
if (!ui || !ui.sessionSelect) return
var select = ui.sessionSelect
var options = [{ value: 'auto', text: '自动:最近收到数据的捕获流' }]
for (var i = 0; i < sessions.length; i++) {
var session = sessions[i]
if (!session.tracks.length && !session.totalBytes) continue
options.push({ value: String(session.id), text: sessionLabel(session) })
}
var signature = options.map(function (item) { return item.value + '=' + item.text }).join('|')
if (select.__signature !== signature) {
select.__signature = signature
select.textContent = ''
for (var j = 0; j < options.length; j++) {
var option = document.createElement('option')
option.value = options[j].value
option.textContent = options[j].text
select.appendChild(option)
}
}
var wanted = selectedSessionId ? String(selectedSessionId) : 'auto'
var exists = false
for (var k = 0; k < select.options.length; k++) {
if (select.options[k].value === wanted) {
exists = true
break
}
}
select.value = exists ? wanted : 'auto'
}
function refreshDirectSelect() {
if (!ui || !ui.directSelect) return
scanDirectMediaElements()
var select = ui.directSelect
if (!directMediaItems.length) {
select.style.display = 'none'
select.textContent = ''
select.__signature = ''
return
}
select.style.display = 'block'
var signature = directMediaItems.map(function (item) { return item.id + '=' + item.label }).join('|')
if (select.__signature !== signature) {
select.__signature = signature
select.textContent = ''
var mseOption = document.createElement('option')
mseOption.value = ''
mseOption.textContent = sessionHasMediaData(targetSession()) ? '优先使用 MSE 视频流' : '自动识别完整视频'
select.appendChild(mseOption)
for (var i = 0; i < directMediaItems.length; i++) {
var option = document.createElement('option')
option.value = directMediaItems[i].id
option.textContent = directMediaItems[i].label
select.appendChild(option)
}
}
var wanted = selectedDirectMediaId || ''
var exists = false
for (var j = 0; j < select.options.length; j++) {
if (select.options[j].value === wanted) { exists = true; break }
}
select.value = exists ? wanted : ''
}
function capturedAppendCount() {
var count = 0
for (var i = 0; i < sessions.length; i++) {
count += sessionOperationCount(sessions[i])
}
return count
}
function directVideoCount() {
var count = 0
for (var i = 0; i < directMediaItems.length; i++) {
if (directMediaItems[i].kind === 'video' && directMediaItems[i].primaryVideo) count++
}
return count
}
function badgeCountInfo() {
var appendCount = capturedAppendCount()
var directCount = directVideoCount()
if (appendCount > 0) {
return {
count: appendCount,
text: appendCount > 999 ? '999+' : String(appendCount),
title: '已捕获 ' + appendCount + ' 个 MSE 媒体片段'
}
}
if (directCount > 0) {
return {
count: directCount,
text: directCount > 999 ? '999+' : String(directCount),
title: '已识别 ' + directCount + ' 个直连视频'
}
}
return {
count: 0,
text: '0',
title: '尚未捕获媒体;点击展开面板'
}
}
function setPanelExpanded(expanded) {
if (!ui) return
ui.expanded = !!expanded
ui.box.style.display = ui.expanded ? 'block' : 'none'
ui.badge.setAttribute('aria-expanded', ui.expanded ? 'true' : 'false')
ui.badge.style.background = ui.expanded ? '#1565c0' : (badgeCountInfo().count > 0 ? '#2e7d32' : '#263238')
ui.badge.title = (ui.expanded ? '收起操作面板;' : '展开操作面板;') + badgeCountInfo().title
}
function updateBadge() {
if (!ui || !ui.badge || !ui.badgeCount) return
var info = badgeCountInfo()
ui.badgeCount.textContent = info.text
ui.badgeCount.style.background = info.count > 0 ? '#ff9800' : '#607d8b'
ui.badge.style.background = ui.expanded ? '#1565c0' : (info.count > 0 ? '#2e7d32' : '#263238')
ui.badge.title = (ui.expanded ? '收起操作面板;' : '展开操作面板;') + info.title
}
function ensureUI() {
if (ui || !document.documentElement) return
var badge = document.createElement('button')
badge.type = 'button'
badge.id = 'mse-extract-lite-badge'
badge.setAttribute('aria-label', '媒体捕获工具')
badge.setAttribute('aria-expanded', 'false')
badge.style.cssText = [
'position:fixed',
'right:18px',
'top:60px',
'z-index:2147483647',
'display:flex',
'align-items:center',
'gap:7px',
'min-width:52px',
'height:34px',
'padding:0 9px',
'border:1px solid rgba(255,255,255,.22)',
'border-radius:17px',
'background:#263238',
'color:#fff',
'box-shadow:0 3px 12px rgba(0,0,0,.35)',
'cursor:pointer',
'font:600 13px/1 Arial,sans-serif'
].join(';')
var badgeIcon = document.createElement('span')
badgeIcon.textContent = '▶'
badgeIcon.style.cssText = 'font-size:12px;line-height:1'
var badgeCount = document.createElement('span')
badgeCount.textContent = '0'
badgeCount.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:0 5px;box-sizing:border-box;border-radius:10px;background:#607d8b;color:#fff;font:700 12px/20px Arial,sans-serif'
badge.appendChild(badgeIcon)
badge.appendChild(badgeCount)
var box = document.createElement('div')
box.id = 'mse-extract-lite-panel'
box.style.cssText = [
'position:fixed',
'right:18px',
'top:102px',
'z-index:2147483647',
'display:none',
'width:320px',
'padding:12px',
'box-sizing:border-box',
'background:rgba(24,30,36,.94)',
'color:#fff',
'font:13px/1.55 Arial,sans-serif',
'border:1px solid rgba(255,255,255,.18)',
'border-radius:6px',
'box-shadow:0 4px 18px rgba(0,0,0,.35)'
].join(';')
var sessionSelect = document.createElement('select')
sessionSelect.style.cssText = 'display:block;width:100%;margin:0 0 8px;padding:7px 8px;box-sizing:border-box;border:1px solid #607d8b;border-radius:4px;background:#263238;color:#fff'
sessionSelect.title = '只列出实际创建的 MediaSource 捕获流,不修改 URL.createObjectURL,也不依赖 DOM video 绑定。'
var directSelect = document.createElement('select')
directSelect.style.cssText = 'display:none;width:100%;margin:0 0 8px;padding:7px 8px;box-sizing:border-box;border:1px solid #8d6e63;border-radius:4px;background:#3e2723;color:#fff'
directSelect.title = '列出页面 video/audio 元素当前实际使用的 HTTP(S) 媒体地址;blob: 和 data: 地址不会显示。'
var status = document.createElement('div')
status.style.cssText = 'margin-bottom:8px;word-break:break-all'
var loadStatus = document.createElement('div')
loadStatus.style.cssText = 'margin:0 0 8px;padding:7px 8px;border-radius:4px;background:rgba(255,255,255,.07);color:#cfd8dc;word-break:break-all'
loadStatus.textContent = '加载状态:等待视频元数据'
var mergeButton = makeButton('合并并下载 MP4', '#2e7d32')
var copyUrlButton = makeButton('复制媒体 URL', '#5d4037')
copyUrlButton.style.display = 'none'
var smartJumpButton = makeButton('智能跳播:关闭', '#455a64')
var speedButton = makeButton('倍速播放:关闭', '#6d4c41')
var rawButton = makeButton('下载原始轨道', '#1565c0')
var jsonButton = makeButton('导出时间轴 JSON', '#555')
var closeButton = makeButton('隐藏', '#444')
sessionSelect.addEventListener('change', function () {
if (sessionSelect.value === 'auto') {
selectedSessionId = 0
} else {
selectedSessionId = parseInt(sessionSelect.value, 10) || 0
}
if (smartJumpEnabled) resetSmartJumpProgress('已切换捕获流,智能跳播状态已重置', smartMediaElement(), 1000)
requestUIRefresh()
})
directSelect.addEventListener('change', function () {
selectedDirectMediaId = directSelect.value
directSelectionTouched = !!directSelect.value
if (smartJumpEnabled) resetSmartJumpProgress('已切换媒体目标,智能跳播状态已重置', smartMediaElement(), 1000)
requestUIRefresh()
})
mergeButton.addEventListener('click', function () {
var direct = effectiveDirectMedia()
if (direct) {
downloadDirectMedia(direct).catch(function (error) {
console.error('[MSE Extract Lite] 直连下载失败', error)
alert('直连下载失败:' + error.message)
})
return
}
var session = targetSession()
if (!session) return alert('尚未捕获到 MediaSource 会话,也未选择可下载的 video/audio 直连地址')
exportMergedMp4(session).catch(function (error) {
console.error('[MSE Extract Lite] 合并失败', error)
alert('合并失败:' + error.message + '\n\n将下载原始轨道和时间轴信息。')
downloadRawTracks(session)
downloadTimelineJson(session)
})
})
copyUrlButton.addEventListener('click', function () {
var direct = selectedDirectMedia()
if (!direct || !direct.url) return alert('当前没有选中可复制的 HTTP(S) 媒体地址')
copyTextToClipboard(direct.url).then(function () {
setPanelStatus('媒体 URL 已复制到剪贴板')
setTimeout(requestUIRefresh, 1200)
}).catch(function (error) {
console.error('[MSE Extract Lite] 复制 URL 失败', error)
window.prompt('自动复制失败,请手动复制媒体 URL:', direct.url)
})
})
smartJumpButton.addEventListener('click', toggleSmartJump)
speedButton.addEventListener('click', toggleFastPlayback)
rawButton.addEventListener('click', function () {
var session = targetSession()
if (!session) return alert('尚未捕获到 MediaSource 会话')
downloadRawTracks(session)
})
jsonButton.addEventListener('click', function () {
var session = targetSession()
if (!session) return alert('尚未捕获到 MediaSource 会话')
downloadTimelineJson(session)
})
badge.addEventListener('click', function () {
setPanelExpanded(!ui.expanded)
if (ui.expanded) requestUIRefresh()
})
closeButton.addEventListener('click', function () {
setPanelExpanded(false)
})
box.appendChild(sessionSelect)
box.appendChild(directSelect)
box.appendChild(status)
box.appendChild(loadStatus)
box.appendChild(mergeButton)
box.appendChild(copyUrlButton)
box.appendChild(smartJumpButton)
box.appendChild(speedButton)
box.appendChild(rawButton)
box.appendChild(jsonButton)
box.appendChild(closeButton)
document.documentElement.appendChild(badge)
document.documentElement.appendChild(box)
ui = {
box: box,
badge: badge,
badgeCount: badgeCount,
expanded: false,
status: status,
sessionSelect: sessionSelect,
directSelect: directSelect,
mergeButton: mergeButton,
copyUrlButton: copyUrlButton,
speedButton: speedButton,
smartJumpButton: smartJumpButton,
loadStatus: loadStatus
}
updateSpeedButton()
updateSmartJumpButton()
updateMediaLoadStatus()
updateBadge()
}
function makeButton(text, color) {
var button = document.createElement('button')
button.type = 'button'
button.textContent = text
button.style.cssText = 'display:block;width:100%;margin:6px 0 0;padding:8px 10px;border:0;border-radius:4px;background:' + color + ';color:#fff;cursor:pointer;font-weight:600'
return button
}
function refreshUI() {
ensureUI()
if (!ui) return
refreshSessionSelect()
refreshDirectSelect()
updateBadge()
var selectedDirect = selectedDirectMedia()
var direct = effectiveDirectMedia()
var session = targetSession()
updateMediaLoadStatus()
ui.copyUrlButton.style.display = selectedDirect ? 'block' : 'none'
if (selectedDirect) ui.copyUrlButton.textContent = selectedDirect.kind === 'audio' ? '复制音频 URL' : '复制视频 URL'
if (direct) {
ui.mergeButton.textContent = direct.kind === 'audio' ? '下载直连音频' : '下载直连视频'
ui.status.innerHTML = [
'<b>已识别 video/audio 直连媒体</b>',
'<div>' + escapeHtml(direct.label) + '</div>',
'<div style="margin-top:4px;color:#b0bec5">地址来自元素的 currentSrc/src,不经过 MSE 捕获与重封装。</div>',
direct.kind === 'audio' ? '<div style="margin-top:4px;color:#ffd180">这是独立音轨,不代表完整视频;仅在手动选择时下载。</div>' : '',
'<div>addSourceBuffer:' + addSourceBufferCalls + ' appendBuffer:' + appendBufferCalls + '</div>'
].join('')
return
}
if (!session) {
var videoCount = 0
var audioCount = 0
try {
videoCount = document.querySelectorAll('video').length
audioCount = document.querySelectorAll('audio').length
} catch (e) {}
ui.mergeButton.textContent = preferRawFragmentMux() ? '原始分片合并并下载 MP4' : '合并并下载 MP4'
ui.status.innerHTML = [
'<b>MSE 捕获器已立即安装</b>',
'<div>正在等待媒体数据或普通 video/audio 地址…</div>',
'<div>addSourceBuffer:' + addSourceBufferCalls + ' appendBuffer:' + appendBufferCalls + '</div>',
'<div>页面 video:' + videoCount + ' audio:' + audioCount + ' 运行:' + Math.max(0, Math.round((Date.now() - installedAt) / 1000)) + ' 秒</div>',
'<div style="margin-top:5px;color:#b0bec5">普通 HTTPS src 会直接列出;blob: 视频则等待 MSE 捕获。</div>'
].join('')
return
}
ui.mergeButton.textContent = preferRawFragmentMux() ? '原始分片合并并下载 MP4' : '合并并下载 MP4'
var operationCount = 0
var pendingCount = 0
for (var i = 0; i < session.tracks.length; i++) {
operationCount += session.tracks[i].operations.length
pendingCount += session.tracks[i].pendingOperations.length
}
var videoHints = 0
var audioHints = 0
for (var kt = 0; kt < session.tracks.length; kt++) {
if (session.tracks[kt].kindHint === 'video') videoHints++
if (session.tracks[kt].kindHint === 'audio') audioHints++
}
ui.status.innerHTML = [
'<b>实际捕获流 #' + session.id + '</b>',
'<div>选择:' + (selectedSessionId ? '手动固定' : '自动跟随最近数据') + '</div>',
'<div>轨道:视频 ' + videoHints + ' 音频 ' + audioHints + ' 页面流 ' + sessions.length + '</div>',
'<div>追加:' + operationCount +
(pendingCount ? ' 处理中:' + pendingCount : '') + '</div>',
'<div>Hook:addSourceBuffer ' + addSourceBufferCalls + ' appendBuffer ' + appendBufferCalls + '</div>',
'<div>已保存:' + formatBytes(session.totalBytes) + '</div>',
'<div>播放速度:' + (fastPlaybackEnabled ? FAST_PLAYBACK_RATE + '×' : '正常') + '</div>',
session.warnings.length ? '<div style="margin-top:5px;color:#ffd180">警告:' + escapeHtml(session.warnings[session.warnings.length - 1]) + '</div>' : ''
].join('')
}
function escapeHtml(text) {
return String(text).replace(/[&<>"']/g, function (char) {
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]
})
}
function copyTextToClipboard(text) {
text = String(text || '')
if (!text) return Promise.reject(new Error('复制内容为空'))
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
return navigator.clipboard.writeText(text).catch(function () {
return legacyCopyText(text)
})
}
return legacyCopyText(text)
}
function legacyCopyText(text) {
return new Promise(function (resolve, reject) {
var textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0'
document.documentElement.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, textarea.value.length)
var ok = false
try { ok = document.execCommand('copy') } catch (error) {}
textarea.remove()
if (ok) resolve()
else reject(new Error('浏览器拒绝写入剪贴板'))
})
}
function downloadBlob(blob, filename) {
var url = URL.createObjectURL(blob)
var anchor = document.createElement('a')
anchor.href = url
anchor.download = sanitizeFilename(filename)
anchor.style.display = 'none'
document.documentElement.appendChild(anchor)
anchor.click()
anchor.remove()
setTimeout(function () { URL.revokeObjectURL(url) }, 120000)
}
function setPanelStatus(text) {
if (ui && ui.status) ui.status.innerHTML = '<b>' + escapeHtml(text) + '</b>'
}
function openDirectUrl(url) {
var anchor = document.createElement('a')
anchor.href = url
anchor.target = '_blank'
anchor.rel = 'noopener noreferrer'
anchor.style.display = 'none'
document.documentElement.appendChild(anchor)
anchor.click()
anchor.remove()
}
async function downloadDirectMedia(format) {
ui.mergeButton.disabled = true
ui.mergeButton.textContent = '正在下载直连媒体…'
setPanelStatus('正在请求直连媒体…')
try {
var response
try {
response = await fetch(format.url, {
method: 'GET',
credentials: 'include',
cache: 'force-cache',
referrer: location.href,
referrerPolicy: 'strict-origin-when-cross-origin'
})
} catch (error) {
openDirectUrl(format.url)
throw new Error('页面 fetch 被 CORS 或防盗链阻止,已在新标签页打开媒体地址')
}
if (!response.ok) {
openDirectUrl(format.url)
throw new Error('媒体服务器返回 HTTP ' + response.status + ',已在新标签页打开媒体地址')
}
var total = Number(response.headers.get('content-length')) || 0
if (total > MAX_REMUX_BYTES) {
openDirectUrl(format.url)
throw new Error('文件达到 ' + formatBytes(total) + ',超过浏览器内存保存上限,已打开媒体地址')
}
var buffer
if (response.body && response.body.getReader) {
var reader = response.body.getReader()
var chunks = []
var received = 0
var lastUpdate = 0
while (true) {
var part = await reader.read()
if (part.done) break
if (!part.value || !part.value.byteLength) continue
chunks.push(part.value)
received += part.value.byteLength
if (received > MAX_REMUX_BYTES) {
try { await reader.cancel() } catch (e) {}
openDirectUrl(format.url)
throw new Error('媒体数据超过浏览器内存保存上限,已打开媒体地址')
}
if (now() - lastUpdate > 300) {
lastUpdate = now()
setPanelStatus('正在下载:' + formatBytes(received) + (total ? ' / ' + formatBytes(total) : ''))
}
}
var bytes = new Uint8Array(received)
var offset = 0
for (var i = 0; i < chunks.length; i++) {
bytes.set(chunks[i], offset)
offset += chunks[i].byteLength
}
buffer = bytes.buffer
} else {
buffer = await response.arrayBuffer()
}
if (!buffer || !buffer.byteLength) throw new Error('媒体响应为空')
var suffix = []
if (format.width && format.height) suffix.push(format.width + 'x' + format.height)
var extension = format.extension || (format.kind === 'audio' ? 'm4a' : 'mp4')
var filename = sanitizeFilename(format.title || safeTitle()) + (suffix.length ? '-' + suffix.join('-') : '') + '.' + extension
var mime = format.kind === 'audio' ? 'audio/' + (extension === 'mp3' ? 'mpeg' : extension) : 'video/' + extension
downloadBlob(new Blob([buffer], { type: mime }), filename)
setPanelStatus('直连媒体已保存')
} finally {
ui.mergeButton.disabled = false
var currentDirect = effectiveDirectMedia()
ui.mergeButton.textContent = currentDirect ? (currentDirect.kind === 'audio' ? '下载直连音频' : '下载直连视频') : '合并并下载 MP4'
setTimeout(requestUIRefresh, 1200)
}
}
function rawExtension(mime) {
if (isMp4Mime(mime)) return 'mp4'
if (isWebMMime(mime)) return 'webm'
var base = String(mime || '').split(';')[0].split('/')[1]
return base || 'bin'
}
function downloadRawTracks(session) {
var base = session.title || safeTitle()
for (var i = 0; i < session.tracks.length; i++) {
var track = session.tracks[i]
var operations = track.operations.filter(function (operation) {
return operation.state === 'ok' && operation.data
})
if (!operations.length) continue
var mime = operations[0].mime || track.initialMime || 'application/octet-stream'
var parts = operations.map(function (operation) { return operation.data })
downloadBlob(
new Blob(parts, { type: String(mime).split(';')[0] }),
base + '-track-' + (i + 1) + '.' + rawExtension(mime)
)
}
}
// ---------------------------------------------------------------------------
// Fragment-preserving MP4 muxer
// ---------------------------------------------------------------------------
// YouTube 等站点的原始 MSE 轨道通常已经是可连续播放的 fMP4。传统路径会把
// moof/mdat 拆成样本后重建 flat MP4;若站点使用非常规分片边界,容易在解析
// 阶段丢失样本。本路径保留原始媒体负载,只修正初始化信息、轨道 ID、tfdt、
// mfhd,并生成 mfra/tfra 随机访问索引。
var RAW_TOP_LEVEL_TYPES = {
ftyp: 1, styp: 1, moov: 1, moof: 1, mdat: 1,
sidx: 1, ssix: 1, emsg: 1, prft: 1, mfra: 1, free: 1, skip: 1
}
function rawWriteU32(bytes, offset, value) {
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32(offset, value >>> 0, false)
}
function rawWriteU64(bytes, offset, value) {
var view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
var high = Math.floor(value / 4294967296)
var low = value - high * 4294967296
view.setUint32(offset, high >>> 0, false)
view.setUint32(offset + 4, low >>> 0, false)
}
function rawCloneRange(bytes, box) {
return bytes.subarray(box.start, box.end).slice()
}
function rawBoxAt(bytes, offset, limit) {
limit = limit === undefined ? bytes.byteLength : limit
if (offset < 0 || offset + 8 > limit) return null
var size = readU32(bytes, offset)
var type = readType(bytes, offset + 4)
var headerSize = 8
var zeroSize = false
if (size === 1) {
if (offset + 16 > limit) return null
size = readU64(bytes, offset + 8)
headerSize = 16
} else if (size === 0) {
zeroSize = true
size = limit - offset
}
if (!isFinite(size) || size < headerSize || offset + size > limit) return null
return {
type: type,
start: offset,
end: offset + size,
size: size,
headerSize: headerSize,
data: offset + headerSize,
zeroSize: zeroSize
}
}
function rawContainerLooksValid(bytes, box) {
if (!box) return false
if (box.type === 'moof') {
var moofChildren = scanBoxesRange(bytes, box.data, box.end)
var hasTraf = false
for (var i = 0; i < moofChildren.length; i++) {
if (moofChildren[i].type !== 'traf') continue
hasTraf = true
var trafChildren = scanBoxesRange(bytes, moofChildren[i].data, moofChildren[i].end)
var hasTfhd = false
var hasTrun = false
for (var j = 0; j < trafChildren.length; j++) {
if (trafChildren[j].type === 'tfhd') hasTfhd = true
if (trafChildren[j].type === 'trun') hasTrun = true
}
if (hasTfhd && hasTrun) return true
}
return hasTraf && moofChildren.length > 0
}
if (box.type === 'moov') {
var moovChildren = scanBoxesRange(bytes, box.data, box.end)
var hasMvhd = false
var hasTrak = false
for (var k = 0; k < moovChildren.length; k++) {
if (moovChildren[k].type === 'mvhd') hasMvhd = true
if (moovChildren[k].type === 'trak') hasTrak = true
}
return hasMvhd && hasTrak
}
return true
}
function rawFindNextBoundary(bytes, start) {
var end = bytes.byteLength
for (var offset = Math.max(0, start); offset + 8 <= end; offset++) {
var type = readType(bytes, offset + 4)
if (type !== 'moof' && type !== 'moov' && type !== 'ftyp' && type !== 'styp') continue
var box = rawBoxAt(bytes, offset, end)
if (!box || box.zeroSize) continue
if (type === 'moof' || type === 'moov') {
if (!rawContainerLooksValid(bytes, box)) continue
}
return offset
}
return end
}
function rawScanTopLevel(bytes) {
var boxes = []
var offset = 0
var end = bytes.byteLength
while (offset + 8 <= end) {
var type = readType(bytes, offset + 4)
var box = rawBoxAt(bytes, offset, end)
var valid = !!box && !!RAW_TOP_LEVEL_TYPES[type]
if (valid && (type === 'moof' || type === 'moov')) valid = rawContainerLooksValid(bytes, box)
if (valid) {
if (type === 'mdat' && box.zeroSize) {
var next = rawFindNextBoundary(bytes, box.data)
box.end = next
box.size = next - box.start
}
boxes.push(box)
offset = box.end
continue
}
var nextOffset = rawFindNextBoundary(bytes, offset + 1)
if (nextOffset <= offset || nextOffset >= end) break
offset = nextOffset
}
return boxes
}
function rawChild(bytes, parent, type) {
var list = scanBoxesRange(bytes, parent.data, parent.end)
for (var i = 0; i < list.length; i++) if (list[i].type === type) return list[i]
return null
}
function rawChildren(bytes, parent, type) {
var list = scanBoxesRange(bytes, parent.data, parent.end)
if (!type) return list
return list.filter(function (box) { return box.type === type })
}
function rawFullFlags(bytes, box) {
return readU32(bytes, box.data) & 0xffffff
}
function rawConcatTrackBytes(captureTrack) {
var operations = captureTrack.operations.filter(function (operation) {
return operation.state === 'ok' && operation.data && isMp4Mime(operation.mime || captureTrack.initialMime)
})
if (!operations.length) throw new Error('轨道 #' + captureTrack.id + ' 没有原始 MP4 数据')
var total = 0
for (var i = 0; i < operations.length; i++) total += operations[i].data.byteLength
if (total > MAX_REMUX_BYTES) throw new Error('原始轨道达到 ' + formatBytes(total) + ',超过浏览器内存处理上限')
var output = new Uint8Array(total)
var offset = 0
for (var j = 0; j < operations.length; j++) {
output.set(new Uint8Array(operations[j].data), offset)
offset += operations[j].data.byteLength
}
return output
}
function rawTrackHandler(bytes, trak) {
var mdia = rawChild(bytes, trak, 'mdia')
var hdlr = mdia ? rawChild(bytes, mdia, 'hdlr') : null
if (!hdlr || hdlr.data + 12 > hdlr.end) return 'unknown'
var handler = readType(bytes, hdlr.data + 8)
return handler === 'vide' ? 'video' : (handler === 'soun' ? 'audio' : 'unknown')
}
function rawParseInit(bytes, moov, kindHint, ftyp) {
var children = rawChildren(bytes, moov)
var mvhd = null
var mvex = null
var traks = []
for (var i = 0; i < children.length; i++) {
if (children[i].type === 'mvhd') mvhd = children[i]
if (children[i].type === 'mvex') mvex = children[i]
if (children[i].type === 'trak') traks.push(children[i])
}
if (!mvhd || !traks.length) throw new Error('原始初始化段缺少 mvhd/trak')
var trak = null
var kind = 'unknown'
for (var t = 0; t < traks.length; t++) {
var candidateKind = rawTrackHandler(bytes, traks[t])
if (!trak || candidateKind === kindHint) {
trak = traks[t]
kind = candidateKind
}
if (candidateKind === kindHint) break
}
if (!trak || kind === 'unknown') throw new Error('原始初始化段没有可识别的音视频轨道')
var tkhd = rawChild(bytes, trak, 'tkhd')
var mdia = rawChild(bytes, trak, 'mdia')
var mdhd = mdia ? rawChild(bytes, mdia, 'mdhd') : null
var minf = mdia ? rawChild(bytes, mdia, 'minf') : null
var stbl = minf ? rawChild(bytes, minf, 'stbl') : null
var stsd = stbl ? rawChild(bytes, stbl, 'stsd') : null
if (!tkhd || !mdhd || !stsd) throw new Error('原始初始化段缺少 tkhd/mdhd/stsd')
var tkVersion = bytes[tkhd.data]
var tkFields = tkhd.data + 4
var trackId = tkVersion === 1 ? readU32(bytes, tkFields + 16) : readU32(bytes, tkFields + 8)
var mdVersion = bytes[mdhd.data]
var mdFields = mdhd.data + 4
var timescale = mdVersion === 1 ? readU32(bytes, mdFields + 16) : readU32(bytes, mdFields + 8)
var mvVersion = bytes[mvhd.data]
var mvFields = mvhd.data + 4
var movieTimescale = mvVersion === 1 ? readU32(bytes, mvFields + 16) : readU32(bytes, mvFields + 8)
var entryCount = stsd.data + 8 <= stsd.end ? readU32(bytes, stsd.data + 4) : 0
var entryStart = stsd.data + 8
var entrySize = entryCount && entryStart + 8 <= stsd.end ? readU32(bytes, entryStart) : 0
if (!entrySize || entryStart + entrySize > stsd.end) throw new Error('原始初始化段没有有效 sample entry')
var description = bytes.subarray(entryStart, entryStart + entrySize).slice()
var entryType = description.byteLength >= 8 ? readType(description, 4) : ''
if (entryType === 'encv' || entryType === 'enca') throw new Error('检测到加密原始轨道 ' + entryType)
var configKey = decoderConfigurationKey(description, kind, { timescale: timescale })
var trex = null
var defaults = { descriptionIndex: 1, duration: 0, size: 0, flags: 0 }
if (mvex) {
var trexList = rawChildren(bytes, mvex, 'trex')
for (var x = 0; x < trexList.length; x++) {
var p = trexList[x].data + 4
if (p + 20 > trexList[x].end) continue
var trexId = readU32(bytes, p)
if (trexId !== trackId && trexList.length > 1) continue
trex = trexList[x]
defaults = {
descriptionIndex: readU32(bytes, p + 4) || 1,
duration: readU32(bytes, p + 8),
size: readU32(bytes, p + 12),
flags: readU32(bytes, p + 16)
}
break
}
}
return {
kind: kind,
trackId: trackId,
timescale: timescale || (kind === 'audio' ? 48000 : 90000),
movieTimescale: movieTimescale || MOVIE_TIMESCALE,
configKey: configKey,
description: description,
ftypBytes: ftyp ? rawCloneRange(bytes, ftyp) : null,
mvhdBytes: rawCloneRange(bytes, mvhd),
trakBytes: rawCloneRange(bytes, trak),
trexBytes: trex ? rawCloneRange(bytes, trex) : null,
defaults: defaults
}
}
function rawParseFragment(bytes, moof, mdat, init, order) {
var trafs = rawChildren(bytes, moof, 'traf')
var selected = null
var tfhd = null
for (var i = 0; i < trafs.length; i++) {
var candidate = rawChild(bytes, trafs[i], 'tfhd')
if (!candidate) continue
var candidateId = readU32(bytes, candidate.data + 4)
if (!selected || candidateId === init.trackId) {
selected = trafs[i]
tfhd = candidate
}
if (candidateId === init.trackId) break
}
if (!selected || !tfhd) return null
var tfFlags = rawFullFlags(bytes, tfhd)
var p = tfhd.data + 4
var sourceTrackId = readU32(bytes, p)
p += 4
var defaults = {
descriptionIndex: init.defaults.descriptionIndex,
duration: init.defaults.duration,
size: init.defaults.size,
flags: init.defaults.flags
}
if (tfFlags & 0x000001) p += 8
if (tfFlags & 0x000002) { defaults.descriptionIndex = readU32(bytes, p) || 1; p += 4 }
if (tfFlags & 0x000008) { defaults.duration = readU32(bytes, p); p += 4 }
if (tfFlags & 0x000010) { defaults.size = readU32(bytes, p); p += 4 }
if (tfFlags & 0x000020) { defaults.flags = readU32(bytes, p); p += 4 }
var tfdt = rawChild(bytes, selected, 'tfdt')
var start = 0
if (tfdt) start = bytes[tfdt.data] === 1 ? readU64(bytes, tfdt.data + 4) : readU32(bytes, tfdt.data + 4)
var duration = 0
var firstSync = true
var syncKnown = init.kind === 'audio'
var runs = rawChildren(bytes, selected, 'trun')
for (var r = 0; r < runs.length; r++) {
var run = runs[r]
var runFlags = rawFullFlags(bytes, run)
var rp = run.data + 4
var sampleCount = readU32(bytes, rp)
rp += 4
var firstSampleFlags = null
if (runFlags & 0x000001) rp += 4
if (runFlags & 0x000004) { firstSampleFlags = readU32(bytes, rp); rp += 4 }
for (var s = 0; s < sampleCount; s++) {
var sampleDuration = runFlags & 0x000100 ? readU32(bytes, rp) : defaults.duration
if (runFlags & 0x000100) rp += 4
if (runFlags & 0x000200) rp += 4
var sampleFlags = runFlags & 0x000400
? readU32(bytes, rp)
: (s === 0 && firstSampleFlags !== null ? firstSampleFlags : defaults.flags)
if (runFlags & 0x000400) rp += 4
if (runFlags & 0x000800) rp += 4
if (r === 0 && s === 0 && init.kind === 'video') {
syncKnown = !!(runFlags & 0x000400) || firstSampleFlags !== null || defaults.flags !== 0
firstSync = (sampleFlags & 0x00010000) === 0
}
duration += sampleDuration || 1
}
}
if (!duration || !runs.length) return null
return {
order: order,
sourceTrackId: sourceTrackId,
start: start,
duration: duration,
firstSync: firstSync,
syncKnown: syncKnown,
moofBytes: rawCloneRange(bytes, moof),
mdatBytes: rawCloneRange(bytes, mdat),
mdatZeroSize: mdat.zeroSize
}
}
function rawNormalizeRun(run) {
var fragments = run.fragments.slice().sort(function (a, b) { return a.order - b.order })
var cursor = 0
var firstSource = fragments.length ? fragments[0].start : 0
for (var i = 0; i < fragments.length; i++) {
var fragment = fragments[i]
var sourceStart = fragment.start - firstSource
var duration = fragment.duration
var expected = cursor
var tolerance = Math.max(run.init.timescale * 2, duration * 2)
var normalized = i === 0 ? 0 : sourceStart
if (i > 0 && (normalized < expected - tolerance || normalized > expected + tolerance * 8)) normalized = expected
if (i > 0 && normalized < expected) normalized = expected
fragment.normalizedStart = Math.max(0, Math.round(normalized))
cursor = fragment.normalizedStart + duration
}
run.fragments = fragments
run.duration = cursor
run.durationSec = cursor / run.init.timescale
run.mediaBytes = fragments.reduce(function (sum, fragment) {
return sum + Math.max(0, fragment.mdatBytes.byteLength - 8)
}, 0)
return run
}
function rawFindEmbeddedContainer(bytes, box) {
if (!box || box.type !== 'mdat') return -1
for (var offset = box.data; offset + 8 <= box.end; offset++) {
var type = readType(bytes, offset + 4)
if (type !== 'moof' && type !== 'moov') continue
var nested = rawBoxAt(bytes, offset, box.end)
if (!nested || nested.zeroSize || !rawContainerLooksValid(bytes, nested)) continue
return offset
}
return -1
}
function rawValidateTopLevelContinuity(bytes, boxes, captureTrack) {
if (!boxes || !boxes.length) {
throw makeCaptureIntegrityError('轨道 #' + captureTrack.id + ' 没有可验证的 MP4 顶层盒')
}
var expectedOffset = 0
for (var i = 0; i < boxes.length; i++) {
var box = boxes[i]
if (box.start !== expectedOffset) {
throw makeCaptureIntegrityError(
'轨道 #' + captureTrack.id + ' 在偏移 ' + expectedOffset +
' 处存在 ' + Math.abs(box.start - expectedOffset) + ' 字节未识别或缺失数据'
)
}
if (box.type === 'mdat' && !box.zeroSize) {
var embedded = rawFindEmbeddedContainer(bytes, box)
if (embedded >= 0) {
throw makeCaptureIntegrityError(
'轨道 #' + captureTrack.id + ' 的 mdat 在偏移 ' + embedded +
' 处吞入了后续有效 moof/moov;通常表示中间 appendBuffer 数据块缺失'
)
}
}
expectedOffset = box.end
}
if (expectedOffset !== bytes.byteLength) {
throw makeCaptureIntegrityError(
'轨道 #' + captureTrack.id + ' 尾部有 ' +
(bytes.byteLength - expectedOffset) + ' 字节未形成完整 MP4 box'
)
}
}
function rawBuildTrackModel(captureTrack) {
var cacheKey = captureTrack.operations.length + ':' + captureTrack.totalBytes
if (captureTrack.rawFragmentCache && captureTrack.rawFragmentCache.key === cacheKey) return captureTrack.rawFragmentCache.value
var bytes = rawConcatTrackBytes(captureTrack)
var top = rawScanTopLevel(bytes)
rawValidateTopLevelContinuity(bytes, top, captureTrack)
var ftyp = null
var currentInit = null
var periods = []
var order = 0
for (var i = 0; i < top.length; i++) {
var box = top[i]
if (box.type === 'ftyp' && !ftyp) ftyp = box
if (box.type === 'moov') {
try {
currentInit = rawParseInit(bytes, box, captureTrack.kindHint, ftyp)
periods.push({ init: currentInit, fragments: [] })
} catch (error) {
currentInit = null
}
continue
}
if (box.type !== 'moof' || !currentInit || !periods.length) continue
var mdat = null
for (var next = i + 1; next < top.length; next++) {
if (top[next].type === 'mdat') { mdat = top[next]; break }
if (top[next].type === 'moof' || top[next].type === 'moov') break
}
if (!mdat) continue
var fragment = rawParseFragment(bytes, box, mdat, currentInit, ++order)
if (fragment) periods[periods.length - 1].fragments.push(fragment)
}
var runs = []
for (var p = 0; p < periods.length; p++) {
var period = periods[p]
if (!period.fragments.length) continue
var previous = runs.length ? runs[runs.length - 1] : null
if (previous && previous.init.kind === period.init.kind && previous.init.configKey === period.init.configKey) {
previous.fragments = previous.fragments.concat(period.fragments)
} else {
runs.push({ init: period.init, fragments: period.fragments.slice() })
}
}
for (var r = 0; r < runs.length; r++) rawNormalizeRun(runs[r])
runs.sort(function (a, b) {
if (b.durationSec !== a.durationSec) return b.durationSec - a.durationSec
return b.mediaBytes - a.mediaBytes
})
if (!runs.length) throw new Error('轨道 #' + captureTrack.id + ' 没有可保留的 moof/mdat 分片')
var chosen = runs[0]
var model = {
captureTrack: captureTrack,
kind: chosen.init.kind,
init: chosen.init,
fragments: chosen.fragments,
duration: chosen.duration,
durationSec: chosen.durationSec,
mediaBytes: chosen.mediaBytes,
alternativeRuns: Math.max(0, runs.length - 1)
}
captureTrack.rawFragmentCache = { key: cacheKey, value: model }
return model
}
function rawModelsFromSession(session) {
var models = []
for (var i = 0; i < session.tracks.length; i++) {
var track = session.tracks[i]
if (!track.operations.length || !isMp4Mime(track.currentMime || track.initialMime)) continue
try {
models.push(rawBuildTrackModel(track))
} catch (error) {
console.warn('[MSE Extract Lite] 原始分片轨道解析失败 #' + track.id, error)
}
}
return models
}
function rawBestModel(models, kind) {
var best = null
for (var i = 0; i < models.length; i++) {
if (models[i].kind !== kind) continue
if (!best || models[i].durationSec > best.durationSec ||
(models[i].durationSec === best.durationSec && models[i].mediaBytes > best.mediaBytes)) best = models[i]
}
return best
}
function rawFindCompanion(primarySession, primaryModel, missingKind) {
var best = null
for (var i = 0; i < sessions.length; i++) {
var candidateSession = sessions[i]
if (candidateSession === primarySession || !candidateSession.totalBytes) continue
var hints = sessionKindHints(candidateSession)
if (!hints[missingKind]) continue
var roughAffinity = timingAffinity(primarySession, candidateSession)
var roughOverlap = activityOverlapRatio(primarySession, candidateSession)
if (roughAffinity < 0.12 && roughOverlap < 0.12 && Math.abs(candidateSession.createdAt - primarySession.createdAt) > 8000) continue
var model = rawBestModel(rawModelsFromSession(candidateSession), missingKind)
if (!model) continue
var ratio = primaryModel && primaryModel.durationSec > 0
? Math.min(primaryModel.durationSec, model.durationSec) / Math.max(primaryModel.durationSec, model.durationSec)
: 1
var score = roughAffinity * 180 + roughOverlap * 80 + ratio * 140 - Math.abs(candidateSession.createdAt - primarySession.createdAt) / 1000
if (!best || score > best.score) best = { model: model, session: candidateSession, score: score }
}
return best && best.score >= 70 ? best : null
}
function rawCollectModels(session) {
var local = rawModelsFromSession(session)
var video = rawBestModel(local, 'video')
var audio = rawBestModel(local, 'audio')
var companions = []
if (video && !audio) {
var audioCompanion = rawFindCompanion(session, video, 'audio')
if (audioCompanion) {
audio = audioCompanion.model
companions.push(audioCompanion.session.id)
}
} else if (audio && !video) {
var videoCompanion = rawFindCompanion(session, audio, 'video')
if (videoCompanion) {
video = videoCompanion.model
companions.push(videoCompanion.session.id)
}
}
if (!video && !audio) throw new Error('没有找到可保留的原始 fMP4 音视频轨道')
return { video: video, audio: audio, companionSessionIds: companions }
}
function rawPatchMvhdBox(raw, duration, nextTrackId) {
var bytes = raw.slice()
var box = scanBoxesRange(bytes, 0, bytes.byteLength)[0]
if (!box || box.type !== 'mvhd') throw new Error('mvhd 修复失败')
var version = bytes[box.data]
var fields = box.data + 4
if (version === 1) rawWriteU64(bytes, fields + 20, duration)
else rawWriteU32(bytes, fields + 12, duration)
rawWriteU32(bytes, box.end - 4, nextTrackId)
return bytes
}
function rawPatchTrakBox(raw, trackId, movieDuration, mediaDuration) {
var bytes = raw.slice()
var trak = scanBoxesRange(bytes, 0, bytes.byteLength)[0]
if (!trak || trak.type !== 'trak') throw new Error('trak 修复失败')
var tkhd = rawChild(bytes, trak, 'tkhd')
var mdia = rawChild(bytes, trak, 'mdia')
var mdhd = mdia ? rawChild(bytes, mdia, 'mdhd') : null
if (!tkhd || !mdhd) throw new Error('trak 缺少 tkhd/mdhd')
var version = bytes[tkhd.data]
var fields = tkhd.data + 4
rawWriteU32(bytes, version === 1 ? fields + 16 : fields + 8, trackId)
if (version === 1) rawWriteU64(bytes, fields + 28, movieDuration)
else rawWriteU32(bytes, fields + 20, movieDuration)
version = bytes[mdhd.data]
fields = mdhd.data + 4
if (version === 1) rawWriteU64(bytes, fields + 20, mediaDuration)
else rawWriteU32(bytes, fields + 12, mediaDuration)
return bytes
}
function rawPatchTrexBox(raw, trackId) {
var bytes = raw.slice()
var trex = scanBoxesRange(bytes, 0, bytes.byteLength)[0]
if (!trex || trex.type !== 'trex') throw new Error('trex 修复失败')
rawWriteU32(bytes, trex.data + 4, trackId)
return bytes
}
function rawBuildCombinedMoov(models, durationSec) {
var base = models.video || models.audio
var movieTimescale = base.init.movieTimescale || MOVIE_TIMESCALE
var movieDuration = Math.max(1, Math.ceil(durationSec * movieTimescale))
var writer = new BoxWriter(32768)
writer.begin('moov')
writer.data(rawPatchMvhdBox(base.init.mvhdBytes, movieDuration, models.video && models.audio ? 3 : 2))
if (models.video) {
writer.data(rawPatchTrakBox(
models.video.init.trakBytes,
1,
movieDuration,
Math.max(1, Math.ceil(models.video.durationSec * models.video.init.timescale))
))
}
if (models.audio) {
writer.data(rawPatchTrakBox(
models.audio.init.trakBytes,
models.video ? 2 : 1,
movieDuration,
Math.max(1, Math.ceil(models.audio.durationSec * models.audio.init.timescale))
))
}
writer.begin('mvex')
writer.beginFull('mehd', 1, 0)
writer.u64(movieDuration)
writer.end()
if (models.video && models.video.init.trexBytes) writer.data(rawPatchTrexBox(models.video.init.trexBytes, 1))
if (models.audio && models.audio.init.trexBytes) writer.data(rawPatchTrexBox(models.audio.init.trexBytes, models.video ? 2 : 1))
writer.end()
writer.end()
return writer.result()
}
function rawPatchFragment(fragment, newTrackId, sequenceNumber, outputOffset) {
var bytes = fragment.moofBytes.slice()
var moof = scanBoxesRange(bytes, 0, bytes.byteLength)[0]
if (!moof || moof.type !== 'moof') throw new Error('moof 修复失败')
var mfhd = rawChild(bytes, moof, 'mfhd')
if (mfhd) rawWriteU32(bytes, mfhd.data + 4, sequenceNumber)
var trafs = rawChildren(bytes, moof, 'traf')
for (var i = 0; i < trafs.length; i++) {
var tfhd = rawChild(bytes, trafs[i], 'tfhd')
if (!tfhd) continue
var flags = rawFullFlags(bytes, tfhd)
var p = tfhd.data + 4
rawWriteU32(bytes, p, newTrackId)
p += 4
if (flags & 0x000001) rawWriteU64(bytes, p, outputOffset)
var tfdt = rawChild(bytes, trafs[i], 'tfdt')
if (tfdt) {
if (bytes[tfdt.data] === 1) rawWriteU64(bytes, tfdt.data + 4, fragment.normalizedStart)
else {
if (fragment.normalizedStart > 0xffffffff) throw new Error('32 位 tfdt 超出可表示范围')
rawWriteU32(bytes, tfdt.data + 4, fragment.normalizedStart)
}
}
}
return bytes
}
function rawPatchMdat(fragment) {
var bytes = fragment.mdatBytes.slice()
if (bytes.byteLength < 8) throw makeCaptureIntegrityError('mdat 过小')
var size = readU32(bytes, 0)
if (size === 1) {
if (bytes.byteLength < 16) throw makeCaptureIntegrityError('扩展 mdat 头不完整')
var declared64 = readU64(bytes, 8)
if (declared64 !== bytes.byteLength) {
throw makeCaptureIntegrityError(
'捕获数据不完整:扩展 mdat 声明 ' + declared64 +
' 字节,实际只有 ' + bytes.byteLength + ' 字节'
)
}
return bytes
}
if (size === 0) {
if (!fragment.mdatZeroSize) {
throw makeCaptureIntegrityError('size=0 mdat 的分片边界未经验证')
}
if (bytes.byteLength > 0xffffffff) {
throw makeCaptureIntegrityError('单个 mdat 超过 4 GB,无法原位修复')
}
rawWriteU32(bytes, 0, bytes.byteLength)
return bytes
}
if (size !== bytes.byteLength) {
throw makeCaptureIntegrityError(
'捕获数据不完整:mdat 声明 ' + size +
' 字节,实际只有 ' + bytes.byteLength + ' 字节'
)
}
return bytes
}
function rawBuildMfra(trackEntries) {
var writer = new BoxWriter(8192)
writer.begin('mfra')
for (var i = 0; i < trackEntries.length; i++) {
var track = trackEntries[i]
writer.beginFull('tfra', 1, 0)
writer.u32(track.trackId)
writer.u32(0) // traf/trun/sample number 均使用 1 字节。
writer.u32(track.entries.length)
for (var j = 0; j < track.entries.length; j++) {
writer.u64(track.entries[j].time)
writer.u64(track.entries[j].offset)
writer.u8(1)
writer.u8(1)
writer.u8(1)
}
writer.end()
}
writer.beginFull('mfro', 0, 0)
var sizePatch = writer.position
writer.u32(0)
writer.end()
var mfraSize = writer.position
writer.end()
writer.patchU32(sizePatch, mfraSize)
return writer.result()
}
function rawBuildIndexedFragmentedMp4(session) {
var collected = rawCollectModels(session)
var models = { video: collected.video, audio: collected.audio }
var base = models.video || models.audio
var durationSec = Math.max(models.video ? models.video.durationSec : 0, models.audio ? models.audio.durationSec : 0)
if (!(durationSec > 0)) throw new Error('原始分片没有有效时长')
var ftyp = base.init.ftypBytes || makeFtyp()
var moov = rawBuildCombinedMoov(models, durationSec)
var fragments = []
if (models.video) {
for (var v = 0; v < models.video.fragments.length; v++) {
fragments.push({ model: models.video, fragment: models.video.fragments[v], trackId: 1, kind: 'video' })
}
}
if (models.audio) {
var audioTrackId = models.video ? 2 : 1
for (var a = 0; a < models.audio.fragments.length; a++) {
fragments.push({ model: models.audio, fragment: models.audio.fragments[a], trackId: audioTrackId, kind: 'audio' })
}
}
fragments.sort(function (first, second) {
var at = first.fragment.normalizedStart / first.model.init.timescale
var bt = second.fragment.normalizedStart / second.model.init.timescale
if (at !== bt) return at - bt
return first.trackId - second.trackId
})
var parts = [ftyp, moov]
var outputOffset = ftyp.byteLength + moov.byteLength
var sequence = 1
var indexByTrack = Object.create(null)
var mediaBytes = 0
for (var i = 0; i < fragments.length; i++) {
var item = fragments[i]
var fragment = item.fragment
item.outputOffset = outputOffset
var moofBytes = rawPatchFragment(fragment, item.trackId, sequence++, outputOffset)
var mdatBytes = rawPatchMdat(fragment)
parts.push(moofBytes, mdatBytes)
if (!indexByTrack[item.trackId]) indexByTrack[item.trackId] = []
if (item.kind === 'audio' || fragment.firstSync || !fragment.syncKnown) {
indexByTrack[item.trackId].push({ time: fragment.normalizedStart, offset: outputOffset })
}
outputOffset += moofBytes.byteLength + mdatBytes.byteLength
mediaBytes += Math.max(0, mdatBytes.byteLength - (readU32(mdatBytes, 0) === 1 ? 16 : 8))
}
if (models.video && (!indexByTrack[1] || !indexByTrack[1].length)) {
indexByTrack[1] = []
for (var fallbackIndex = 0; fallbackIndex < fragments.length; fallbackIndex++) {
if (fragments[fallbackIndex].trackId !== 1) continue
indexByTrack[1].push({
time: fragments[fallbackIndex].fragment.normalizedStart,
offset: fragments[fallbackIndex].outputOffset
})
}
}
var trackEntries = []
Object.keys(indexByTrack).sort().forEach(function (id) {
trackEntries.push({ trackId: parseInt(id, 10), entries: indexByTrack[id] })
})
var mfra = rawBuildMfra(trackEntries)
parts.push(mfra)
if (models.video && models.video.alternativeRuns) {
addWarning(session, '检测到视频解码配置切换;原始分片模式仅保留连续时长最长的一套配置。')
}
if (models.audio && models.audio.alternativeRuns) {
addWarning(session, '检测到音频解码配置切换;原始分片模式仅保留连续时长最长的一套配置。')
}
if (collected.companionSessionIds.length) {
addWarning(session, '原始分片模式已关联 MediaSource 会话 #' + collected.companionSessionIds.join('、') + ' 的配对轨道。')
}
return {
blob: new Blob(parts, { type: 'video/mp4' }),
durationSec: durationSec,
mediaBytes: mediaBytes,
hasVideo: !!models.video,
hasAudio: !!models.audio,
videoFragments: models.video ? models.video.fragments.length : 0,
audioFragments: models.audio ? models.audio.fragments.length : 0
}
}
function validateIndexedFragmentedMp4(result) {
if (!result || !result.blob || result.blob.size < 1024) return Promise.reject(new Error('原始分片合并结果为空'))
if (result.mediaBytes && result.blob.size < result.mediaBytes) return Promise.reject(new Error('原始分片合并结果小于媒体负载'))
return result.blob.arrayBuffer().then(function (buffer) {
var bytes = new Uint8Array(buffer)
var top = scanBoxesRange(bytes, 0, bytes.byteLength)
var moov = false
var mfra = false
var moofCount = 0
var mdatBytes = 0
for (var i = 0; i < top.length; i++) {
if (top[i].type === 'moov') moov = true
if (top[i].type === 'mfra') mfra = true
if (top[i].type === 'moof') moofCount++
if (top[i].type === 'mdat') mdatBytes += top[i].end - top[i].data
}
if (!moov || !mfra || !moofCount || !mdatBytes) throw new Error('原始分片合并结果缺少 moov/moof/mdat/mfra')
if (result.mediaBytes && mdatBytes < result.mediaBytes * 0.98) throw new Error('原始分片输出的 mdat 覆盖率异常')
return result
})
}
function preferRawFragmentMux() {
var host = String(location.hostname || '').toLowerCase()
return host === 'youtube.com' || host === 'www.youtube.com' || /(^|\.)youtube\.com$/.test(host) || host === 'youtu.be'
}
function sessionToJson(session) {
return {
version: 3,
title: session.title,
createdAt: session.createdAt,
endedAt: session.endedAt,
totalBytes: session.totalBytes,
warnings: session.warnings.slice(),
tracks: session.tracks.map(function (track) {
return {
id: track.id,
initialMime: track.initialMime,
currentMime: track.currentMime,
kindHint: track.kindHint,
totalBytes: track.totalBytes,
appendCallCount: track.appendCallNo,
capturedCallCount: track.appendCallNo,
failedCallCount: track.failedCallCount,
events: track.events.slice(),
operations: track.operations.map(function (operation) {
return {
seq: operation.seq,
callNo: operation.callNo,
state: operation.state,
byteLength: operation.byteLength,
constructorName: operation.constructorName,
sourceByteOffset: operation.sourceByteOffset,
sourceByteLength: operation.sourceByteLength,
sourceBufferByteLength: operation.sourceBufferByteLength,
mime: operation.mime,
mode: operation.mode,
timestampOffset: operation.timestampOffset,
timestampOffsetChanged: operation.timestampOffsetChanged,
appendWindowStart: operation.appendWindowStart,
appendWindowEnd: isFinite(operation.appendWindowEnd) ? operation.appendWindowEnd : null,
bufferedBefore: operation.bufferedBefore,
bufferedAfter: operation.bufferedAfter,
startedAt: operation.startedAt,
completedAt: operation.completedAt
}
})
}
})
}
}
function downloadTimelineJson(session) {
var json = JSON.stringify(sessionToJson(session), null, 2)
downloadBlob(new Blob([json], { type: 'application/json;charset=utf-8' }), session.title + '-timeline.json')
}
function readU32(bytes, offset) {
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(offset, false)
}
function readU64(bytes, offset) {
var view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
var high = view.getUint32(offset, false)
var low = view.getUint32(offset + 4, false)
return high * 4294967296 + low
}
function readType(bytes, offset) {
return String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3])
}
function scanTopLevelBoxes(bytes) {
var boxes = []
var offset = 0
while (offset + 8 <= bytes.byteLength) {
var size = readU32(bytes, offset)
var type = readType(bytes, offset + 4)
var headerSize = 8
if (size === 1) {
if (offset + 16 > bytes.byteLength) return null
size = readU64(bytes, offset + 8)
headerSize = 16
} else if (size === 0) {
size = bytes.byteLength - offset
}
if (!isFinite(size) || size < headerSize || offset + size > bytes.byteLength) return null
boxes.push({ type: type, start: offset, size: size, end: offset + size, headerSize: headerSize })
offset += size
}
if (offset !== bytes.byteLength) return null
return boxes
}
function fnv1a(bytes) {
var hash = 2166136261
for (var i = 0; i < bytes.length; i++) {
hash ^= bytes[i]
hash = Math.imul(hash, 16777619)
}
return (hash >>> 0).toString(16)
}
function prepareMp4Inputs(captureTrack) {
var operations = captureTrack.operations.filter(function (operation) {
return operation.state === 'ok' && operation.data && isMp4Mime(operation.mime || captureTrack.initialMime)
})
if (!operations.length) throw new Error('轨道 #' + captureTrack.id + ' 没有可用的 MP4 数据')
var chunks = []
var totalBytes = 0
for (var i = 0; i < operations.length; i++) {
var bytes = new Uint8Array(operations[i].data)
chunks.push({ start: totalBytes, end: totalBytes + bytes.byteLength, bytes: bytes, operation: operations[i] })
totalBytes += bytes.byteLength
}
if (totalBytes > MAX_REMUX_BYTES) {
throw new Error('单轨数据达到 ' + formatBytes(totalBytes) + ',浏览器内存重封装风险过高,请下载原始轨道后用 FFmpeg 合并')
}
function locate(position) {
var low = 0
var high = chunks.length - 1
while (low <= high) {
var mid = (low + high) >> 1
var chunk = chunks[mid]
if (position < chunk.start) high = mid - 1
else if (position >= chunk.end) low = mid + 1
else return mid
}
return -1
}
function byteAt(position) {
var index = locate(position)
if (index < 0) return -1
return chunks[index].bytes[position - chunks[index].start]
}
function u32At(position) {
return byteAt(position) * 16777216 + byteAt(position + 1) * 65536 +
byteAt(position + 2) * 256 + byteAt(position + 3)
}
function u64At(position) {
return u32At(position) * 4294967296 + u32At(position + 4)
}
function typeAt(position) {
return String.fromCharCode(byteAt(position), byteAt(position + 1), byteAt(position + 2), byteAt(position + 3))
}
function copyRange(start, end, target, offset) {
var index = locate(start)
if (index < 0) throw new Error('MP4 分片范围越界')
while (start < end && index < chunks.length) {
var chunk = chunks[index]
var from = Math.max(start, chunk.start)
var to = Math.min(end, chunk.end)
if (to > from) {
var part = chunk.bytes.subarray(from - chunk.start, to - chunk.start)
target.set(part, offset)
offset += part.byteLength
start = to
}
index++
}
return offset
}
function concatRanges(ranges) {
var total = 0
for (var r = 0; r < ranges.length; r++) total += ranges[r].end - ranges[r].start
var output = new Uint8Array(total)
var offset = 0
for (var j = 0; j < ranges.length; j++) {
var rangeOffset = offset
offset = copyRange(ranges[j].start, ranges[j].end, output, offset)
// 输出缓冲不再保留 appendBuffer 边界;把 size=0 改写为显式长度,防止解析时吞掉后续 box。
if (ranges[j].zeroSize && ranges[j].end - ranges[j].start <= 0xffffffff) {
new DataView(output.buffer).setUint32(rangeOffset, ranges[j].end - ranges[j].start, false)
}
}
return output.buffer
}
function hashRange(start, end) {
var hash = 2166136261
var index = locate(start)
while (start < end && index >= 0 && index < chunks.length) {
var chunk = chunks[index]
var from = Math.max(start, chunk.start)
var to = Math.min(end, chunk.end)
for (var p = from - chunk.start; p < to - chunk.start; p++) {
hash ^= chunk.bytes[p]
hash = Math.imul(hash, 16777619)
}
start = to
index++
}
return (hash >>> 0).toString(16)
}
function operationAt(position) {
var index = locate(position)
return index >= 0 ? chunks[index].operation : operations[0]
}
var segmentBoundaryTypes = {
ftyp: 1, styp: 1, moov: 1, moof: 1, sidx: 1, ssix: 1,
emsg: 1, prft: 1, mfra: 1, pdin: 1
}
function plausibleSegmentBoundary(position) {
if (position < 0 || position + 8 > totalBytes) return false
var size = u32At(position)
var type = typeAt(position + 4)
var headerSize = 8
if (!segmentBoundaryTypes[type]) return false
if (size === 1) {
if (position + 16 > totalBytes) return false
size = u64At(position + 8)
headerSize = 16
} else if (size === 0) {
// 新片段通常不会以 size=0 的控制 box 开始;拒绝该候选可降低媒体负载误命中的概率。
return false
}
if (!isFinite(size) || size < headerSize || position + size > totalBytes) return false
// moof 必须至少含一个 traf。仅匹配到 ASCII “moof” 不能作为媒体负载边界。
if (type === 'moof') {
var childPos = position + headerSize
var hasTraf = false
while (childPos + 8 <= position + size) {
var childSize = u32At(childPos)
var childType = typeAt(childPos + 4)
var childHeader = 8
if (childSize === 1) {
if (childPos + 16 > position + size) return false
childSize = u64At(childPos + 8)
childHeader = 16
} else if (childSize === 0) {
childSize = position + size - childPos
}
if (!isFinite(childSize) || childSize < childHeader || childPos + childSize > position + size) return false
if (childType === 'traf') hasTraf = true
childPos += childSize
}
if (childPos !== position + size || !hasTraf) return false
}
return true
}
function zeroSizedBoxEnd(offset) {
var index = locate(offset)
if (index < 0) return totalBytes
// appendBuffer 可能只是一个 size=0 mdat 的中间块。不能把 mdat 强制截在当前 append 末尾;
// 向后越过所有“续传块”,直到某个 append 的开头能验证为新的顶层媒体片段。
for (var i = index + 1; i < chunks.length; i++) {
var boundary = chunks[i].start
if (plausibleSegmentBoundary(boundary)) return boundary
}
return totalBytes
}
function scanChunkBoxes() {
var boxes = []
var offset = 0
while (offset + 8 <= totalBytes) {
var size = u32At(offset)
var originalSize = size
var type = typeAt(offset + 4)
var headerSize = 8
if (size === 1) {
if (offset + 16 > totalBytes) return null
size = u64At(offset + 8)
headerSize = 16
} else if (size === 0) {
var zeroEnd = zeroSizedBoxEnd(offset)
size = zeroEnd - offset
}
if (!isFinite(size) || size < headerSize || offset + size > totalBytes) return null
boxes.push({ type: type, start: offset, end: offset + size, size: size, headerSize: headerSize, zeroSize: originalSize === 0 })
offset += size
}
return offset === totalBytes ? boxes : null
}
var boxes = scanChunkBoxes()
if (!boxes) {
return [{
buffer: concatRanges([{ start: 0, end: totalBytes }]),
moofMeta: [],
defaultMeta: operations[0],
warnings: ['无法完整识别顶层 MP4 box;将按单一初始化段解析。'],
periodId: String(captureTrack.id) + ':1',
periodOrder: 1,
initSignature: 'unknown',
mediaPayloadBytes: 0
}]
}
var periods = []
var pendingFtyp = null
var current = null
var periodSerial = 0
var warnings = []
function newPeriod(moov, operation) {
periodSerial++
current = {
ranges: [],
moofMeta: [],
defaultMeta: operation || operations[0],
hasMedia: false,
hasMdat: false,
mediaPayloadBytes: 0,
periodId: String(captureTrack.id) + ':' + periodSerial,
periodOrder: periodSerial,
initSignature: hashRange(moov.start, moov.end)
}
if (pendingFtyp) current.ranges.push({ start: pendingFtyp.start, end: pendingFtyp.end, zeroSize: pendingFtyp.zeroSize })
current.ranges.push({ start: moov.start, end: moov.end, zeroSize: moov.zeroSize })
}
function finishPeriod() {
if (!current || !current.hasMedia || !current.hasMdat) {
current = null
return
}
periods.push({
buffer: concatRanges(current.ranges),
moofMeta: current.moofMeta,
defaultMeta: current.defaultMeta,
warnings: [],
periodId: current.periodId,
periodOrder: current.periodOrder,
initSignature: current.initSignature,
mediaPayloadBytes: current.mediaPayloadBytes
})
current = null
}
for (var b = 0; b < boxes.length; b++) {
var box = boxes[b]
var operation = operationAt(box.start)
if (box.type === 'ftyp') {
pendingFtyp = box
continue
}
if (box.type === 'moov') {
if (current && current.hasMedia) finishPeriod()
newPeriod(box, operation)
continue
}
if (!current) {
if (box.type === 'moof' || box.type === 'mdat') {
warnings.push('发现缺少初始化段的媒体数据,已跳过;请从页面刷新后开始捕获。')
}
continue
}
current.ranges.push({ start: box.start, end: box.end, zeroSize: box.zeroSize })
if (box.type === 'moof') {
current.hasMedia = true
current.moofMeta.push(operation)
if (!current.defaultMeta) current.defaultMeta = operation
} else if (box.type === 'mdat') {
current.hasMdat = true
current.mediaPayloadBytes += Math.max(0, box.size - box.headerSize)
}
}
finishPeriod()
if (!periods.length) {
return [{
buffer: concatRanges([{ start: 0, end: totalBytes }]),
moofMeta: [],
defaultMeta: operations[0],
warnings: ['未能拆分初始化段;将按原始字节解析。'].concat(warnings),
periodId: String(captureTrack.id) + ':1',
periodOrder: 1,
initSignature: 'unknown',
mediaPayloadBytes: 0
}]
}
if (periods.length > 1) warnings.push('检测到 ' + periods.length + ' 个初始化周期;将按编码配置和时序识别插入段。')
for (var q = 0; q < periods.length; q++) periods[q].warnings = warnings.slice()
return periods
}
/*
* MiniMP4:仅实现本脚本实际需要的 ISO-BMFF/fMP4 子集。
* 支持:moov/trak/mdhd/hdlr/stsd/trex 与 moof/traf/tfhd/tfdt/trun/mdat。
* 不包含 MP4Box 的分段器、item metadata、字幕、文件写入器等无关模块。
*/
function miniParseMp4(prepared) {
var bytes = new Uint8Array(prepared.buffer)
var view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
function u8(p) { return view.getUint8(p) }
function u16(p) { return view.getUint16(p, false) }
function i32(p) { return view.getInt32(p, false) }
function u32(p) { return view.getUint32(p, false) }
function u64(p) { return u32(p) * 4294967296 + u32(p + 4) }
function typeAt(p) { return String.fromCharCode(bytes[p], bytes[p + 1], bytes[p + 2], bytes[p + 3]) }
function hex2(n) { return (n < 16 ? '0' : '') + n.toString(16) }
function boxes(start, end) {
var out = []
var p = start
while (p + 8 <= end) {
var size = u32(p)
var type = typeAt(p + 4)
var header = 8
if (size === 1) {
if (p + 16 > end) break
size = u64(p + 8)
header = 16
} else if (size === 0) {
size = end - p
}
if (!isFinite(size) || size < header || p + size > end) break
out.push({ type: type, start: p, end: p + size, size: size, header: header, data: p + header })
p += size
}
return out
}
function child(parent, type) {
var list = boxes(parent.data, parent.end)
for (var i = 0; i < list.length; i++) if (list[i].type === type) return list[i]
return null
}
function children(parent, type) {
var list = boxes(parent.data, parent.end)
if (!type) return list
return list.filter(function (box) { return box.type === type })
}
function flags(box) {
return (u8(box.data + 1) << 16) | (u8(box.data + 2) << 8) | u8(box.data + 3)
}
function lang(value) {
var a = ((value >> 10) & 31) + 96
var b = ((value >> 5) & 31) + 96
var c = (value & 31) + 96
return a < 97 || b < 97 || c < 97 ? 'und' : String.fromCharCode(a, b, c)
}
function slice(start, end) { return bytes.subarray(start, end) }
function hasFourCC(raw, value) {
var a = value.charCodeAt(0), b = value.charCodeAt(1), c = value.charCodeAt(2), d = value.charCodeAt(3)
for (var i = 4; i + 3 < raw.length; i++) {
if (raw[i] === a && raw[i + 1] === b && raw[i + 2] === c && raw[i + 3] === d) return true
}
return false
}
function codecOf(entry, raw) {
var type = entry.type
if ((type === 'avc1' || type === 'avc3') && raw.length >= 94) {
for (var p = 86; p + 12 <= raw.length;) {
var size = raw[p] * 16777216 + (raw[p + 1] << 16) + (raw[p + 2] << 8) + raw[p + 3]
var sub = String.fromCharCode(raw[p + 4], raw[p + 5], raw[p + 6], raw[p + 7])
if (size < 8 || p + size > raw.length) break
if (sub === 'avcC' && size >= 12) return type + '.' + hex2(raw[p + 9]) + hex2(raw[p + 10]) + hex2(raw[p + 11])
p += size
}
}
return type
}
function findSampleEntryChild(raw, wanted, start) {
var p = start || 8
while (p + 8 <= raw.length) {
var size = raw[p] * 16777216 + raw[p + 1] * 65536 + raw[p + 2] * 256 + raw[p + 3]
var type = String.fromCharCode(raw[p + 4], raw[p + 5], raw[p + 6], raw[p + 7])
if (size < 8 || p + size > raw.length) break
if (type === wanted) return { start: p, data: p + 8, end: p + size }
p += size
}
return null
}
function nalLengthSize(description) {
if (!description || !description.raw) return 4
var raw = description.raw
if (description.type === 'avc1' || description.type === 'avc3') {
var avcc = findSampleEntryChild(raw, 'avcC', 86)
if (avcc && avcc.data + 5 <= avcc.end) return (raw[avcc.data + 4] & 3) + 1
}
if (description.type === 'hvc1' || description.type === 'hev1') {
var hvcc = findSampleEntryChild(raw, 'hvcC', 86)
if (hvcc && hvcc.data + 22 <= hvcc.end) return (raw[hvcc.data + 21] & 3) + 1
}
return 4
}
function randomAccessFromSample(data, description) {
if (!data || !description) return null
var type = String(description.type || '').toLowerCase()
var avc = type === 'avc1' || type === 'avc3'
var hevc = type === 'hvc1' || type === 'hev1'
if (!avc && !hevc) return null
var lengthSize = nalLengthSize(description)
var p = 0
var sawVcl = false
while (p + lengthSize <= data.length) {
var size = 0
for (var i = 0; i < lengthSize; i++) size = size * 256 + data[p + i]
p += lengthSize
if (!size || p + size > data.length) break
var nalType = avc ? (data[p] & 31) : ((data[p] >> 1) & 63)
if (avc) {
if (nalType === 5) return true
if (nalType >= 1 && nalType <= 5) sawVcl = true
} else {
if (nalType >= 16 && nalType <= 21) return true
if (nalType <= 31) sawVcl = true
}
p += size
}
return sawVcl ? false : null
}
function parseStsd(stsd, kind) {
if (!stsd || stsd.data + 8 > stsd.end) return []
var count = u32(stsd.data + 4)
var p = stsd.data + 8
var entries = []
for (var i = 0; i < count && p + 8 <= stsd.end; i++) {
var size = u32(p)
if (size < 16 || p + size > stsd.end) break
var entry = { type: typeAt(p + 4), start: p, end: p + size, size: size }
var raw = slice(p, p + size)
var info = {
type: entry.type,
raw: raw,
encrypted: entry.type === 'encv' || entry.type === 'enca' || hasFourCC(raw, 'sinf'),
codec: codecOf(entry, raw),
width: 0,
height: 0,
sampleRate: 0,
channelCount: 0,
sampleSize: 16
}
if (kind === 'video' && size >= 36) {
info.width = u16(p + 32)
info.height = u16(p + 34)
} else if (kind === 'audio' && size >= 36) {
info.channelCount = u16(p + 24)
info.sampleSize = u16(p + 26) || 16
info.sampleRate = Math.floor(u32(p + 32) / 65536)
}
entries.push(info)
p += size
}
return entries
}
var top = boxes(0, bytes.length)
var moov = null
for (var ti = 0; ti < top.length; ti++) if (top[ti].type === 'moov') { moov = top[ti]; break }
if (!moov) throw new Error('未找到 moov 初始化段')
var trexMap = Object.create(null)
var mvex = child(moov, 'mvex')
if (mvex) {
var trexList = children(mvex, 'trex')
for (var tx = 0; tx < trexList.length; tx++) {
var trex = trexList[tx]
var tp = trex.data + 4
if (tp + 20 > trex.end) continue
trexMap[u32(tp)] = {
descriptionIndex: u32(tp + 4) || 1,
duration: u32(tp + 8),
size: u32(tp + 12),
flags: u32(tp + 16)
}
}
}
var trackMap = Object.create(null)
var trackList = []
var traks = children(moov, 'trak')
for (var tr = 0; tr < traks.length; tr++) {
var trak = traks[tr]
var tkhd = child(trak, 'tkhd')
var mdia = child(trak, 'mdia')
if (!tkhd || !mdia) continue
var tkFields = tkhd.data + 4
var trackId = u8(tkhd.data) === 1 ? u32(tkFields + 16) : u32(tkFields + 8)
var mdhd = child(mdia, 'mdhd')
var hdlr = child(mdia, 'hdlr')
var minf = child(mdia, 'minf')
var stbl = minf ? child(minf, 'stbl') : null
var stsd = stbl ? child(stbl, 'stsd') : null
if (!trackId || !mdhd || !hdlr || !stsd) continue
var handler = typeAt(hdlr.data + 8)
var kind = handler === 'vide' ? 'video' : (handler === 'soun' ? 'audio' : 'other')
if (kind === 'other') continue
var mdFields = mdhd.data + 4
var version = u8(mdhd.data)
var timescale = version === 1 ? u32(mdFields + 16) : u32(mdFields + 8)
var langOffset = version === 1 ? mdFields + 28 : mdFields + 16
var descriptions = parseStsd(stsd, kind)
if (!descriptions.length) continue
for (var ee = 0; ee < descriptions.length; ee++) {
if (descriptions[ee].encrypted) throw new Error('检测到加密样本条目 ' + descriptions[ee].type + ',脚本无法生成可播放的明文 MP4')
}
var first = descriptions[0]
var track = {
id: trackId,
kind: kind,
timescale: timescale || (kind === 'audio' ? 48000 : 90000),
language: langOffset + 2 <= mdhd.end ? lang(u16(langOffset)) : 'und',
descriptions: descriptions,
codec: first.codec || first.type,
width: kind === 'video' ? (first.width || Math.round(u32(tkhd.end - 8) / 65536)) : 0,
height: kind === 'video' ? (first.height || Math.round(u32(tkhd.end - 4) / 65536)) : 0,
sampleRate: kind === 'audio' ? first.sampleRate : 0,
channelCount: kind === 'audio' ? first.channelCount : 0,
sampleSize: kind === 'audio' ? first.sampleSize : 16,
samples: [],
nextDts: 0
}
trackMap[trackId] = track
trackList.push(track)
}
if (!trackList.length) throw new Error('moov 中没有可识别的音视频轨道')
function nextMdat(index) {
for (var i = index + 1; i < top.length; i++) {
if (top[i].type === 'mdat') return top[i]
if (top[i].type === 'moof' || top[i].type === 'moov') break
}
return null
}
var moofNumber = 0
for (var bi = 0; bi < top.length; bi++) {
var moof = top[bi]
if (moof.type !== 'moof') continue
moofNumber++
var mdat = nextMdat(bi)
var trafs = children(moof, 'traf')
var previousRunEnd = mdat ? mdat.data : moof.end
for (var fi = 0; fi < trafs.length; fi++) {
var traf = trafs[fi]
var tfhd = child(traf, 'tfhd')
if (!tfhd) continue
var tfFlags = flags(tfhd)
var fp = tfhd.data + 4
var id = u32(fp); fp += 4
var trackInfo = trackMap[id]
if (!trackInfo && trackList.length === 1) {
// 某些播放器重建初始化段后会改变 fragment track_ID;单轨 SourceBuffer 可安全映射到唯一轨道。
trackInfo = trackList[0]
trackMap[id] = trackInfo
}
if (!trackInfo) continue
var defaults = trexMap[id] || trexMap[trackInfo.id] || { descriptionIndex: 1, duration: 0, size: 0, flags: 0 }
var baseDataOffset = null
var descriptionIndex = defaults.descriptionIndex || 1
var defaultDuration = defaults.duration || 0
var defaultSize = defaults.size || 0
var defaultFlags = defaults.flags || 0
if (tfFlags & 0x000001) { baseDataOffset = u64(fp); fp += 8 }
if (tfFlags & 0x000002) { descriptionIndex = u32(fp) || 1; fp += 4 }
if (tfFlags & 0x000008) { defaultDuration = u32(fp); fp += 4 }
if (tfFlags & 0x000010) { defaultSize = u32(fp); fp += 4 }
if (tfFlags & 0x000020) { defaultFlags = u32(fp); fp += 4 }
var tfdt = child(traf, 'tfdt')
var decodeTime = trackInfo.nextDts || 0
if (tfdt) decodeTime = u8(tfdt.data) === 1 ? u64(tfdt.data + 4) : u32(tfdt.data + 4)
var base = baseDataOffset !== null ? baseDataOffset : moof.start
if (tfFlags & 0x020000) base = moof.start
var runs = children(traf, 'trun')
var runEnd = previousRunEnd
for (var ri = 0; ri < runs.length; ri++) {
var trun = runs[ri]
var runVersion = u8(trun.data)
var runFlags = flags(trun)
var rp = trun.data + 4
var count = u32(rp); rp += 4
var dataOffset = null
var firstFlags = null
if (runFlags & 0x000001) { dataOffset = i32(rp); rp += 4 }
if (runFlags & 0x000004) { firstFlags = u32(rp); rp += 4 }
var dataPos = dataOffset !== null ? base + dataOffset : runEnd
if (mdat && (dataPos < mdat.data || dataPos >= mdat.end)) dataPos = ri === 0 ? mdat.data : runEnd
for (var si = 0; si < count; si++) {
var duration = runFlags & 0x000100 ? u32(rp) : defaultDuration
if (runFlags & 0x000100) rp += 4
var size = runFlags & 0x000200 ? u32(rp) : defaultSize
if (runFlags & 0x000200) rp += 4
var sampleFlags = runFlags & 0x000400 ? u32(rp) : (si === 0 && firstFlags !== null ? firstFlags : defaultFlags)
if (runFlags & 0x000400) rp += 4
var cto = 0
if (runFlags & 0x000800) { cto = runVersion === 1 ? i32(rp) : u32(rp); rp += 4 }
if (!duration) duration = 1
if (!size) throw new Error('轨道 #' + id + ' 的样本大小为 0')
if (dataPos < 0 || dataPos + size > bytes.length) throw new Error('样本数据越界')
var desc = trackInfo.descriptions[Math.max(0, descriptionIndex - 1)] || trackInfo.descriptions[0]
var sampleData = slice(dataPos, dataPos + size)
var flagsKnown = !!(runFlags & 0x000400) || (si === 0 && firstFlags !== null) || defaultFlags !== 0
var flagSync = (sampleFlags & 0x00010000) === 0
var payloadSync = trackInfo.kind === 'video' ? randomAccessFromSample(sampleData, desc) : null
var syncKnown = trackInfo.kind === 'audio' || flagsKnown || payloadSync !== null
var isSync = trackInfo.kind === 'audio' || (flagsKnown ? flagSync : (payloadSync === null ? flagSync : payloadSync))
trackInfo.samples.push({
dts: decodeTime,
cts: decodeTime + cto,
duration: duration,
timescale: trackInfo.timescale,
isSync: isSync,
syncKnown: syncKnown,
description: desc.raw,
data: sampleData,
moofNumber: moofNumber
})
dataPos += size
decodeTime += duration
}
runEnd = dataPos
}
previousRunEnd = Math.max(previousRunEnd, runEnd)
trackInfo.nextDts = decodeTime
}
}
var output = []
for (var oi = 0; oi < trackList.length; oi++) if (trackList[oi].samples.length) output.push(trackList[oi])
if (!output.length) throw new Error('没有解析到 fMP4 媒体样本')
return output
}
function parseMp4Buffer(prepared) {
return Promise.resolve({ tracks: miniParseMp4(prepared), prepared: prepared })
}
function serializeBox(box) {
if (box instanceof Uint8Array) return box.slice()
if (box instanceof ArrayBuffer) return new Uint8Array(box.slice(0))
if (ArrayBuffer.isView(box)) return new Uint8Array(box.buffer, box.byteOffset, box.byteLength).slice()
throw new Error('无效的 MP4 样本描述')
}
function sampleMeta(sample, prepared) {
return sample && sample.moofNumber ? (prepared.moofMeta[sample.moofNumber - 1] || prepared.defaultMeta) : prepared.defaultMeta
}
function extractParsedTracks(parsed, captureTrack) {
var result = []
for (var t = 0; t < parsed.tracks.length; t++) {
var source = parsed.tracks[t]
var allSamples = []
for (var i = 0; i < source.samples.length; i++) {
var sample = source.samples[i]
var meta = sampleMeta(sample, parsed.prepared)
allSamples.push({
sourceDts: sample.dts / source.timescale,
sourceCts: sample.cts / source.timescale,
duration: Math.max(sample.duration / source.timescale, 1 / source.timescale),
isSync: sample.isSync !== false,
syncKnown: sample.syncKnown !== false,
description: sample.description,
descriptionKey: descriptionKey(sample.description),
data: sample.data,
appendSeq: meta ? meta.seq : 0,
meta: meta || parsed.prepared.defaultMeta,
moofNumber: sample.moofNumber || 0,
periodId: parsed.prepared.periodId,
periodOrder: parsed.prepared.periodOrder,
initSignature: parsed.prepared.initSignature
})
}
applyMseTimeline(allSamples, source.kind)
allSamples = allSamples.filter(function (sample) { return sample.keep !== false })
var byDescription = Object.create(null)
for (var si = 0; si < allSamples.length; si++) {
var key = allSamples[si].descriptionKey
if (!byDescription[key]) byDescription[key] = []
byDescription[key].push(allSamples[si])
}
for (var descKey in byDescription) {
var samples = deduplicateSamples(byDescription[descKey])
if (!samples.length) continue
var fourcc = readType(samples[0].description, 4).toLowerCase()
var family = /^(?:avc1|avc3)$/.test(fourcc) ? 'avc' : (/^(?:hvc1|hev1)$/.test(fourcc) ? 'hevc' : fourcc)
var decoderSignature = decoderConfigurationKey(samples[0].description, source.kind, {
family: family,
timescale: source.timescale,
width: source.width,
height: source.height,
sampleRate: source.sampleRate,
channelCount: source.channelCount
})
var trackSignature = [source.kind, decoderSignature].join('|')
for (var sk = 0; sk < samples.length; sk++) {
samples[sk].trackSignature = trackSignature
samples[sk].exactSignature = trackSignature
samples[sk].decoderSignature = decoderSignature
}
result.push({
kind: source.kind,
codec: source.codec || fourcc,
timescale: source.timescale,
width: source.width || 0,
height: source.height || 0,
sampleRate: source.sampleRate || 0,
channelCount: source.channelCount || 0,
sampleSize: source.sampleSize || 16,
language: source.language || 'und',
captureTrackId: captureTrack.id,
periodId: parsed.prepared.periodId,
periodOrder: parsed.prepared.periodOrder,
initSignature: parsed.prepared.initSignature,
trackSignature: trackSignature,
exactSignature: trackSignature,
decoderSignature: decoderSignature,
descriptionKey: descKey,
compatibilityKey: trackSignature,
samples: samples
})
}
}
return result
}
function subtractBufferedRanges(after, before) {
var output = []
after = after || []
before = before || []
for (var i = 0; i < after.length; i++) {
var pieces = [{ start: after[i][0], end: after[i][1] }]
for (var j = 0; j < before.length; j++) {
var next = []
for (var p = 0; p < pieces.length; p++) {
var piece = pieces[p]
var bs = before[j][0]
var be = before[j][1]
if (be <= piece.start + EPS || bs >= piece.end - EPS) {
next.push(piece)
continue
}
if (bs > piece.start + EPS) next.push({ start: piece.start, end: Math.min(bs, piece.end) })
if (be < piece.end - EPS) next.push({ start: Math.max(be, piece.start), end: piece.end })
}
pieces = next
}
for (var k = 0; k < pieces.length; k++) {
if (pieces[k].end > pieces[k].start + 0.005) output.push(pieces[k])
}
}
return output
}
function inferBufferedShift(meta, sourcePresentationStart, sourcePresentationEnd, predictedStart) {
if (!meta) return null
var expected = Math.max(0.001, sourcePresentationEnd - sourcePresentationStart)
var additions = subtractBufferedRanges(meta.bufferedAfter, meta.bufferedBefore)
var best = null
var bestScore = Infinity
for (var i = 0; i < additions.length; i++) {
var length = additions[i].end - additions[i].start
// 只有新增范围覆盖了大部分片段时才用它校正,避免重叠追加造成误判。
if (length < Math.min(expected * 0.55, Math.max(0.08, expected - 0.35))) continue
var score = Math.abs(length - expected) + Math.abs(additions[i].start - predictedStart) * 0.15
if (score < bestScore) {
best = additions[i]
bestScore = score
}
}
if (!best) return null
return best.start - sourcePresentationStart
}
function applyMseTimeline(samples, kind) {
var groups = []
var groupMap = Object.create(null)
for (var i = 0; i < samples.length; i++) {
var sample = samples[i]
// bufferedBefore/bufferedAfter 属于一次 appendBuffer,而不是单个 moof。
// 同一次 append 中可能包含多个 moof;按 appendSeq 分组可避免把每个 moof 都校正到同一时间起点。
var key = sample.meta && sample.meta.seq ? 'a' + sample.meta.seq : 'm' + (sample.moofNumber || i)
var group = groupMap[key]
if (!group) {
group = {
key: key,
meta: sample.meta || {},
samples: [],
order: sample.meta ? sample.meta.seq : i
}
groupMap[key] = group
groups.push(group)
}
group.samples.push(sample)
}
groups.sort(function (a, b) { return a.order - b.order })
var runningEnd = null
for (var g = 0; g < groups.length; g++) {
var current = groups[g]
var minDts = Infinity
var minCts = Infinity
var maxPresentationEnd = -Infinity
for (var j = 0; j < current.samples.length; j++) {
minDts = Math.min(minDts, current.samples[j].sourceDts)
minCts = Math.min(minCts, current.samples[j].sourceCts)
maxPresentationEnd = Math.max(maxPresentationEnd, current.samples[j].sourceCts + current.samples[j].duration)
}
var meta = current.meta || {}
var mode = meta.mode === 'sequence' ? 'sequence' : 'segments'
var offset = safeNumber(meta.timestampOffset, 0)
var shift
if (mode === 'sequence') {
var groupStart
if (runningEnd === null || meta.timestampOffsetChanged) groupStart = offset
else groupStart = runningEnd
shift = groupStart - minDts
} else {
shift = offset
}
var predictedPresentationStart = minCts + shift
var bufferedShift = inferBufferedShift(meta, minCts, maxPresentationEnd, predictedPresentationStart)
if (bufferedShift !== null && Math.abs(bufferedShift - shift) < Math.max(30, maxPresentationEnd - minCts + 5)) {
shift = bufferedShift
}
var groupEnd = -Infinity
for (var k = 0; k < current.samples.length; k++) {
var item = current.samples[k]
item.dts = item.sourceDts + shift
item.cts = item.sourceCts + shift
var ptsEnd = item.cts + item.duration
var windowStart = safeNumber(meta.appendWindowStart, 0)
var windowEnd = safeNumber(meta.appendWindowEnd, Infinity)
item.keep = ptsEnd > windowStart + EPS && item.cts < windowEnd - EPS
groupEnd = Math.max(groupEnd, item.dts + item.duration)
}
if (kind === 'video') keepDecodeStart(current.samples)
if (mode === 'sequence') runningEnd = groupEnd
else runningEnd = runningEnd === null ? groupEnd : Math.max(runningEnd, groupEnd)
}
}
function keepDecodeStart(samples) {
var firstKept = -1
for (var i = 0; i < samples.length; i++) {
if (samples[i].keep !== false) {
firstKept = i
break
}
}
if (firstKept <= 0 || samples[firstKept].isSync) return
for (var j = firstKept - 1; j >= 0; j--) {
samples[j].keep = true
if (samples[j].isSync) break
}
}
function quickSampleDataKey(sample) {
if (sample.__payloadKey) return sample.__payloadKey
var data = sample.data || new Uint8Array(0)
var hash = 2166136261
// 仅在 DTS 冲突或周期去重时调用;完整哈希可避免不同画面恰好拥有相同头尾字节。
for (var i = 0; i < data.byteLength; i++) {
hash ^= data[i]
hash = Math.imul(hash, 16777619)
}
sample.__payloadKey = data.byteLength + ':' + (hash >>> 0).toString(16)
return sample.__payloadKey
}
function samplesAreExactDuplicates(a, b) {
if (!a || !b) return false
if (Math.abs(a.dts - b.dts) >= 1e-6) return false
if (Math.abs(a.duration - b.duration) >= 1e-6) return false
if ((a.descriptionKey || '') !== (b.descriptionKey || '')) return false
return quickSampleDataKey(a) === quickSampleDataKey(b)
}
function deduplicateSamples(samples) {
samples.sort(function (a, b) {
if (a.dts !== b.dts) return a.dts - b.dts
if (a.appendSeq !== b.appendSeq) return a.appendSeq - b.appendSeq
return (a.moofNumber || 0) - (b.moofNumber || 0)
})
var output = []
for (var i = 0; i < samples.length; i++) {
var sample = samples[i]
var duplicateIndex = -1
// 同一 DTS 可能是清晰度切换、初始化段重建或时间戳复位,不能仅凭 DTS 删除。
// 只把描述、时长和媒体负载都一致的样本视为真正重复。
for (var j = output.length - 1; j >= 0; j--) {
if (Math.abs(output[j].dts - sample.dts) >= 1e-6) break
if (samplesAreExactDuplicates(output[j], sample)) {
duplicateIndex = j
break
}
}
if (duplicateIndex >= 0) {
if (sample.appendSeq >= output[duplicateIndex].appendSeq) output[duplicateIndex] = sample
} else {
output.push(sample)
}
}
return output
}
function cloneParsedTracks(parsedTracks) {
return parsedTracks.map(function (track) {
var copy = {}
for (var key in track) copy[key] = track[key]
copy.samples = track.samples.slice()
return copy
})
}
function mediaSampleStats(parsedTracks, kind) {
var count = 0
var bytes = 0
var sync = 0
var start = Infinity
var end = -Infinity
for (var i = 0; i < parsedTracks.length; i++) {
if (parsedTracks[i].kind !== kind) continue
for (var j = 0; j < parsedTracks[i].samples.length; j++) {
var sample = parsedTracks[i].samples[j]
count++
bytes += sample.data ? sample.data.byteLength : 0
if (sample.isSync) sync++
start = Math.min(start, sample.cts)
end = Math.max(end, sample.cts + sample.duration)
}
}
return { count: count, bytes: bytes, sync: sync, start: start, end: end, span: isFinite(start) && isFinite(end) ? end - start : 0 }
}
function mergeSampleIntervals(samples) {
var sorted = samples.slice().sort(function (a, b) { return a.cts - b.cts })
var intervals = []
for (var i = 0; i < sorted.length; i++) {
var start = sorted[i].cts
var end = sorted[i].cts + sorted[i].duration
if (!isFinite(start) || !isFinite(end) || end <= start) continue
var last = intervals.length ? intervals[intervals.length - 1] : null
var tolerance = Math.max(0.12, sorted[i].duration * 3)
if (last && start <= last.end + tolerance) last.end = Math.max(last.end, end)
else intervals.push({ start: start, end: end })
}
return intervals
}
function overlapWithIntervals(sample, intervals) {
var start = sample.cts
var end = sample.cts + sample.duration
for (var i = 0; i < intervals.length; i++) {
if (end > intervals[i].start - 0.08 && start < intervals[i].end + 0.08) return true
}
return false
}
function sampleInterval(samples) {
var start = Infinity
var end = -Infinity
for (var i = 0; i < samples.length; i++) {
start = Math.min(start, samples[i].cts)
end = Math.max(end, samples[i].cts + samples[i].duration)
}
return { start: start, end: end, span: isFinite(start) && isFinite(end) ? end - start : 0 }
}
function shiftSamples(samples, shift) {
if (!shift) return
for (var i = 0; i < samples.length; i++) {
samples[i].dts += shift
samples[i].cts += shift
samples[i].timelineFallback = true
}
}
function normalizeStrictGroup(group) {
var buckets = Object.create(null)
for (var i = 0; i < group.tracks.length; i++) {
var track = group.tracks[i]
var bucketKey = [track.captureSessionId || 0, track.captureTrackId || 0, track.periodId || ('p' + i)].join(':')
if (!buckets[bucketKey]) {
buckets[bucketKey] = {
key: bucketKey,
order: Infinity,
periodOrder: track.periodOrder || 0,
samples: [],
base: track
}
}
var bucket = buckets[bucketKey]
bucket.samples = bucket.samples.concat(track.samples)
for (var s = 0; s < track.samples.length; s++) bucket.order = Math.min(bucket.order, track.samples[s].appendSeq || Infinity)
}
var periods = Object.keys(buckets).map(function (key) {
var bucket = buckets[key]
bucket.samples = deduplicateSamples(bucket.samples)
if (!isFinite(bucket.order)) bucket.order = bucket.periodOrder || 0
return bucket
}).filter(function (bucket) { return bucket.samples.length })
periods.sort(function (a, b) {
if (a.order !== b.order) return a.order - b.order
return a.periodOrder - b.periodOrder
})
var normalized = []
var runningEnd = null
var previousPeriodOrder = null
var repaired = 0
var skippedDuplicates = 0
for (var p = 0; p < periods.length; p++) {
var period = periods[p]
var interval = sampleInterval(period.samples)
if (!isFinite(interval.start) || !isFinite(interval.end)) continue
var originalStart = interval.start
var originalGap = runningEnd === null ? 0 : originalStart - runningEnd
var overlap = runningEnd === null ? 0 : runningEnd - originalStart
var overlapThreshold = Math.max(0.25, Math.min(1.0, interval.span * 0.25))
// 某些站点在重建初始化段后让 tfdt 从 0 重新开始,而 buffered 增量又无法可靠取得。
// 若后续周期大面积压在前一周期上,按真实 append 顺序连续重建,避免十几 MB 被折叠成几秒。
if (runningEnd !== null && overlap > overlapThreshold) {
shiftSamples(period.samples, runningEnd - originalStart)
interval = sampleInterval(period.samples)
repaired++
}
var first = period.samples[0]
if (first) {
var skippedConfigPeriod = previousPeriodOrder !== null && period.periodOrder && previousPeriodOrder && period.periodOrder > previousPeriodOrder + 1
var realGap = originalGap > Math.max(0.6, (first.duration || 0.04) * 10)
first.hardBoundary = normalized.length === 0 || skippedConfigPeriod || realGap
}
for (var n = 0; n < period.samples.length; n++) normalized.push(period.samples[n])
runningEnd = runningEnd === null ? interval.end : Math.max(runningEnd, interval.end)
previousPeriodOrder = period.periodOrder || previousPeriodOrder
}
group.samples = deduplicateSamples(normalized)
group.repairedPeriods = repaired
group.skippedDuplicatePeriods = skippedDuplicates
var intervals = mergeSampleIntervals(group.samples)
group.intervals = intervals
group.span = 0
group.bytes = 0
group.duration = 0
for (var j = 0; j < group.samples.length; j++) {
group.bytes += group.samples[j].data.byteLength
group.duration += group.samples[j].duration
}
for (var k = 0; k < intervals.length; k++) group.span += intervals[k].end - intervals[k].start
}
function makeStrictGroups(list) {
var groups = Object.create(null)
for (var i = 0; i < list.length; i++) {
var key = list[i].decoderSignature || list[i].exactSignature || list[i].trackSignature || list[i].compatibilityKey || (list[i].kind + '|default')
if (!groups[key]) groups[key] = { key: key, tracks: [], duration: 0, span: 0, bytes: 0, samples: [] }
groups[key].tracks.push(list[i])
}
for (var name in groups) normalizeStrictGroup(groups[name])
return groups
}
function bestStrictGroup(groups, preferredIntervals) {
var best = null
for (var name in groups) {
var candidate = groups[name]
var overlap = 0
if (preferredIntervals && preferredIntervals.length) {
for (var s = 0; s < candidate.samples.length; s++) {
if (overlapWithIntervals(candidate.samples[s], preferredIntervals)) overlap += candidate.samples[s].duration
}
}
candidate.overlap = overlap
if (!best ||
(preferredIntervals && overlap > best.overlap + 0.01) ||
((!preferredIntervals || Math.abs(overlap - best.overlap) <= 0.01) && candidate.span > best.span + 0.01) ||
(Math.abs(candidate.span - best.span) <= 0.01 && candidate.duration > best.duration + 0.01) ||
(Math.abs(candidate.duration - best.duration) <= 0.01 && candidate.bytes > best.bytes)) best = candidate
}
return best
}
function canonicalDescription(samples) {
var choices = Object.create(null)
var best = null
for (var i = 0; i < samples.length; i++) {
var key = samples[i].descriptionKey || descriptionKey(samples[i].description)
if (!choices[key]) choices[key] = { key: key, description: samples[i].description, duration: 0, bytes: 0 }
choices[key].duration += samples[i].duration
choices[key].bytes += samples[i].data ? samples[i].data.byteLength : 0
}
for (var name in choices) {
var choice = choices[name]
if (!best || choice.duration > best.duration + 0.01 || (Math.abs(choice.duration - best.duration) <= 0.01 && choice.bytes > best.bytes)) best = choice
}
return best
}
function groupToMergedTrack(group, kind, clipIntervals) {
if (!group || !group.tracks.length) return null
var base = group.tracks[0]
var samples = group.samples
if (clipIntervals && clipIntervals.length) {
samples = samples.filter(function (sample) { return overlapWithIntervals(sample, clipIntervals) })
}
samples = deduplicateSamples(samples)
if (!samples.length) return null
// 同一解码配置中允许 btrt/pasp/色彩等非关键字段变化,但输出统一使用覆盖时间最长的 sample entry。
// 这样不会因为 YouTube 重建 init 段而产生几十个无意义的 stsd 条目。
var canonical = canonicalDescription(samples)
var normalizedSamples = []
for (var i = 0; i < samples.length; i++) {
var copy = {}
for (var field in samples[i]) copy[field] = samples[i][field]
if (canonical) {
copy.description = canonical.description
copy.descriptionKey = canonical.key
}
normalizedSamples.push(copy)
}
return {
kind: kind,
codec: base.codec,
timescale: base.timescale,
width: base.width || 0,
height: base.height || 0,
sampleRate: base.sampleRate || 0,
channelCount: base.channelCount || 0,
sampleSize: base.sampleSize || 16,
language: base.language || 'und',
compatibilityKey: group.key,
repairedPeriods: group.repairedPeriods || 0,
skippedDuplicatePeriods: group.skippedDuplicatePeriods || 0,
samples: normalizedSamples
}
}
function mergeTracksByKind(parsedTracks, session) {
var videoList = []
var audioList = []
for (var i = 0; i < parsedTracks.length; i++) {
if (parsedTracks[i].kind === 'video') videoList.push(parsedTracks[i])
else if (parsedTracks[i].kind === 'audio') audioList.push(parsedTracks[i])
}
var merged = []
var videoGroups = makeStrictGroups(videoList)
var videoNames = Object.keys(videoGroups)
var selectedVideo = bestStrictGroup(videoGroups, null)
var videoIntervals = selectedVideo ? selectedVideo.intervals : []
if (videoNames.length > 1 && session) {
addWarning(session, '检测到清晰度或解码配置切换;仅保留覆盖时间最长的严格视频配置,并按其时间范围裁剪音频,避免黑屏和音画错位。')
}
var videoTrack = groupToMergedTrack(selectedVideo, 'video', null)
if (selectedVideo && selectedVideo.repairedPeriods && session) {
addWarning(session, '检测到初始化周期时间戳复位;已按 appendBuffer 顺序修复 ' + selectedVideo.repairedPeriods + ' 个视频周期。')
}
if (videoTrack) merged.push(videoTrack)
var audioGroups = makeStrictGroups(audioList)
var audioNames = Object.keys(audioGroups)
var selectedAudio = bestStrictGroup(audioGroups, videoIntervals.length ? videoIntervals : null)
if (audioNames.length > 1 && session) {
addWarning(session, '检测到多个音频解码配置;已选择与视频时间范围重叠最多的一组。')
}
var audioTrack = groupToMergedTrack(selectedAudio, 'audio', videoIntervals.length ? videoIntervals : null)
if (selectedAudio && selectedAudio.repairedPeriods && session) {
addWarning(session, '检测到音频周期时间戳复位;已按 appendBuffer 顺序修复 ' + selectedAudio.repairedPeriods + ' 个音频周期。')
}
if (audioTrack) merged.push(audioTrack)
return merged
}
function descriptionKey(bytes) {
return bytes.byteLength + ':' + fnv1a(bytes)
}
function sampleEntryChildOffset(bytes, kind) {
if (kind === 'video') return bytes.byteLength >= 86 ? 86 : 8
if (kind === 'audio') {
if (bytes.byteLength < 36) return 8
var version = (bytes[16] << 8) | bytes[17]
if (version === 1 && bytes.byteLength >= 52) return 52
if (version === 2 && bytes.byteLength >= 72) return 72
return 36
}
return 8
}
function decoderConfigurationBoxes(bytes, kind) {
var wanted = kind === 'video'
? { avcC: 1, hvcC: 1, av1C: 1, vpcC: 1, esds: 1, dvcC: 1, dvvC: 1 }
: { esds: 1, dOps: 1, dfLa: 1, alac: 1, dac3: 1, dec3: 1, dac4: 1, mhaC: 1 }
var output = []
var queue = [{ start: sampleEntryChildOffset(bytes, kind), end: bytes.byteLength }]
while (queue.length) {
var range = queue.shift()
var p = range.start
while (p + 8 <= range.end) {
var size = bytes[p] * 16777216 + bytes[p + 1] * 65536 + bytes[p + 2] * 256 + bytes[p + 3]
var type = String.fromCharCode(bytes[p + 4], bytes[p + 5], bytes[p + 6], bytes[p + 7])
if (size === 1 || size < 8 || p + size > range.end) break
if (wanted[type]) output.push(type + ':' + size + ':' + fnv1a(bytes.subarray(p, p + size)))
if (type === 'wave' || type === 'sinf' || type === 'schi') queue.push({ start: p + 8, end: p + size })
p += size
}
}
return output
}
function decoderConfigurationKey(description, kind, info) {
var bytes = description instanceof Uint8Array ? description : new Uint8Array(description)
var type = bytes.byteLength >= 8 ? readType(bytes, 4).toLowerCase() : 'unknown'
var details = []
if (kind === 'video' && bytes.byteLength >= 36) {
details.push(((bytes[32] << 8) | bytes[33]) + 'x' + ((bytes[34] << 8) | bytes[35]))
} else if (kind === 'audio' && bytes.byteLength >= 36) {
var channels = (bytes[24] << 8) | bytes[25]
var rate = bytes[32] * 16777216 + bytes[33] * 65536 + bytes[34] * 256 + bytes[35]
details.push(Math.floor(rate / 65536) + 'x' + channels)
}
var configs = decoderConfigurationBoxes(bytes, kind)
// 未识别到 codec config 时才回退到完整 sample entry;已识别时忽略 btrt/pasp/colr 等非解码字段。
if (!configs.length) configs.push('full:' + descriptionKey(bytes))
return [
info && info.family ? info.family : type,
info && info.timescale ? info.timescale : 0,
details.join(','),
configs.join(',')
].join('|')
}
function trimVideoStartToSync(samples) {
if (!samples.length) return samples
samples = samples.slice().sort(function (a, b) {
if (a.dts !== b.dts) return a.dts - b.dts
return a.appendSeq - b.appendSeq
})
var regions = []
var current = []
var previous = null
for (var i = 0; i < samples.length; i++) {
var sample = samples[i]
var gap = previous ? sample.dts - (previous.dts + previous.duration) : 0
var boundary = !current.length || sample.hardBoundary || gap > Math.max(0.6, (previous ? previous.duration : sample.duration) * 10)
if (boundary && current.length) {
regions.push(current)
current = []
}
current.push(sample)
previous = sample
}
if (current.length) regions.push(current)
var output = []
for (var r = 0; r < regions.length; r++) {
var region = regions[r]
var syncIndex = -1
var anyKnown = false
for (var j = 0; j < region.length; j++) {
if (region[j].syncKnown !== false) anyKnown = true
if (region[j].isSync) {
syncIndex = j
break
}
}
// AVC/HEVC 或样本标志能确认关键帧时,从首个关键帧开始。
// 若 codec 没有可用的关键帧标志,则保留该连续区间,避免整个 YouTube 周期被误删。
if (syncIndex >= 0) {
for (var k = syncIndex; k < region.length; k++) output.push(region[k])
} else if (!anyKnown) {
for (var u = 0; u < region.length; u++) output.push(region[u])
}
}
return output
}
function prepareOutputTracks(tracks) {
// 确保每个视频初始化周期从关键帧开始,避免从 P/B 帧起播。
var videoStart = Infinity
for (var vt = 0; vt < tracks.length; vt++) {
if (tracks[vt].kind !== 'video') continue
tracks[vt].samples = trimVideoStartToSync(tracks[vt].samples)
for (var vs = 0; vs < tracks[vt].samples.length; vs++) videoStart = Math.min(videoStart, tracks[vt].samples[vs].cts)
}
if (tracks.some(function (track) { return track.kind === 'video' }) && !isFinite(videoStart)) {
throw new Error('视频轨没有可解码关键帧')
}
// 视频因关键帧对齐而向后裁剪时,同步去掉更早的音频,防止开头有声无画。
if (isFinite(videoStart)) {
for (var at = 0; at < tracks.length; at++) {
if (tracks[at].kind !== 'audio') continue
tracks[at].samples = tracks[at].samples.filter(function (sample) {
return sample.cts + sample.duration > videoStart - 0.05
})
}
}
var starts = []
var globalStart = Infinity
for (var i = 0; i < tracks.length; i++) {
var start = Infinity
for (var j = 0; j < tracks[i].samples.length; j++) start = Math.min(start, tracks[i].samples[j].cts)
starts.push(start)
globalStart = Math.min(globalStart, start)
}
if (!isFinite(globalStart)) throw new Error('没有可输出的媒体样本')
var minStart = Infinity
var maxStart = -Infinity
for (var st = 0; st < starts.length; st++) {
if (!isFinite(starts[st])) continue
minStart = Math.min(minStart, starts[st])
maxStart = Math.max(maxStart, starts[st])
}
// 只有音视频起点异常相差很大时才分别归零。正常的编码延迟仍保留。
var normalizeIndependently = isFinite(minStart) && isFinite(maxStart) && maxStart - minStart > MAX_AV_START_DRIFT
for (var t = 0; t < tracks.length; t++) {
var track = tracks[t]
if (!track.samples.length) throw new Error((track.kind === 'video' ? '视频' : '音频') + '轨没有可用样本')
var descriptions = []
var descriptionMap = Object.create(null)
var normalized = []
var trackBase = normalizeIndependently ? starts[t] : globalStart
if (!isFinite(trackBase)) trackBase = 0
for (var s = 0; s < track.samples.length; s++) {
var sample = track.samples[s]
if (!sample.description || !sample.data || !sample.data.byteLength) continue
var entryBytes = serializeBox(sample.description)
if (!entryBytes || entryBytes.byteLength < 16) continue
var key = descriptionKey(entryBytes)
var descriptionIndex = descriptionMap[key]
if (descriptionIndex === undefined) {
descriptions.push(entryBytes)
descriptionIndex = descriptions.length
descriptionMap[key] = descriptionIndex
}
normalized.push({
data: sample.data,
dtsSec: sample.dts - trackBase,
ctsSec: sample.cts - trackBase,
durationSec: sample.duration,
isSync: sample.isSync,
appendSeq: sample.appendSeq,
descriptionIndex: descriptionIndex,
periodId: sample.periodId || ''
})
}
normalized.sort(function (a, b) {
if (a.dtsSec !== b.dtsSec) return a.dtsSec - b.dtsSec
return a.appendSeq - b.appendSeq
})
if (!normalized.length || !descriptions.length) throw new Error((track.kind === 'video' ? '视频' : '音频') + '轨样本描述无效')
if (track.kind === 'video' && !normalized.some(function (sample) { return sample.isSync })) {
throw new Error('视频轨没有可解码关键帧')
}
track.sampleEntries = descriptions
track.samples = buildTrackMediaTimeline(normalized, track.timescale)
}
return tracks
}
function buildTrackMediaTimeline(samples, timescale) {
if (!samples.length) return []
var output = []
var mediaCursor = 0
var decodeStartSec = samples[0].dtsSec
var originalPresentationStartSec = Infinity
var compactPresentationStart = Infinity
var compactPresentationEnd = -Infinity
var group = {
presentationStartSec: 0,
presentationEndSec: 0,
mediaTime: 0,
mediaStart: 0,
mediaEnd: 0
}
for (var i = 0; i < samples.length; i++) {
var current = samples[i]
originalPresentationStartSec = Math.min(originalPresentationStartSec, current.ctsSec)
// 关键:只使用样本自己的 duration,不再把 DTS 跳变写成上一帧的超长时长。
// MSE 发生 discontinuity 后,输出轨道按实际样本时长连续重建。
var duration = Math.max(1, Math.round(current.durationSec * timescale))
var compositionOffset = Math.round((current.ctsSec - current.dtsSec) * timescale)
var item = {
data: current.data,
dtsSec: current.dtsSec,
ctsSec: current.ctsSec,
durationSec: current.durationSec,
mediaDts: mediaCursor,
mediaCts: mediaCursor + compositionOffset,
duration: duration,
isSync: current.isSync,
descriptionIndex: current.descriptionIndex,
group: group,
fileOffset: 0,
offsetPatch: null
}
output.push(item)
compactPresentationStart = Math.min(compactPresentationStart, item.mediaCts)
compactPresentationEnd = Math.max(compactPresentationEnd, item.mediaCts + duration)
mediaCursor += duration
}
var initialDelaySec = Math.max(0, originalPresentationStartSec)
var compactDurationSec = Math.max(0, (compactPresentationEnd - compactPresentationStart) / timescale)
group.presentationStartSec = initialDelaySec
group.presentationEndSec = initialDelaySec + compactDurationSec
group.mediaTime = Math.max(0, compactPresentationStart)
group.mediaEnd = mediaCursor
return output
}
function runLengthPairs(values) {
var result = []
if (!values.length) return result
var value = values[0]
var count = 1
for (var i = 1; i < values.length; i++) {
if (values[i] === value) count++
else {
result.push([count, value])
value = values[i]
count = 1
}
}
result.push([count, value])
return result
}
function BoxWriter(initialSize) {
this.buffer = new ArrayBuffer(initialSize || 65536)
this.view = new DataView(this.buffer)
this.bytes = new Uint8Array(this.buffer)
this.position = 0
this.stack = []
}
BoxWriter.prototype.ensure = function (count) {
if (this.position + count <= this.buffer.byteLength) return
var size = Math.max(this.buffer.byteLength * 2, this.position + count + 65536)
var next = new ArrayBuffer(size)
new Uint8Array(next).set(new Uint8Array(this.buffer, 0, this.position))
this.buffer = next
this.view = new DataView(next)
this.bytes = new Uint8Array(next)
}
BoxWriter.prototype.u8 = function (value) {
this.ensure(1)
this.view.setUint8(this.position, value & 255)
this.position += 1
}
BoxWriter.prototype.u16 = function (value) {
this.ensure(2)
this.view.setUint16(this.position, value & 65535, false)
this.position += 2
}
BoxWriter.prototype.i16 = function (value) {
this.ensure(2)
this.view.setInt16(this.position, value, false)
this.position += 2
}
BoxWriter.prototype.u24 = function (value) {
this.u8(value >>> 16)
this.u8(value >>> 8)
this.u8(value)
}
BoxWriter.prototype.u32 = function (value) {
this.ensure(4)
this.view.setUint32(this.position, value >>> 0, false)
this.position += 4
}
BoxWriter.prototype.i32 = function (value) {
this.ensure(4)
this.view.setInt32(this.position, value | 0, false)
this.position += 4
}
BoxWriter.prototype.u64 = function (value) {
var high = Math.floor(value / 4294967296)
var low = value - high * 4294967296
this.u32(high)
this.u32(low)
}
BoxWriter.prototype.string = function (value) {
this.ensure(value.length)
for (var i = 0; i < value.length; i++) this.u8(value.charCodeAt(i))
}
BoxWriter.prototype.data = function (value) {
var bytes = value instanceof Uint8Array ? value : new Uint8Array(value)
this.ensure(bytes.byteLength)
this.bytes.set(bytes, this.position)
this.position += bytes.byteLength
}
BoxWriter.prototype.fixed1616 = function (value) {
this.u32(Math.round(value * 65536))
}
BoxWriter.prototype.fixed88 = function (value) {
this.u16(Math.round(value * 256))
}
BoxWriter.prototype.begin = function (type) {
var start = this.position
this.u32(0)
this.string(type)
this.stack.push(start)
return start
}
BoxWriter.prototype.beginFull = function (type, version, flags) {
var start = this.begin(type)
this.u8(version || 0)
this.u24(flags || 0)
return start
}
BoxWriter.prototype.end = function () {
if (!this.stack.length) throw new Error('MP4 box 栈为空')
var start = this.stack.pop()
var size = this.position - start
this.patchU32(start, size)
return size
}
BoxWriter.prototype.patchU32 = function (position, value) {
this.view.setUint32(position, value >>> 0, false)
}
BoxWriter.prototype.patchU64 = function (position, value) {
var high = Math.floor(value / 4294967296)
var low = value - high * 4294967296
this.view.setUint32(position, high >>> 0, false)
this.view.setUint32(position + 4, low >>> 0, false)
}
BoxWriter.prototype.result = function () {
return new Uint8Array(this.buffer.slice(0, this.position))
}
function writeIdentityMatrix(writer) {
writer.u32(0x00010000); writer.u32(0); writer.u32(0)
writer.u32(0); writer.u32(0x00010000); writer.u32(0)
writer.u32(0); writer.u32(0); writer.u32(0x40000000)
}
function makeFtyp() {
var writer = new BoxWriter(64)
writer.begin('ftyp')
writer.string('isom')
writer.u32(0x200)
writer.string('isom')
writer.string('iso6')
writer.string('mp41')
writer.string('dash')
writer.end()
return writer.result()
}
function buildEdits(samples, timescale) {
if (!samples.length) return []
var group = samples[0].group
var edits = []
if (group.presentationStartSec > 0.001) {
edits.push({
segmentDuration: Math.max(1, Math.round(group.presentationStartSec * MOVIE_TIMESCALE)),
mediaTime: -1
})
}
var presentationDuration = group.presentationEndSec - group.presentationStartSec
if (!(presentationDuration > 0)) presentationDuration = group.mediaEnd / timescale
edits.push({
segmentDuration: Math.max(1, Math.round(presentationDuration * MOVIE_TIMESCALE)),
mediaTime: group.mediaTime
})
return edits
}
function writeMovieHeader(writer, duration) {
writer.beginFull('mvhd', 0, 0)
writer.u32(0); writer.u32(0)
writer.u32(MOVIE_TIMESCALE)
writer.u32(duration)
writer.fixed1616(1)
writer.fixed88(1)
writer.u16(0)
writer.u32(0); writer.u32(0)
writeIdentityMatrix(writer)
for (var i = 0; i < 6; i++) writer.u32(0)
writer.u32(3)
writer.end()
}
function writeTrack(writer, track, trackId, useCo64) {
var samples = track.samples
var edits = buildEdits(samples, track.timescale)
var trackDuration = 0
for (var e = 0; e < edits.length; e++) trackDuration += edits[e].segmentDuration
var mediaDuration = samples.length ? samples[samples.length - 1].mediaDts + samples[samples.length - 1].duration : 0
writer.begin('trak')
writer.beginFull('tkhd', 0, 0x000007)
writer.u32(0); writer.u32(0)
writer.u32(trackId)
writer.u32(0)
writer.u32(trackDuration)
writer.u32(0); writer.u32(0)
writer.u16(0)
writer.u16(0)
writer.fixed88(track.kind === 'audio' ? 1 : 0)
writer.u16(0)
writeIdentityMatrix(writer)
writer.fixed1616(track.kind === 'video' ? track.width : 0)
writer.fixed1616(track.kind === 'video' ? track.height : 0)
writer.end()
if (edits.length && !(edits.length === 1 && edits[0].mediaTime === 0)) {
var useVersion1 = false
for (var ei = 0; ei < edits.length; ei++) {
if (edits[ei].segmentDuration > 0xffffffff || edits[ei].mediaTime > 0x7fffffff) useVersion1 = true
}
writer.begin('edts')
writer.beginFull('elst', useVersion1 ? 1 : 0, 0)
writer.u32(edits.length)
for (var ej = 0; ej < edits.length; ej++) {
if (useVersion1) {
writer.u64(edits[ej].segmentDuration)
if (edits[ej].mediaTime < 0) {
writer.u32(0xffffffff); writer.u32(0xffffffff)
} else {
writer.u64(edits[ej].mediaTime)
}
} else {
writer.u32(edits[ej].segmentDuration)
writer.i32(edits[ej].mediaTime)
}
writer.i16(1)
writer.i16(0)
}
writer.end()
writer.end()
}
writer.begin('mdia')
writer.beginFull('mdhd', 0, 0)
writer.u32(0); writer.u32(0)
writer.u32(track.timescale)
writer.u32(mediaDuration)
writer.u16(0x55c4)
writer.u16(0)
writer.end()
writer.beginFull('hdlr', 0, 0)
writer.u32(0)
writer.string(track.kind === 'audio' ? 'soun' : 'vide')
writer.u32(0); writer.u32(0); writer.u32(0)
writer.string(track.kind === 'audio' ? 'SoundHandler' : 'VideoHandler')
writer.u8(0)
writer.end()
writer.begin('minf')
if (track.kind === 'audio') {
writer.beginFull('smhd', 0, 0)
writer.i16(0); writer.u16(0)
writer.end()
} else {
writer.beginFull('vmhd', 0, 1)
writer.u16(0)
writer.u16(0); writer.u16(0); writer.u16(0)
writer.end()
}
writer.begin('dinf')
writer.beginFull('dref', 0, 0)
writer.u32(1)
writer.beginFull('url ', 0, 1)
writer.end()
writer.end()
writer.end()
writer.begin('stbl')
writer.beginFull('stsd', 0, 0)
writer.u32(track.sampleEntries.length)
for (var sd = 0; sd < track.sampleEntries.length; sd++) writer.data(track.sampleEntries[sd])
writer.end()
var durations = samples.map(function (sample) { return sample.duration })
var sttsRuns = runLengthPairs(durations)
writer.beginFull('stts', 0, 0)
writer.u32(sttsRuns.length)
for (var st = 0; st < sttsRuns.length; st++) {
writer.u32(sttsRuns[st][0])
writer.u32(sttsRuns[st][1])
}
writer.end()
var compositionOffsets = samples.map(function (sample) { return sample.mediaCts - sample.mediaDts })
var hasCompositionOffsets = compositionOffsets.some(function (value) { return value !== 0 })
if (hasCompositionOffsets) {
var cttsVersion = compositionOffsets.some(function (value) { return value < 0 }) ? 1 : 0
var cttsRuns = runLengthPairs(compositionOffsets)
writer.beginFull('ctts', cttsVersion, 0)
writer.u32(cttsRuns.length)
for (var ct = 0; ct < cttsRuns.length; ct++) {
writer.u32(cttsRuns[ct][0])
if (cttsVersion === 1) writer.i32(cttsRuns[ct][1])
else writer.u32(cttsRuns[ct][1])
}
writer.end()
}
var stscEntries = []
var previousDescription = -1
for (var sc = 0; sc < samples.length; sc++) {
if (samples[sc].descriptionIndex !== previousDescription) {
stscEntries.push({
firstChunk: sc + 1,
samplesPerChunk: 1,
descriptionIndex: samples[sc].descriptionIndex
})
previousDescription = samples[sc].descriptionIndex
}
}
writer.beginFull('stsc', 0, 0)
writer.u32(stscEntries.length)
for (var se = 0; se < stscEntries.length; se++) {
writer.u32(stscEntries[se].firstChunk)
writer.u32(stscEntries[se].samplesPerChunk)
writer.u32(stscEntries[se].descriptionIndex)
}
writer.end()
writer.beginFull('stsz', 0, 0)
writer.u32(0)
writer.u32(samples.length)
for (var sz = 0; sz < samples.length; sz++) writer.u32(samples[sz].data.byteLength)
writer.end()
writer.beginFull(useCo64 ? 'co64' : 'stco', 0, 0)
writer.u32(samples.length)
for (var co = 0; co < samples.length; co++) {
samples[co].offsetPatch = writer.position
if (useCo64) writer.u64(0)
else writer.u32(0)
}
writer.end()
if (track.kind === 'video') {
var syncSamples = []
for (var ss = 0; ss < samples.length; ss++) {
if (samples[ss].isSync) syncSamples.push(ss + 1)
}
if (syncSamples.length && syncSamples.length !== samples.length) {
writer.beginFull('stss', 0, 0)
writer.u32(syncSamples.length)
for (var si = 0; si < syncSamples.length; si++) writer.u32(syncSamples[si])
writer.end()
}
}
writer.end()
writer.end()
writer.end()
writer.end()
return trackDuration
}
function makeMdatHeader(dataSize) {
var large = dataSize + 8 > 0xffffffff
var writer = new BoxWriter(16)
if (large) {
writer.u32(1)
writer.string('mdat')
writer.u64(dataSize + 16)
} else {
writer.u32(dataSize + 8)
writer.string('mdat')
}
return writer.result()
}
function buildFlatMp4(tracks) {
tracks = prepareOutputTracks(tracks)
if (!tracks.length) throw new Error('没有视频或音频轨道')
var totalDataSize = 0
var orderedSamples = []
for (var t = 0; t < tracks.length; t++) {
for (var s = 0; s < tracks[t].samples.length; s++) {
tracks[t].samples[s].trackOrder = t
totalDataSize += tracks[t].samples[s].data.byteLength
orderedSamples.push(tracks[t].samples[s])
}
}
orderedSamples.sort(function (a, b) {
if (a.dtsSec !== b.dtsSec) return a.dtsSec - b.dtsSec
return a.trackOrder - b.trackOrder
})
var useCo64 = totalDataSize > 0xf0000000
var ftyp = makeFtyp()
var moovWriter = new BoxWriter(Math.min(64 * 1024 * 1024, Math.max(65536, orderedSamples.length * (useCo64 ? 12 : 8))))
moovWriter.begin('moov')
var durationPatchTrack = []
var trackDurations = []
for (var i = 0; i < tracks.length; i++) {
trackDurations.push(0)
}
var estimatedMovieDuration = 0
for (var td = 0; td < tracks.length; td++) {
var edits = buildEdits(tracks[td].samples, tracks[td].timescale)
var duration = 0
for (var ed = 0; ed < edits.length; ed++) duration += edits[ed].segmentDuration
estimatedMovieDuration = Math.max(estimatedMovieDuration, duration)
}
writeMovieHeader(moovWriter, estimatedMovieDuration)
for (var tr = 0; tr < tracks.length; tr++) {
trackDurations[tr] = writeTrack(moovWriter, tracks[tr], tr + 1, useCo64)
}
moovWriter.end()
var mdatHeader = makeMdatHeader(totalDataSize)
var dataStart = ftyp.byteLength + moovWriter.position + mdatHeader.byteLength
var offset = dataStart
for (var os = 0; os < orderedSamples.length; os++) {
orderedSamples[os].fileOffset = offset
offset += orderedSamples[os].data.byteLength
}
for (var pt = 0; pt < tracks.length; pt++) {
for (var ps = 0; ps < tracks[pt].samples.length; ps++) {
var outputSample = tracks[pt].samples[ps]
if (useCo64) moovWriter.patchU64(outputSample.offsetPatch, outputSample.fileOffset)
else moovWriter.patchU32(outputSample.offsetPatch, outputSample.fileOffset)
}
}
var blobParts = [ftyp, moovWriter.result(), mdatHeader]
for (var bp = 0; bp < orderedSamples.length; bp++) blobParts.push(orderedSamples[bp].data)
return new Blob(blobParts, { type: 'video/mp4' })
}
function outputTrackStats(tracks) {
var result = { dataBytes: 0, videoSamples: 0, audioSamples: 0, expectVideo: false, expectAudio: false }
for (var i = 0; i < tracks.length; i++) {
if (tracks[i].kind === 'video') result.expectVideo = true
if (tracks[i].kind === 'audio') result.expectAudio = true
for (var j = 0; j < tracks[i].samples.length; j++) {
result.dataBytes += tracks[i].samples[j].data.byteLength
if (tracks[i].kind === 'video') result.videoSamples++
if (tracks[i].kind === 'audio') result.audioSamples++
}
}
return result
}
function scanBoxesRange(bytes, start, end) {
var output = []
var offset = start
while (offset + 8 <= end) {
var size = readU32(bytes, offset)
var header = 8
if (size === 1) {
if (offset + 16 > end) break
size = readU64(bytes, offset + 8)
header = 16
} else if (size === 0) size = end - offset
if (!isFinite(size) || size < header || offset + size > end) break
output.push({ type: readType(bytes, offset + 4), start: offset, end: offset + size, data: offset + header, headerSize: header })
offset += size
}
return output
}
function validateMp4Blob(blob, expected) {
return new Promise(function (resolve, reject) {
if (!blob || blob.size < 1024) return reject(new Error('输出 MP4 为空或过小'))
if (expected.dataBytes && blob.size < expected.dataBytes) return reject(new Error('输出文件小于媒体样本总量,可能只生成了 MP4 空壳'))
blob.arrayBuffer().then(function (buffer) {
var bytes = new Uint8Array(buffer)
var top = scanBoxesRange(bytes, 0, bytes.length)
var moov = null
var mdatBytes = 0
for (var i = 0; i < top.length; i++) {
if (top[i].type === 'moov') moov = top[i]
if (top[i].type === 'mdat') mdatBytes += top[i].end - top[i].data
}
if (!moov || !mdatBytes) throw new Error('输出 MP4 缺少 moov 或 mdat')
var traks = scanBoxesRange(bytes, moov.data, moov.end).filter(function (box) { return box.type === 'trak' })
var hasVideo = false, hasAudio = false, videoSamples = 0, audioSamples = 0
for (var t = 0; t < traks.length; t++) {
var mdia = rawChild(bytes, traks[t], 'mdia')
var hdlr = mdia ? rawChild(bytes, mdia, 'hdlr') : null
var minf = mdia ? rawChild(bytes, mdia, 'minf') : null
var stbl = minf ? rawChild(bytes, minf, 'stbl') : null
var stsz = stbl ? rawChild(bytes, stbl, 'stsz') : null
var stsd = stbl ? rawChild(bytes, stbl, 'stsd') : null
if (!hdlr || !stsz || !stsd || stsz.data + 12 > stsz.end || stsd.data + 8 > stsd.end) continue
var kind = readType(bytes, hdlr.data + 8)
var sampleCount = readU32(bytes, stsz.data + 8)
var entryCount = readU32(bytes, stsd.data + 4)
if (!entryCount) continue
if (kind === 'vide') { hasVideo = true; videoSamples += sampleCount }
if (kind === 'soun') { hasAudio = true; audioSamples += sampleCount }
}
if (expected.dataBytes && mdatBytes < expected.dataBytes) throw new Error('mdat 小于媒体样本总量')
if (expected.expectVideo && (!hasVideo || videoSamples < MIN_VIDEO_SAMPLES)) throw new Error('输出 MP4 缺少有效视频轨')
if (expected.expectAudio && (!hasAudio || audioSamples < 1)) throw new Error('输出 MP4 缺少有效音频样本')
resolve({ hasVideo: hasVideo, hasAudio: hasAudio, videoSamples: videoSamples, audioSamples: audioSamples, size: blob.size })
}).catch(reject)
})
}
function capturedMp4Bytes(session) {
var total = 0
if (!session || !session.tracks) return total
for (var i = 0; i < session.tracks.length; i++) {
var track = session.tracks[i]
for (var j = 0; j < track.operations.length; j++) {
var operation = track.operations[j]
if (operation.state === 'ok' && operation.data && isMp4Mime(operation.mime || track.initialMime)) total += operation.byteLength || operation.data.byteLength || 0
}
}
return total
}
async function buildAndValidateMp4(parsedTracks, session) {
var sourceVideo = mediaSampleStats(parsedTracks, 'video')
var sourceAudio = mediaSampleStats(parsedTracks, 'audio')
var capturedBytes = capturedMp4Bytes(session)
var parsedBytes = sourceVideo.bytes + sourceAudio.bytes
if (capturedBytes > 2 * 1024 * 1024 && parsedBytes < capturedBytes * 0.08) {
throw new Error('仅从 ' + formatBytes(capturedBytes) + ' 捕获数据中解析到 ' + formatBytes(parsedBytes) + ' 媒体样本,解析覆盖率异常,已停止生成短片')
}
var mergedTracks = mergeTracksByKind(cloneParsedTracks(parsedTracks), session)
if (!mergedTracks.length) throw new Error('没有可合并的兼容音视频轨道')
var mergedVideo = mediaSampleStats(mergedTracks, 'video')
if (!mergedVideo.count && sourceVideo.count) throw new Error('兼容性筛选后视频轨为空')
if (sourceVideo.bytes > 1024 * 1024 && mergedVideo.bytes && mergedVideo.bytes < sourceVideo.bytes * 0.12 && mergedVideo.span < Math.max(6, sourceVideo.span * 0.35)) {
throw new Error('视频筛选后仅剩 ' + formatBytes(mergedVideo.bytes) + ' / ' + formatBytes(sourceVideo.bytes) + ',疑似初始化周期被错误拆分,已停止生成异常短片')
}
// buildFlatMp4 会按关键帧和音视频起点裁剪样本;校验基准必须在裁剪后计算。
var blob = buildFlatMp4(mergedTracks)
var expected = outputTrackStats(mergedTracks)
await validateMp4Blob(blob, expected)
return blob
}
async function parseCaptureSession(session, warningSession) {
if (!session || !session.tracks || !session.tracks.length) return []
for (var p = 0; p < session.tracks.length; p++) {
if (session.tracks[p].pendingOperations.length) throw new Error('会话 #' + session.id + ' 仍有 appendBuffer 操作未完成,请稍后再导出')
}
var parsedTracks = []
var diagnostics = []
for (var i = 0; i < session.tracks.length; i++) {
var captureTrack = session.tracks[i]
if (!captureTrack.operations.length) continue
var mime = captureTrack.operations[0].mime || captureTrack.initialMime
if (!isMp4Mime(mime)) {
addWarning(warningSession || session, '检测到非 MP4 MSE 轨道;浏览器合并器只支持未加密 ISO BMFF/fMP4。')
continue
}
var preparedList
try {
preparedList = prepareMp4Inputs(captureTrack)
} catch (prepareError) {
diagnostics.push('轨道 #' + captureTrack.id + ':' + prepareError.message)
continue
}
var trackParsed = 0
for (var pi = 0; pi < preparedList.length; pi++) {
var prepared = preparedList[pi]
for (var w = 0; w < prepared.warnings.length; w++) addWarning(warningSession || session, prepared.warnings[w])
try {
var parsed = await parseMp4Buffer(prepared)
var parsedPayloadBytes = 0
for (var pt = 0; pt < parsed.tracks.length; pt++) {
for (var ps = 0; ps < parsed.tracks[pt].samples.length; ps++) {
parsedPayloadBytes += parsed.tracks[pt].samples[ps].data ? parsed.tracks[pt].samples[ps].data.byteLength : 0
}
}
if (prepared.mediaPayloadBytes > 512 * 1024 && parsedPayloadBytes < prepared.mediaPayloadBytes * 0.2) {
throw new Error('仅解析到 ' + formatBytes(parsedPayloadBytes) + ' / ' + formatBytes(prepared.mediaPayloadBytes) + ' 的 mdat 负载,疑似媒体盒跨 append 边界未完整重组')
}
var extracted = extractParsedTracks(parsed, captureTrack)
for (var et = 0; et < extracted.length; et++) extracted[et].captureSessionId = session.id
if (extracted.length) {
parsedTracks = parsedTracks.concat(extracted)
trackParsed += extracted.length
} else {
diagnostics.push('轨道 #' + captureTrack.id + ' 周期 ' + prepared.periodOrder + ':没有有效样本')
}
} catch (periodError) {
diagnostics.push('轨道 #' + captureTrack.id + ' 周期 ' + prepared.periodOrder + ':' + periodError.message)
addWarning(warningSession || session, '已跳过无法独立解析的 MP4 周期 #' + prepared.periodOrder + ':' + periodError.message)
}
}
if (!trackParsed) addWarning(warningSession || session, '轨道 #' + captureTrack.id + ' 未找到可用媒体周期。')
}
if (!parsedTracks.length && diagnostics.length) {
throw new Error('没有解析到 fMP4 媒体样本;' + diagnostics.slice(0, 3).join(';'))
}
return parsedTracks
}
function parsedKindStats(parsedTracks, kind) {
var stats = mediaSampleStats(parsedTracks, kind)
stats.duration = stats.span
return stats
}
function companionScore(primarySession, candidateSession, primaryStats, candidateStats) {
if (!candidateStats || !candidateStats.count || !(candidateStats.duration > 0)) return -Infinity
var score = 0
var affinity = timingAffinity(primarySession, candidateSession)
var overlap = activityOverlapRatio(primarySession, candidateSession)
score += affinity * 180
score += Math.min(1, overlap) * 80
if (primaryStats && primaryStats.duration > 0) {
var ratio = Math.min(primaryStats.duration, candidateStats.duration) /
Math.max(primaryStats.duration, candidateStats.duration)
score += ratio * 140
if (ratio < 0.55) score -= 180
else if (ratio < 0.75) score -= 45
}
var createdDiff = Math.abs((primarySession.createdAt || 0) - (candidateSession.createdAt || 0)) / 1000
score += Math.max(0, 35 - createdDiff * 2)
if (affinity < 0.18 && overlap < 0.18) score -= 160
return score
}
async function findCompanionTracks(primarySession, primaryTracks, missingKind) {
var oppositeKind = missingKind === 'audio' ? 'video' : 'audio'
var primaryStats = parsedKindStats(primaryTracks, oppositeKind)
var best = null
for (var i = 0; i < sessions.length; i++) {
var candidateSession = sessions[i]
if (candidateSession === primarySession || !candidateSession.totalBytes) continue
var hints = sessionKindHints(candidateSession)
if (!hints[missingKind]) continue
var roughAffinity = timingAffinity(primarySession, candidateSession)
var roughOverlap = activityOverlapRatio(primarySession, candidateSession)
var createdGap = Math.abs((primarySession.createdAt || 0) - (candidateSession.createdAt || 0))
// 短视频页面可能预加载很多会话。先按真实 appendBuffer 活动过滤,避免解析无关视频。
if (roughAffinity < 0.12 && roughOverlap < 0.12 && createdGap > 8000) continue
var candidateTracks
try {
candidateTracks = await parseCaptureSession(candidateSession, primarySession)
} catch (error) {
console.warn('[MSE Extract Lite] 忽略无法解析的候选会话 #' + candidateSession.id, error)
continue
}
var candidateStats = parsedKindStats(candidateTracks, missingKind)
var score = companionScore(primarySession, candidateSession, primaryStats, candidateStats)
if (!best || score > best.score) {
best = {
session: candidateSession,
tracks: candidateTracks.filter(function (track) { return track.kind === missingKind }),
stats: candidateStats,
score: score
}
}
}
if (!best || !best.tracks.length || best.score < 80) return null
return best
}
async function collectExportTracks(session) {
var parsedTracks = await parseCaptureSession(session, session)
if (!parsedTracks.length) throw new Error('没有解析到可重封装的未加密 MP4 音视频样本')
var videoStats = parsedKindStats(parsedTracks, 'video')
var audioStats = parsedKindStats(parsedTracks, 'audio')
var companions = []
if (videoStats.count && !audioStats.count) {
var audioCompanion = await findCompanionTracks(session, parsedTracks, 'audio')
if (audioCompanion) {
parsedTracks = parsedTracks.concat(audioCompanion.tracks)
companions.push(audioCompanion.session.id)
addWarning(session, '视频与音频位于不同 MediaSource 会话;已自动关联音频会话 #' + audioCompanion.session.id + '。')
}
} else if (audioStats.count && !videoStats.count) {
var videoCompanion = await findCompanionTracks(session, parsedTracks, 'video')
if (videoCompanion) {
parsedTracks = parsedTracks.concat(videoCompanion.tracks)
companions.push(videoCompanion.session.id)
addWarning(session, '音频与视频位于不同 MediaSource 会话;已自动关联视频会话 #' + videoCompanion.session.id + '。')
}
}
return { tracks: parsedTracks, companionSessionIds: companions }
}
async function exportMergedMp4(session) {
if (!session.tracks.length) throw new Error('当前会话没有轨道')
for (var pendingIndex = 0; pendingIndex < session.tracks.length; pendingIndex++) {
if (session.tracks[pendingIndex].pendingOperations.length) throw new Error('仍有 appendBuffer 操作未完成,请稍后再导出')
}
ui.mergeButton.disabled = true
ui.mergeButton.textContent = '正在分析原始媒体分片…'
try {
// YouTube 的原始轨道通常完整可播,但其分片布局会使样本级重封装丢失大量数据。
// 优先保留原始 moof/mdat,仅修复初始化信息和随机访问索引。
if (preferRawFragmentMux()) {
try {
var indexed = rawBuildIndexedFragmentedMp4(session)
ui.mergeButton.textContent = '正在校验原始分片与跳转索引…'
await validateIndexedFragmentedMp4(indexed)
if (indexed.hasVideo && !indexed.hasAudio) {
addWarning(session, '原始分片合并未找到可配对音频轨,输出文件将只有视频。')
}
addWarning(session, 'YouTube 已使用原始分片保留模式:未拆解媒体样本,并已生成 mfra/tfra 跳转索引。')
downloadBlob(indexed.blob, session.title + '-indexed.mp4')
return
} catch (rawError) {
if (rawError && rawError.captureIntegrity) {
console.error('[MSE Extract Lite] 捕获数据不完整,已取消生成输出文件', rawError)
addWarning(session, '捕获数据不完整:' + rawError.message + ';已取消生成 indexed/fixed MP4,请重新刷新页面捕获。')
throw rawError
}
console.warn('[MSE Extract Lite] 原始分片保留模式失败,回退到样本级重封装', rawError)
addWarning(session, '原始分片保留模式失败:' + rawError.message + ';已回退到普通重封装。')
}
}
ui.mergeButton.textContent = '正在解析并查找音轨…'
var collected = await collectExportTracks(session)
var parsedTracks = collected.tracks
var finalVideo = parsedKindStats(parsedTracks, 'video')
var finalAudio = parsedKindStats(parsedTracks, 'audio')
if (finalVideo.count && !finalAudio.count) {
addWarning(session, '未捕获到可配对音频轨;该站点可能在 Worker、跨域 iframe 或非 MSE 音频链路中播放声音。')
}
ui.mergeButton.textContent = '正在重建音视频时间轴…'
var blob = await buildAndValidateMp4(parsedTracks, session)
downloadBlob(blob, session.title + '-fixed.mp4')
} finally {
ui.mergeButton.disabled = false
ui.mergeButton.textContent = preferRawFragmentMux() ? '原始分片合并并下载 MP4' : '合并并下载 MP4'
requestUIRefresh()
}
}
window.__MSE_EXTRACT_LITE_DEBUG__ = {
miniParseMp4: miniParseMp4,
buildFlatMp4: buildFlatMp4,
validateMp4Blob: validateMp4Blob,
buildAndValidateMp4: buildAndValidateMp4,
buildTrackMediaTimeline: buildTrackMediaTimeline,
mergeTracksByKind: mergeTracksByKind,
mergeSampleIntervals: mergeSampleIntervals,
decoderConfigurationKey: decoderConfigurationKey,
normalizeStrictGroup: normalizeStrictGroup,
prepareOutputTracks: prepareOutputTracks,
prepareMp4Inputs: prepareMp4Inputs,
scanTopLevelBoxes: scanTopLevelBoxes,
sessionToJson: sessionToJson,
collectExportTracks: collectExportTracks,
timingAffinity: timingAffinity,
activityOverlapRatio: activityOverlapRatio,
bufferedMetrics: bufferedMetrics,
smartMediaElement: smartMediaElement,
smartJumpTick: smartJumpTick,
resetSmartJumpProgress: resetSmartJumpProgress,
syncSmartJumpMedia: syncSmartJumpMedia,
sessions: sessions,
targetSession: targetSession,
rawBuildTrackModel: rawBuildTrackModel,
rawBuildIndexedFragmentedMp4: rawBuildIndexedFragmentedMp4,
validateIndexedFragmentedMp4: validateIndexedFragmentedMp4
}
// 默认只显示捕获数量徽标;点击徽标后展开完整操作面板。
scheduleDirectScan()
requestUIRefresh()
setTimeout(requestUIRefresh, 1000)
setTimeout(requestUIRefresh, 3000)
startRuntimeScheduler()
console.info('[MSE Extract Lite 2.2.1] 已安装完整 appendBuffer 捕获、严格 mdat 校验、YouTube 索引输出、折叠徽标、直连识别与智能跳播')
})()