straight_to_video 0.0.11 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/app/assets/javascripts/straight-to-video.js +195 -80
- data/index.js +194 -79
- data/lib/straight_to_video/version.rb +1 -1
- data/package-lock.json +2 -2
- data/package.json +2 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: db199c55c9da4fd2927332a51b0bacc98c8ee0c4422218f9bdbbf2f280dd5648
|
|
4
|
+
data.tar.gz: 67c67d5170d3d7be451f2d050d2f6b2063f225f1c7dbbd8b3fc82462ddccee2f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7a85c866bf129896f1c49d5ae9e323f7e7e13cd52f8e92c8aa64d80bc716c1f5545f7f9376f3c4c9ca5a3fcfa188cd247f04cc90650c0b1a66f09eb23dd7cf22
|
|
7
|
+
data.tar.gz: ec227f671fd6e10196b91cbd9ccc46abed79a317ea0d822ebccf5aa36488098d2a52efe7770081e225c95d4c1e2c91b225642632e3c8e837b616121ab75fe135
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.13
|
|
4
|
+
|
|
5
|
+
* Fast-start already-compliant MP4 and MOV uploads without re-encoding their media packets.
|
|
6
|
+
* Preserve compatible 44.1 kHz and 48 kHz AAC audio on the compliant-media path.
|
|
7
|
+
|
|
8
|
+
## 0.0.12
|
|
9
|
+
|
|
10
|
+
* Bound the WebCodecs encoder queue so long videos cannot exhaust WebKit memory.
|
|
11
|
+
* Keep browser encoding at or below the source video's bitrate.
|
|
12
|
+
|
|
3
13
|
## 0.0.11
|
|
4
14
|
|
|
5
15
|
* Work around WebKit labeling the requested first HEVC keyframe as a delta packet.
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
// straight-to-video@0.0.
|
|
1
|
+
// straight-to-video@0.0.13 vendored by the straight_to_video gem
|
|
2
2
|
// straight-to-video - https://github.com/searlsco/straight-to-video
|
|
3
3
|
|
|
4
4
|
// ----- External imports -----
|
|
5
5
|
import {
|
|
6
6
|
Input, ALL_FORMATS, BlobSource, AudioBufferSink,
|
|
7
7
|
Output, Mp4OutputFormat, BufferTarget,
|
|
8
|
-
AudioSampleSource, AudioSample, EncodedVideoPacketSource, EncodedPacket, EncodedPacketSink, VideoSampleSink
|
|
8
|
+
AudioSampleSource, AudioSample, EncodedVideoPacketSource, EncodedPacket, EncodedPacketSink, VideoSampleSink,
|
|
9
|
+
EncodedAudioPacketSource
|
|
9
10
|
} from 'mediabunny'
|
|
10
11
|
|
|
11
12
|
// ----- Constants -----
|
|
@@ -14,6 +15,7 @@ const TARGET_VIDEO_BITRATE = 12_000_000
|
|
|
14
15
|
const TARGET_AUDIO_BITRATE = 96_000
|
|
15
16
|
const TARGET_AUDIO_SR = 48_000
|
|
16
17
|
const TARGET_AUDIO_CHANNELS = 2
|
|
18
|
+
const MAX_VIDEO_ENCODER_QUEUE_SIZE = 4
|
|
17
19
|
|
|
18
20
|
// ----- Video metadata probe -----
|
|
19
21
|
async function probeVideo (file) {
|
|
@@ -34,34 +36,55 @@ async function probeVideo (file) {
|
|
|
34
36
|
})
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
async function
|
|
39
|
+
async function estimateSourceVideoStats (file) {
|
|
38
40
|
try {
|
|
39
41
|
const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS })
|
|
40
42
|
const tracks = await input.getTracks()
|
|
41
43
|
const video = tracks.find(t => typeof t.isVideoTrack === 'function' && t.isVideoTrack())
|
|
42
|
-
if (!video) return 0
|
|
44
|
+
if (!video) return { fps: 0, bitrate: 0, codec: null, rotation: 0 }
|
|
43
45
|
const sink = new EncodedPacketSink(video)
|
|
44
46
|
const durations = []
|
|
47
|
+
let firstTimestamp = Infinity
|
|
48
|
+
let lastTimestamp = -Infinity
|
|
49
|
+
let totalBytes = 0
|
|
45
50
|
for await (const packet of sink.packets(undefined, undefined, { metadataOnly: true })) {
|
|
46
|
-
const
|
|
47
|
-
if (packet.timestamp >= 0 && Number.isFinite(
|
|
51
|
+
const duration = Number(packet?.duration)
|
|
52
|
+
if (packet.timestamp >= 0 && Number.isFinite(duration) && duration > 0) {
|
|
53
|
+
durations.push(duration)
|
|
54
|
+
firstTimestamp = Math.min(firstTimestamp, packet.timestamp)
|
|
55
|
+
lastTimestamp = Math.max(lastTimestamp, packet.timestamp + duration)
|
|
56
|
+
totalBytes += packet.byteLength
|
|
57
|
+
}
|
|
48
58
|
if (durations.length >= 120) break
|
|
49
59
|
}
|
|
50
|
-
if (!durations.length) return 0
|
|
60
|
+
if (!durations.length) return { fps: 0, bitrate: 0, codec: video.codec, rotation: video.rotation }
|
|
51
61
|
durations.sort((a, b) => a - b)
|
|
52
|
-
const
|
|
53
|
-
|
|
62
|
+
const duration = durations[Math.floor(durations.length / 2)]
|
|
63
|
+
const sampledDuration = lastTimestamp - firstTimestamp
|
|
64
|
+
return {
|
|
65
|
+
fps: Number.isFinite(duration) && duration > 0 ? (1 / duration) : 0,
|
|
66
|
+
bitrate: sampledDuration > 0 ? (totalBytes * 8 / sampledDuration) : 0,
|
|
67
|
+
codec: video.codec,
|
|
68
|
+
rotation: video.rotation
|
|
69
|
+
}
|
|
54
70
|
} catch (_) {
|
|
55
|
-
return 0
|
|
71
|
+
return { fps: 0, bitrate: 0, codec: null, rotation: 0 }
|
|
56
72
|
}
|
|
57
73
|
}
|
|
58
74
|
|
|
59
|
-
async function
|
|
75
|
+
async function determineEncodingPlan (file, { width, height, duration }) {
|
|
60
76
|
const maxFps = Math.max(width, height) <= 1920 ? 30 : 60
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
const source = await estimateSourceVideoStats(file)
|
|
78
|
+
const copyVideo = ['avc', 'hevc'].includes(source.codec) &&
|
|
79
|
+
source.rotation === 0 &&
|
|
80
|
+
Math.max(width, height) <= MAX_LONG_SIDE &&
|
|
81
|
+
source.fps >= 23 && source.fps <= 60.1 &&
|
|
82
|
+
Number(duration) > 0 && (file.size * 8 / Number(duration)) <= TARGET_VIDEO_BITRATE
|
|
83
|
+
return {
|
|
84
|
+
fps: copyVideo ? source.fps : (maxFps === 30 ? 30 : (source.fps >= 45 ? 60 : 30)),
|
|
85
|
+
bitrate: source.bitrate > 0 ? Math.min(TARGET_VIDEO_BITRATE, Math.round(source.bitrate)) : TARGET_VIDEO_BITRATE,
|
|
86
|
+
copyVideo
|
|
87
|
+
}
|
|
65
88
|
}
|
|
66
89
|
|
|
67
90
|
// ----- Audio helpers -----
|
|
@@ -125,8 +148,8 @@ async function canOptimizeVideo (file) {
|
|
|
125
148
|
const scale = Math.min(1, MAX_LONG_SIDE / Math.max(2, long))
|
|
126
149
|
const targetWidth = Math.max(2, Math.round(width * scale))
|
|
127
150
|
const targetHeight = Math.max(2, Math.round(height * scale))
|
|
128
|
-
const
|
|
129
|
-
const sup = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight,
|
|
151
|
+
const plan = await determineEncodingPlan(file, { width, height, duration })
|
|
152
|
+
const sup = plan.copyVideo || await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, ...plan }).then(() => true).catch(() => false)
|
|
130
153
|
if (!sup) return { ok: false, reason: 'unsupported-video-config', message: 'No supported encoder configuration for this resolution on this device.' }
|
|
131
154
|
|
|
132
155
|
// Header sniffing when file.type is empty/incorrect
|
|
@@ -142,7 +165,7 @@ async function canOptimizeVideo (file) {
|
|
|
142
165
|
const hasEbml = buf.length >= 4 && buf[0] === 0x1A && buf[1] === 0x45 && buf[2] === 0xDF && buf[3] === 0xA3
|
|
143
166
|
if (!(hasFtyp || hasEbml)) return { ok: false, reason: 'unknown-container', message: 'Unrecognized container; expected MP4/MOV or WebM.' }
|
|
144
167
|
}
|
|
145
|
-
return { ok: true, reason: 'ok', message: 'ok', plan: { width: targetWidth, height: targetHeight,
|
|
168
|
+
return { ok: true, reason: 'ok', message: 'ok', plan: { width: targetWidth, height: targetHeight, ...plan } }
|
|
146
169
|
} catch (e) {
|
|
147
170
|
return { ok: false, reason: 'probe-failed', message: String(e?.message || e) }
|
|
148
171
|
}
|
|
@@ -156,17 +179,25 @@ async function optimizeVideo (file, { onProgress } = {}) {
|
|
|
156
179
|
const feas = await canOptimizeVideo(file)
|
|
157
180
|
if (!feas.ok) return { changed: false, file }
|
|
158
181
|
|
|
182
|
+
if (feas.plan.copyVideo) {
|
|
183
|
+
const fastStarted = await fastStartMp4(file)
|
|
184
|
+
if (fastStarted) {
|
|
185
|
+
if (typeof onProgress === 'function') onProgress(1)
|
|
186
|
+
return { changed: true, file: fastStarted }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
159
190
|
const srcMeta = await probeVideo(file)
|
|
160
191
|
const newFile = await encodeVideo({ file, srcMeta: { w: srcMeta.width, h: srcMeta.height, duration: srcMeta.duration }, plan: feas.plan, onProgress })
|
|
161
192
|
return { changed: true, file: newFile }
|
|
162
193
|
}
|
|
163
194
|
|
|
164
|
-
async function selectVideoEncoderConfig ({ width, height, fps }) {
|
|
165
|
-
const hevc = { codec: 'hvc1.1.4.L123.B0', width, height, framerate: fps, bitrate
|
|
195
|
+
async function selectVideoEncoderConfig ({ width, height, fps, bitrate = TARGET_VIDEO_BITRATE }) {
|
|
196
|
+
const hevc = { codec: 'hvc1.1.4.L123.B0', width, height, framerate: fps, bitrate, hardwareAcceleration: 'prefer-hardware', hevc: { format: 'hevc' } }
|
|
166
197
|
const supH = await VideoEncoder.isConfigSupported(hevc).catch(() => ({ supported: false }))
|
|
167
198
|
if (supH.supported) return { codecId: 'hevc', config: supH.config }
|
|
168
199
|
|
|
169
|
-
const avc = { codec: 'avc1.64002A', width, height, framerate: fps, bitrate
|
|
200
|
+
const avc = { codec: 'avc1.64002A', width, height, framerate: fps, bitrate, hardwareAcceleration: 'prefer-hardware', avc: { format: 'avc' } }
|
|
170
201
|
const supA = await VideoEncoder.isConfigSupported(avc)
|
|
171
202
|
return { codecId: 'avc', config: supA.config }
|
|
172
203
|
}
|
|
@@ -175,6 +206,12 @@ function shouldDecodeViaVideoElement () {
|
|
|
175
206
|
return (navigator?.vendor || '').includes('Apple')
|
|
176
207
|
}
|
|
177
208
|
|
|
209
|
+
async function applyVideoEncoderBackpressure (encoder) {
|
|
210
|
+
while (encoder.encodeQueueSize > MAX_VIDEO_ENCODER_QUEUE_SIZE) {
|
|
211
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
178
215
|
async function waitForFrameReady (video, budgetMs) {
|
|
179
216
|
if (typeof video.requestVideoFrameCallback !== 'function') return false
|
|
180
217
|
return await new Promise((resolve) => {
|
|
@@ -239,6 +276,7 @@ async function encodeFramesViaVideoElement ({ file, durationCfr, step, frames, c
|
|
|
239
276
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
240
277
|
ve.encode(vf, { keyFrame: i === 0 })
|
|
241
278
|
vf.close()
|
|
279
|
+
await applyVideoEncoderBackpressure(ve)
|
|
242
280
|
|
|
243
281
|
if (typeof onProgress === 'function') {
|
|
244
282
|
try {
|
|
@@ -285,6 +323,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
285
323
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
286
324
|
ve.encode(vf, { keyFrame: i === 0 })
|
|
287
325
|
vf.close()
|
|
326
|
+
await applyVideoEncoderBackpressure(ve)
|
|
288
327
|
|
|
289
328
|
if (typeof onProgress === 'function') {
|
|
290
329
|
try {
|
|
@@ -311,6 +350,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
311
350
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
312
351
|
ve.encode(vf, { keyFrame: i === 0 })
|
|
313
352
|
vf.close()
|
|
353
|
+
await applyVideoEncoderBackpressure(ve)
|
|
314
354
|
|
|
315
355
|
if (typeof onProgress === 'function') {
|
|
316
356
|
try {
|
|
@@ -373,6 +413,35 @@ function _scanBoxes (u8, start, end) {
|
|
|
373
413
|
return out
|
|
374
414
|
}
|
|
375
415
|
|
|
416
|
+
async function fastStartMp4 (file) {
|
|
417
|
+
const u8 = new Uint8Array(await file.arrayBuffer())
|
|
418
|
+
const boxes = _scanBoxes(u8, 0, u8.byteLength)
|
|
419
|
+
const ftyp = boxes.find(b => b.type === 'ftyp')
|
|
420
|
+
const mdat = boxes.find(b => b.type === 'mdat')
|
|
421
|
+
const moov = boxes.find(b => b.type === 'moov')
|
|
422
|
+
if (!ftyp || !mdat || !moov || ftyp.offset !== 0 || moov.offset < mdat.offset) return null
|
|
423
|
+
|
|
424
|
+
const relocatedMoov = u8.slice(moov.offset, moov.offset + moov.size)
|
|
425
|
+
const dv = new DataView(relocatedMoov.buffer, relocatedMoov.byteOffset, relocatedMoov.byteLength)
|
|
426
|
+
for (let i = 0; i < relocatedMoov.byteLength - 16; i++) {
|
|
427
|
+
if (relocatedMoov[i + 4] !== 0x73 || relocatedMoov[i + 5] !== 0x74 || relocatedMoov[i + 6] !== 0x63 || relocatedMoov[i + 7] !== 0x6f) continue
|
|
428
|
+
const count = dv.getUint32(i + 12)
|
|
429
|
+
if (i + 16 + count * 4 > relocatedMoov.byteLength) return null
|
|
430
|
+
for (let j = 0; j < count; j++) {
|
|
431
|
+
const offset = i + 16 + j * 4
|
|
432
|
+
dv.setUint32(offset, dv.getUint32(offset) + moov.size)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const payload = _concat(
|
|
437
|
+
u8.slice(ftyp.offset, ftyp.offset + ftyp.size),
|
|
438
|
+
relocatedMoov,
|
|
439
|
+
...boxes.filter(box => box !== ftyp && box !== moov).map(box => u8.slice(box.offset, box.offset + box.size))
|
|
440
|
+
)
|
|
441
|
+
const dot = file.name.lastIndexOf('.')
|
|
442
|
+
return new File([payload], `${file.name.substring(0, dot)}-optimized.mp4`, { type: 'video/mp4', lastModified: Date.now() })
|
|
443
|
+
}
|
|
444
|
+
|
|
376
445
|
function _esdTag (tag, payload) {
|
|
377
446
|
return _concat(new Uint8Array([tag, 0x80, 0x80, 0x80, payload.byteLength]), payload)
|
|
378
447
|
}
|
|
@@ -628,76 +697,122 @@ async function encodeVideo ({ file, srcMeta, plan, onProgress }) {
|
|
|
628
697
|
const targetWidth = Math.max(2, Number(plan?.width) || Math.round(w * scale))
|
|
629
698
|
const targetHeight = Math.max(2, Number(plan?.height) || Math.round(h * scale))
|
|
630
699
|
|
|
631
|
-
const
|
|
700
|
+
const fallbackPlan = plan || await determineEncodingPlan(file, { width: w, height: h, duration: durationCfr })
|
|
701
|
+
const targetFps = Math.max(1, Number(fallbackPlan.fps))
|
|
632
702
|
const step = 1 / Math.max(1, targetFps)
|
|
633
703
|
const frames = Math.max(1, Math.floor(durationCfr / step))
|
|
634
704
|
|
|
635
705
|
const output = new Output({ format: new Mp4OutputFormat({ fastStart: 'in-memory' }), target: new BufferTarget() })
|
|
636
|
-
const
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
sampleRate: TARGET_AUDIO_SR,
|
|
655
|
-
onEncodedPacket: (_packet, meta) => {
|
|
656
|
-
const aot = 2; const idx = 3; const b0 = (aot << 3) | (idx >> 1); const b1 = ((idx & 1) << 7) | (TARGET_AUDIO_CHANNELS << 3)
|
|
657
|
-
meta.decoderConfig = { codec: 'mp4a.40.2', numberOfChannels: TARGET_AUDIO_CHANNELS, sampleRate: TARGET_AUDIO_SR, description: new Uint8Array([b0, b1]) }
|
|
706
|
+
const passthroughInput = fallbackPlan.copyVideo ? new Input({ source: new BlobSource(file), formats: ALL_FORMATS }) : null
|
|
707
|
+
const passthroughTracks = passthroughInput ? await passthroughInput.getTracks() : []
|
|
708
|
+
const passthroughTrack = passthroughTracks.find(t => typeof t.isVideoTrack === 'function' && t.isVideoTrack())
|
|
709
|
+
const passthroughAudioTrack = passthroughTrack && passthroughTracks.find(t =>
|
|
710
|
+
typeof t.isAudioTrack === 'function' && t.isAudioTrack() &&
|
|
711
|
+
t.codec === 'aac' && [1, 2].includes(t.numberOfChannels) && [44_100, 48_000].includes(t.sampleRate)
|
|
712
|
+
)
|
|
713
|
+
const encoder = passthroughTrack ? null : await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps: targetFps, bitrate: fallbackPlan.bitrate })
|
|
714
|
+
const videoTrack = new EncodedVideoPacketSource(passthroughTrack?.codec || encoder.codecId)
|
|
715
|
+
output.addVideoTrack(videoTrack, passthroughTrack ? { rotation: passthroughTrack.rotation } : { frameRate: targetFps })
|
|
716
|
+
|
|
717
|
+
let audioBuffer = null
|
|
718
|
+
if (!passthroughAudioTrack) {
|
|
719
|
+
const _warn = console.warn
|
|
720
|
+
console.warn = (...args) => {
|
|
721
|
+
const m = args && args[0]
|
|
722
|
+
if (typeof m === 'string' && m.includes('Unsupported audio codec') && m.includes('apac')) return
|
|
723
|
+
_warn.apply(console, args)
|
|
658
724
|
}
|
|
659
|
-
|
|
725
|
+
audioBuffer = await decodeAudioPCM(file, { duration: durationCfr })
|
|
726
|
+
console.warn = _warn
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const audioSource = passthroughAudioTrack
|
|
730
|
+
? new EncodedAudioPacketSource('aac')
|
|
731
|
+
: new AudioSampleSource({
|
|
732
|
+
codec: 'aac',
|
|
733
|
+
bitrate: TARGET_AUDIO_BITRATE,
|
|
734
|
+
bitrateMode: 'constant',
|
|
735
|
+
numberOfChannels: TARGET_AUDIO_CHANNELS,
|
|
736
|
+
sampleRate: TARGET_AUDIO_SR,
|
|
737
|
+
onEncodedPacket: (_packet, meta) => {
|
|
738
|
+
const aot = 2; const idx = 3; const b0 = (aot << 3) | (idx >> 1); const b1 = ((idx & 1) << 7) | (TARGET_AUDIO_CHANNELS << 3)
|
|
739
|
+
meta.decoderConfig = { codec: 'mp4a.40.2', numberOfChannels: TARGET_AUDIO_CHANNELS, sampleRate: TARGET_AUDIO_SR, description: new Uint8Array([b0, b1]) }
|
|
740
|
+
}
|
|
741
|
+
})
|
|
660
742
|
output.addAudioTrack(audioSource)
|
|
661
743
|
|
|
662
744
|
await output.start()
|
|
745
|
+
const passthroughAudioPromise = passthroughAudioTrack && (async () => {
|
|
746
|
+
const sink = new EncodedPacketSink(passthroughAudioTrack)
|
|
747
|
+
const decoderConfig = await passthroughAudioTrack.getDecoderConfig()
|
|
748
|
+
const firstTimestamp = await passthroughAudioTrack.getFirstTimestamp()
|
|
749
|
+
for await (const packet of sink.packets()) {
|
|
750
|
+
await audioSource.add(packet.clone({ timestamp: Math.max(0, packet.timestamp - firstTimestamp) }), { decoderConfig: decoderConfig || undefined })
|
|
751
|
+
}
|
|
752
|
+
audioSource.close()
|
|
753
|
+
})()
|
|
663
754
|
|
|
664
|
-
let
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
const
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
755
|
+
let videoDuration = durationCfr
|
|
756
|
+
if (passthroughTrack) {
|
|
757
|
+
const sink = new EncodedPacketSink(passthroughTrack)
|
|
758
|
+
const decoderConfig = await passthroughTrack.getDecoderConfig()
|
|
759
|
+
const firstTimestamp = await passthroughTrack.getFirstTimestamp()
|
|
760
|
+
videoDuration = 0
|
|
761
|
+
for await (const packet of sink.packets(undefined, undefined, { verifyKeyPackets: true })) {
|
|
762
|
+
const normalizedPacket = packet.clone({ timestamp: Math.max(0, packet.timestamp - firstTimestamp) })
|
|
763
|
+
await videoTrack.add(normalizedPacket, { decoderConfig: decoderConfig || undefined })
|
|
764
|
+
videoDuration = Math.max(videoDuration, normalizedPacket.timestamp + normalizedPacket.duration)
|
|
765
|
+
if (typeof onProgress === 'function') {
|
|
766
|
+
try {
|
|
767
|
+
onProgress(Math.min(1, videoDuration / durationCfr))
|
|
768
|
+
} catch (err) {
|
|
769
|
+
console.warn('straight-to-video: onProgress callback threw; ignoring error', err)
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
videoTrack.close()
|
|
774
|
+
} else {
|
|
775
|
+
let codecDesc = null
|
|
776
|
+
const pendingPackets = []
|
|
777
|
+
const ve = new VideoEncoder({
|
|
778
|
+
output: (chunk, meta) => {
|
|
779
|
+
if (!codecDesc && meta?.decoderConfig?.description) codecDesc = meta.decoderConfig.description
|
|
780
|
+
pendingPackets.push({ chunk })
|
|
781
|
+
},
|
|
782
|
+
error: () => {}
|
|
783
|
+
})
|
|
784
|
+
ve.configure(encoder.config)
|
|
785
|
+
|
|
786
|
+
const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight
|
|
787
|
+
const ctx = canvas.getContext('2d', { alpha: false })
|
|
788
|
+
|
|
789
|
+
await (shouldDecodeViaVideoElement()
|
|
790
|
+
? encodeFramesViaVideoElement({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress })
|
|
791
|
+
: encodeFramesViaVideoSampleSink({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress }))
|
|
792
|
+
await ve.flush()
|
|
793
|
+
|
|
794
|
+
const muxCount = Math.min(frames, pendingPackets.length)
|
|
795
|
+
videoDuration = muxCount * step
|
|
796
|
+
for (let i = 0; i < muxCount; i++) {
|
|
797
|
+
const { chunk } = pendingPackets[i]
|
|
798
|
+
const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data)
|
|
799
|
+
const ts = i * step; const dur = step
|
|
800
|
+
const pkt = new EncodedPacket(data, i === 0 || chunk.type === 'key' ? 'key' : 'delta', ts, dur)
|
|
801
|
+
await videoTrack.add(pkt, { decoderConfig: { codec: encoder.config.codec, codedWidth: targetWidth, codedHeight: targetHeight, description: codecDesc } })
|
|
802
|
+
}
|
|
691
803
|
}
|
|
692
804
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
805
|
+
if (passthroughAudioPromise) {
|
|
806
|
+
await passthroughAudioPromise
|
|
807
|
+
} else {
|
|
808
|
+
const totalVideoSamples = videoDuration * TARGET_AUDIO_SR
|
|
809
|
+
const targetSamples = Math.max(1024, Math.floor(totalVideoSamples / 1024) * 1024 - 2048)
|
|
810
|
+
const audioExact = await renderStereo48kExact(audioBuffer, targetSamples)
|
|
811
|
+
const interleaved = interleaveStereoF32(audioExact)
|
|
812
|
+
const sample = new AudioSample({ format: 'f32', sampleRate: TARGET_AUDIO_SR, numberOfChannels: TARGET_AUDIO_CHANNELS, timestamp: 0, data: interleaved })
|
|
813
|
+
await audioSource.add(sample)
|
|
814
|
+
audioSource.close()
|
|
815
|
+
}
|
|
701
816
|
await output.finalize()
|
|
702
817
|
const normalized = await normalizeMp4Container(output.target.buffer)
|
|
703
818
|
const payload = new Uint8Array(normalized)
|
data/index.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import {
|
|
5
5
|
Input, ALL_FORMATS, BlobSource, AudioBufferSink,
|
|
6
6
|
Output, Mp4OutputFormat, BufferTarget,
|
|
7
|
-
AudioSampleSource, AudioSample, EncodedVideoPacketSource, EncodedPacket, EncodedPacketSink, VideoSampleSink
|
|
7
|
+
AudioSampleSource, AudioSample, EncodedVideoPacketSource, EncodedPacket, EncodedPacketSink, VideoSampleSink,
|
|
8
|
+
EncodedAudioPacketSource
|
|
8
9
|
} from 'mediabunny'
|
|
9
10
|
|
|
10
11
|
// ----- Constants -----
|
|
@@ -13,6 +14,7 @@ const TARGET_VIDEO_BITRATE = 12_000_000
|
|
|
13
14
|
const TARGET_AUDIO_BITRATE = 96_000
|
|
14
15
|
const TARGET_AUDIO_SR = 48_000
|
|
15
16
|
const TARGET_AUDIO_CHANNELS = 2
|
|
17
|
+
const MAX_VIDEO_ENCODER_QUEUE_SIZE = 4
|
|
16
18
|
|
|
17
19
|
// ----- Video metadata probe -----
|
|
18
20
|
async function probeVideo (file) {
|
|
@@ -33,34 +35,55 @@ async function probeVideo (file) {
|
|
|
33
35
|
})
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
async function
|
|
38
|
+
async function estimateSourceVideoStats (file) {
|
|
37
39
|
try {
|
|
38
40
|
const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS })
|
|
39
41
|
const tracks = await input.getTracks()
|
|
40
42
|
const video = tracks.find(t => typeof t.isVideoTrack === 'function' && t.isVideoTrack())
|
|
41
|
-
if (!video) return 0
|
|
43
|
+
if (!video) return { fps: 0, bitrate: 0, codec: null, rotation: 0 }
|
|
42
44
|
const sink = new EncodedPacketSink(video)
|
|
43
45
|
const durations = []
|
|
46
|
+
let firstTimestamp = Infinity
|
|
47
|
+
let lastTimestamp = -Infinity
|
|
48
|
+
let totalBytes = 0
|
|
44
49
|
for await (const packet of sink.packets(undefined, undefined, { metadataOnly: true })) {
|
|
45
|
-
const
|
|
46
|
-
if (packet.timestamp >= 0 && Number.isFinite(
|
|
50
|
+
const duration = Number(packet?.duration)
|
|
51
|
+
if (packet.timestamp >= 0 && Number.isFinite(duration) && duration > 0) {
|
|
52
|
+
durations.push(duration)
|
|
53
|
+
firstTimestamp = Math.min(firstTimestamp, packet.timestamp)
|
|
54
|
+
lastTimestamp = Math.max(lastTimestamp, packet.timestamp + duration)
|
|
55
|
+
totalBytes += packet.byteLength
|
|
56
|
+
}
|
|
47
57
|
if (durations.length >= 120) break
|
|
48
58
|
}
|
|
49
|
-
if (!durations.length) return 0
|
|
59
|
+
if (!durations.length) return { fps: 0, bitrate: 0, codec: video.codec, rotation: video.rotation }
|
|
50
60
|
durations.sort((a, b) => a - b)
|
|
51
|
-
const
|
|
52
|
-
|
|
61
|
+
const duration = durations[Math.floor(durations.length / 2)]
|
|
62
|
+
const sampledDuration = lastTimestamp - firstTimestamp
|
|
63
|
+
return {
|
|
64
|
+
fps: Number.isFinite(duration) && duration > 0 ? (1 / duration) : 0,
|
|
65
|
+
bitrate: sampledDuration > 0 ? (totalBytes * 8 / sampledDuration) : 0,
|
|
66
|
+
codec: video.codec,
|
|
67
|
+
rotation: video.rotation
|
|
68
|
+
}
|
|
53
69
|
} catch (_) {
|
|
54
|
-
return 0
|
|
70
|
+
return { fps: 0, bitrate: 0, codec: null, rotation: 0 }
|
|
55
71
|
}
|
|
56
72
|
}
|
|
57
73
|
|
|
58
|
-
async function
|
|
74
|
+
async function determineEncodingPlan (file, { width, height, duration }) {
|
|
59
75
|
const maxFps = Math.max(width, height) <= 1920 ? 30 : 60
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
76
|
+
const source = await estimateSourceVideoStats(file)
|
|
77
|
+
const copyVideo = ['avc', 'hevc'].includes(source.codec) &&
|
|
78
|
+
source.rotation === 0 &&
|
|
79
|
+
Math.max(width, height) <= MAX_LONG_SIDE &&
|
|
80
|
+
source.fps >= 23 && source.fps <= 60.1 &&
|
|
81
|
+
Number(duration) > 0 && (file.size * 8 / Number(duration)) <= TARGET_VIDEO_BITRATE
|
|
82
|
+
return {
|
|
83
|
+
fps: copyVideo ? source.fps : (maxFps === 30 ? 30 : (source.fps >= 45 ? 60 : 30)),
|
|
84
|
+
bitrate: source.bitrate > 0 ? Math.min(TARGET_VIDEO_BITRATE, Math.round(source.bitrate)) : TARGET_VIDEO_BITRATE,
|
|
85
|
+
copyVideo
|
|
86
|
+
}
|
|
64
87
|
}
|
|
65
88
|
|
|
66
89
|
// ----- Audio helpers -----
|
|
@@ -124,8 +147,8 @@ async function canOptimizeVideo (file) {
|
|
|
124
147
|
const scale = Math.min(1, MAX_LONG_SIDE / Math.max(2, long))
|
|
125
148
|
const targetWidth = Math.max(2, Math.round(width * scale))
|
|
126
149
|
const targetHeight = Math.max(2, Math.round(height * scale))
|
|
127
|
-
const
|
|
128
|
-
const sup = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight,
|
|
150
|
+
const plan = await determineEncodingPlan(file, { width, height, duration })
|
|
151
|
+
const sup = plan.copyVideo || await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, ...plan }).then(() => true).catch(() => false)
|
|
129
152
|
if (!sup) return { ok: false, reason: 'unsupported-video-config', message: 'No supported encoder configuration for this resolution on this device.' }
|
|
130
153
|
|
|
131
154
|
// Header sniffing when file.type is empty/incorrect
|
|
@@ -141,7 +164,7 @@ async function canOptimizeVideo (file) {
|
|
|
141
164
|
const hasEbml = buf.length >= 4 && buf[0] === 0x1A && buf[1] === 0x45 && buf[2] === 0xDF && buf[3] === 0xA3
|
|
142
165
|
if (!(hasFtyp || hasEbml)) return { ok: false, reason: 'unknown-container', message: 'Unrecognized container; expected MP4/MOV or WebM.' }
|
|
143
166
|
}
|
|
144
|
-
return { ok: true, reason: 'ok', message: 'ok', plan: { width: targetWidth, height: targetHeight,
|
|
167
|
+
return { ok: true, reason: 'ok', message: 'ok', plan: { width: targetWidth, height: targetHeight, ...plan } }
|
|
145
168
|
} catch (e) {
|
|
146
169
|
return { ok: false, reason: 'probe-failed', message: String(e?.message || e) }
|
|
147
170
|
}
|
|
@@ -155,17 +178,25 @@ async function optimizeVideo (file, { onProgress } = {}) {
|
|
|
155
178
|
const feas = await canOptimizeVideo(file)
|
|
156
179
|
if (!feas.ok) return { changed: false, file }
|
|
157
180
|
|
|
181
|
+
if (feas.plan.copyVideo) {
|
|
182
|
+
const fastStarted = await fastStartMp4(file)
|
|
183
|
+
if (fastStarted) {
|
|
184
|
+
if (typeof onProgress === 'function') onProgress(1)
|
|
185
|
+
return { changed: true, file: fastStarted }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
158
189
|
const srcMeta = await probeVideo(file)
|
|
159
190
|
const newFile = await encodeVideo({ file, srcMeta: { w: srcMeta.width, h: srcMeta.height, duration: srcMeta.duration }, plan: feas.plan, onProgress })
|
|
160
191
|
return { changed: true, file: newFile }
|
|
161
192
|
}
|
|
162
193
|
|
|
163
|
-
async function selectVideoEncoderConfig ({ width, height, fps }) {
|
|
164
|
-
const hevc = { codec: 'hvc1.1.4.L123.B0', width, height, framerate: fps, bitrate
|
|
194
|
+
async function selectVideoEncoderConfig ({ width, height, fps, bitrate = TARGET_VIDEO_BITRATE }) {
|
|
195
|
+
const hevc = { codec: 'hvc1.1.4.L123.B0', width, height, framerate: fps, bitrate, hardwareAcceleration: 'prefer-hardware', hevc: { format: 'hevc' } }
|
|
165
196
|
const supH = await VideoEncoder.isConfigSupported(hevc).catch(() => ({ supported: false }))
|
|
166
197
|
if (supH.supported) return { codecId: 'hevc', config: supH.config }
|
|
167
198
|
|
|
168
|
-
const avc = { codec: 'avc1.64002A', width, height, framerate: fps, bitrate
|
|
199
|
+
const avc = { codec: 'avc1.64002A', width, height, framerate: fps, bitrate, hardwareAcceleration: 'prefer-hardware', avc: { format: 'avc' } }
|
|
169
200
|
const supA = await VideoEncoder.isConfigSupported(avc)
|
|
170
201
|
return { codecId: 'avc', config: supA.config }
|
|
171
202
|
}
|
|
@@ -174,6 +205,12 @@ function shouldDecodeViaVideoElement () {
|
|
|
174
205
|
return (navigator?.vendor || '').includes('Apple')
|
|
175
206
|
}
|
|
176
207
|
|
|
208
|
+
async function applyVideoEncoderBackpressure (encoder) {
|
|
209
|
+
while (encoder.encodeQueueSize > MAX_VIDEO_ENCODER_QUEUE_SIZE) {
|
|
210
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
177
214
|
async function waitForFrameReady (video, budgetMs) {
|
|
178
215
|
if (typeof video.requestVideoFrameCallback !== 'function') return false
|
|
179
216
|
return await new Promise((resolve) => {
|
|
@@ -238,6 +275,7 @@ async function encodeFramesViaVideoElement ({ file, durationCfr, step, frames, c
|
|
|
238
275
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
239
276
|
ve.encode(vf, { keyFrame: i === 0 })
|
|
240
277
|
vf.close()
|
|
278
|
+
await applyVideoEncoderBackpressure(ve)
|
|
241
279
|
|
|
242
280
|
if (typeof onProgress === 'function') {
|
|
243
281
|
try {
|
|
@@ -284,6 +322,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
284
322
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
285
323
|
ve.encode(vf, { keyFrame: i === 0 })
|
|
286
324
|
vf.close()
|
|
325
|
+
await applyVideoEncoderBackpressure(ve)
|
|
287
326
|
|
|
288
327
|
if (typeof onProgress === 'function') {
|
|
289
328
|
try {
|
|
@@ -310,6 +349,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
310
349
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
311
350
|
ve.encode(vf, { keyFrame: i === 0 })
|
|
312
351
|
vf.close()
|
|
352
|
+
await applyVideoEncoderBackpressure(ve)
|
|
313
353
|
|
|
314
354
|
if (typeof onProgress === 'function') {
|
|
315
355
|
try {
|
|
@@ -372,6 +412,35 @@ function _scanBoxes (u8, start, end) {
|
|
|
372
412
|
return out
|
|
373
413
|
}
|
|
374
414
|
|
|
415
|
+
async function fastStartMp4 (file) {
|
|
416
|
+
const u8 = new Uint8Array(await file.arrayBuffer())
|
|
417
|
+
const boxes = _scanBoxes(u8, 0, u8.byteLength)
|
|
418
|
+
const ftyp = boxes.find(b => b.type === 'ftyp')
|
|
419
|
+
const mdat = boxes.find(b => b.type === 'mdat')
|
|
420
|
+
const moov = boxes.find(b => b.type === 'moov')
|
|
421
|
+
if (!ftyp || !mdat || !moov || ftyp.offset !== 0 || moov.offset < mdat.offset) return null
|
|
422
|
+
|
|
423
|
+
const relocatedMoov = u8.slice(moov.offset, moov.offset + moov.size)
|
|
424
|
+
const dv = new DataView(relocatedMoov.buffer, relocatedMoov.byteOffset, relocatedMoov.byteLength)
|
|
425
|
+
for (let i = 0; i < relocatedMoov.byteLength - 16; i++) {
|
|
426
|
+
if (relocatedMoov[i + 4] !== 0x73 || relocatedMoov[i + 5] !== 0x74 || relocatedMoov[i + 6] !== 0x63 || relocatedMoov[i + 7] !== 0x6f) continue
|
|
427
|
+
const count = dv.getUint32(i + 12)
|
|
428
|
+
if (i + 16 + count * 4 > relocatedMoov.byteLength) return null
|
|
429
|
+
for (let j = 0; j < count; j++) {
|
|
430
|
+
const offset = i + 16 + j * 4
|
|
431
|
+
dv.setUint32(offset, dv.getUint32(offset) + moov.size)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const payload = _concat(
|
|
436
|
+
u8.slice(ftyp.offset, ftyp.offset + ftyp.size),
|
|
437
|
+
relocatedMoov,
|
|
438
|
+
...boxes.filter(box => box !== ftyp && box !== moov).map(box => u8.slice(box.offset, box.offset + box.size))
|
|
439
|
+
)
|
|
440
|
+
const dot = file.name.lastIndexOf('.')
|
|
441
|
+
return new File([payload], `${file.name.substring(0, dot)}-optimized.mp4`, { type: 'video/mp4', lastModified: Date.now() })
|
|
442
|
+
}
|
|
443
|
+
|
|
375
444
|
function _esdTag (tag, payload) {
|
|
376
445
|
return _concat(new Uint8Array([tag, 0x80, 0x80, 0x80, payload.byteLength]), payload)
|
|
377
446
|
}
|
|
@@ -627,76 +696,122 @@ async function encodeVideo ({ file, srcMeta, plan, onProgress }) {
|
|
|
627
696
|
const targetWidth = Math.max(2, Number(plan?.width) || Math.round(w * scale))
|
|
628
697
|
const targetHeight = Math.max(2, Number(plan?.height) || Math.round(h * scale))
|
|
629
698
|
|
|
630
|
-
const
|
|
699
|
+
const fallbackPlan = plan || await determineEncodingPlan(file, { width: w, height: h, duration: durationCfr })
|
|
700
|
+
const targetFps = Math.max(1, Number(fallbackPlan.fps))
|
|
631
701
|
const step = 1 / Math.max(1, targetFps)
|
|
632
702
|
const frames = Math.max(1, Math.floor(durationCfr / step))
|
|
633
703
|
|
|
634
704
|
const output = new Output({ format: new Mp4OutputFormat({ fastStart: 'in-memory' }), target: new BufferTarget() })
|
|
635
|
-
const
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
sampleRate: TARGET_AUDIO_SR,
|
|
654
|
-
onEncodedPacket: (_packet, meta) => {
|
|
655
|
-
const aot = 2; const idx = 3; const b0 = (aot << 3) | (idx >> 1); const b1 = ((idx & 1) << 7) | (TARGET_AUDIO_CHANNELS << 3)
|
|
656
|
-
meta.decoderConfig = { codec: 'mp4a.40.2', numberOfChannels: TARGET_AUDIO_CHANNELS, sampleRate: TARGET_AUDIO_SR, description: new Uint8Array([b0, b1]) }
|
|
705
|
+
const passthroughInput = fallbackPlan.copyVideo ? new Input({ source: new BlobSource(file), formats: ALL_FORMATS }) : null
|
|
706
|
+
const passthroughTracks = passthroughInput ? await passthroughInput.getTracks() : []
|
|
707
|
+
const passthroughTrack = passthroughTracks.find(t => typeof t.isVideoTrack === 'function' && t.isVideoTrack())
|
|
708
|
+
const passthroughAudioTrack = passthroughTrack && passthroughTracks.find(t =>
|
|
709
|
+
typeof t.isAudioTrack === 'function' && t.isAudioTrack() &&
|
|
710
|
+
t.codec === 'aac' && [1, 2].includes(t.numberOfChannels) && [44_100, 48_000].includes(t.sampleRate)
|
|
711
|
+
)
|
|
712
|
+
const encoder = passthroughTrack ? null : await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps: targetFps, bitrate: fallbackPlan.bitrate })
|
|
713
|
+
const videoTrack = new EncodedVideoPacketSource(passthroughTrack?.codec || encoder.codecId)
|
|
714
|
+
output.addVideoTrack(videoTrack, passthroughTrack ? { rotation: passthroughTrack.rotation } : { frameRate: targetFps })
|
|
715
|
+
|
|
716
|
+
let audioBuffer = null
|
|
717
|
+
if (!passthroughAudioTrack) {
|
|
718
|
+
const _warn = console.warn
|
|
719
|
+
console.warn = (...args) => {
|
|
720
|
+
const m = args && args[0]
|
|
721
|
+
if (typeof m === 'string' && m.includes('Unsupported audio codec') && m.includes('apac')) return
|
|
722
|
+
_warn.apply(console, args)
|
|
657
723
|
}
|
|
658
|
-
|
|
724
|
+
audioBuffer = await decodeAudioPCM(file, { duration: durationCfr })
|
|
725
|
+
console.warn = _warn
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const audioSource = passthroughAudioTrack
|
|
729
|
+
? new EncodedAudioPacketSource('aac')
|
|
730
|
+
: new AudioSampleSource({
|
|
731
|
+
codec: 'aac',
|
|
732
|
+
bitrate: TARGET_AUDIO_BITRATE,
|
|
733
|
+
bitrateMode: 'constant',
|
|
734
|
+
numberOfChannels: TARGET_AUDIO_CHANNELS,
|
|
735
|
+
sampleRate: TARGET_AUDIO_SR,
|
|
736
|
+
onEncodedPacket: (_packet, meta) => {
|
|
737
|
+
const aot = 2; const idx = 3; const b0 = (aot << 3) | (idx >> 1); const b1 = ((idx & 1) << 7) | (TARGET_AUDIO_CHANNELS << 3)
|
|
738
|
+
meta.decoderConfig = { codec: 'mp4a.40.2', numberOfChannels: TARGET_AUDIO_CHANNELS, sampleRate: TARGET_AUDIO_SR, description: new Uint8Array([b0, b1]) }
|
|
739
|
+
}
|
|
740
|
+
})
|
|
659
741
|
output.addAudioTrack(audioSource)
|
|
660
742
|
|
|
661
743
|
await output.start()
|
|
744
|
+
const passthroughAudioPromise = passthroughAudioTrack && (async () => {
|
|
745
|
+
const sink = new EncodedPacketSink(passthroughAudioTrack)
|
|
746
|
+
const decoderConfig = await passthroughAudioTrack.getDecoderConfig()
|
|
747
|
+
const firstTimestamp = await passthroughAudioTrack.getFirstTimestamp()
|
|
748
|
+
for await (const packet of sink.packets()) {
|
|
749
|
+
await audioSource.add(packet.clone({ timestamp: Math.max(0, packet.timestamp - firstTimestamp) }), { decoderConfig: decoderConfig || undefined })
|
|
750
|
+
}
|
|
751
|
+
audioSource.close()
|
|
752
|
+
})()
|
|
662
753
|
|
|
663
|
-
let
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
754
|
+
let videoDuration = durationCfr
|
|
755
|
+
if (passthroughTrack) {
|
|
756
|
+
const sink = new EncodedPacketSink(passthroughTrack)
|
|
757
|
+
const decoderConfig = await passthroughTrack.getDecoderConfig()
|
|
758
|
+
const firstTimestamp = await passthroughTrack.getFirstTimestamp()
|
|
759
|
+
videoDuration = 0
|
|
760
|
+
for await (const packet of sink.packets(undefined, undefined, { verifyKeyPackets: true })) {
|
|
761
|
+
const normalizedPacket = packet.clone({ timestamp: Math.max(0, packet.timestamp - firstTimestamp) })
|
|
762
|
+
await videoTrack.add(normalizedPacket, { decoderConfig: decoderConfig || undefined })
|
|
763
|
+
videoDuration = Math.max(videoDuration, normalizedPacket.timestamp + normalizedPacket.duration)
|
|
764
|
+
if (typeof onProgress === 'function') {
|
|
765
|
+
try {
|
|
766
|
+
onProgress(Math.min(1, videoDuration / durationCfr))
|
|
767
|
+
} catch (err) {
|
|
768
|
+
console.warn('straight-to-video: onProgress callback threw; ignoring error', err)
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
videoTrack.close()
|
|
773
|
+
} else {
|
|
774
|
+
let codecDesc = null
|
|
775
|
+
const pendingPackets = []
|
|
776
|
+
const ve = new VideoEncoder({
|
|
777
|
+
output: (chunk, meta) => {
|
|
778
|
+
if (!codecDesc && meta?.decoderConfig?.description) codecDesc = meta.decoderConfig.description
|
|
779
|
+
pendingPackets.push({ chunk })
|
|
780
|
+
},
|
|
781
|
+
error: () => {}
|
|
782
|
+
})
|
|
783
|
+
ve.configure(encoder.config)
|
|
784
|
+
|
|
785
|
+
const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight
|
|
786
|
+
const ctx = canvas.getContext('2d', { alpha: false })
|
|
787
|
+
|
|
788
|
+
await (shouldDecodeViaVideoElement()
|
|
789
|
+
? encodeFramesViaVideoElement({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress })
|
|
790
|
+
: encodeFramesViaVideoSampleSink({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress }))
|
|
791
|
+
await ve.flush()
|
|
792
|
+
|
|
793
|
+
const muxCount = Math.min(frames, pendingPackets.length)
|
|
794
|
+
videoDuration = muxCount * step
|
|
795
|
+
for (let i = 0; i < muxCount; i++) {
|
|
796
|
+
const { chunk } = pendingPackets[i]
|
|
797
|
+
const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data)
|
|
798
|
+
const ts = i * step; const dur = step
|
|
799
|
+
const pkt = new EncodedPacket(data, i === 0 || chunk.type === 'key' ? 'key' : 'delta', ts, dur)
|
|
800
|
+
await videoTrack.add(pkt, { decoderConfig: { codec: encoder.config.codec, codedWidth: targetWidth, codedHeight: targetHeight, description: codecDesc } })
|
|
801
|
+
}
|
|
690
802
|
}
|
|
691
803
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
804
|
+
if (passthroughAudioPromise) {
|
|
805
|
+
await passthroughAudioPromise
|
|
806
|
+
} else {
|
|
807
|
+
const totalVideoSamples = videoDuration * TARGET_AUDIO_SR
|
|
808
|
+
const targetSamples = Math.max(1024, Math.floor(totalVideoSamples / 1024) * 1024 - 2048)
|
|
809
|
+
const audioExact = await renderStereo48kExact(audioBuffer, targetSamples)
|
|
810
|
+
const interleaved = interleaveStereoF32(audioExact)
|
|
811
|
+
const sample = new AudioSample({ format: 'f32', sampleRate: TARGET_AUDIO_SR, numberOfChannels: TARGET_AUDIO_CHANNELS, timestamp: 0, data: interleaved })
|
|
812
|
+
await audioSource.add(sample)
|
|
813
|
+
audioSource.close()
|
|
814
|
+
}
|
|
700
815
|
await output.finalize()
|
|
701
816
|
const normalized = await normalizeMp4Container(output.target.buffer)
|
|
702
817
|
const payload = new Uint8Array(normalized)
|
data/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "straight-to-video",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "straight-to-video",
|
|
9
|
-
"version": "0.0.
|
|
9
|
+
"version": "0.0.13",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"mediabunny": "^1.27.3"
|
data/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "straight-to-video",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"description": "Browser-based, hardware-accelerated video upload optimization",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
],
|
|
27
27
|
"repository": {
|
|
28
28
|
"type": "git",
|
|
29
|
-
"url": "https://github.com/searlsco/straight-to-video.git"
|
|
29
|
+
"url": "git+https://github.com/searlsco/straight-to-video.git"
|
|
30
30
|
},
|
|
31
31
|
"license": "MIT",
|
|
32
32
|
"files": [
|