straight_to_video 0.0.12 → 0.0.14
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 +184 -74
- data/index.js +183 -73
- data/lib/straight_to_video/version.rb +1 -1
- data/package-lock.json +2 -2
- data/package.json +1 -1
- 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: 0cb2a8345491c6b11662c50c5d7604a9ac78024303e066da972ece9b8c967881
|
|
4
|
+
data.tar.gz: 3ec93954a73ad30c65804b77ebdd5bfb7d8ebb10004913de7be5e955416e5f71
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8f38b3c21dfa452d47113a6f77ce066387b8b2eca3587ffcf5ca4d2908926fc8d70f87fc486841188bef73695bf5979945c30cb1f38a70bd24d4b1100eb98506
|
|
7
|
+
data.tar.gz: d59d9a1c2b0c4bfea1e68bfefa6d57c69eedd43aa7ce54d691c571e463d3833322d0e4a9c8f4a88af6a3910022f1700e96734e025d37b61d41eb1a708b60b0fd
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.14
|
|
4
|
+
|
|
5
|
+
* Preserve B-frame presentation timestamps (`ctts`) when normalizing the MP4 container, fixing juddery playback of compliant uploads that were re-muxed on the passthrough path.
|
|
6
|
+
* Request an encoder keyframe every 2 seconds so re-encoded videos can recover from seeks, dropped frames, and downstream transcoding (previously the entire video had a single keyframe).
|
|
7
|
+
|
|
8
|
+
## 0.0.13
|
|
9
|
+
|
|
10
|
+
* Fast-start already-compliant MP4 and MOV uploads without re-encoding their media packets.
|
|
11
|
+
* Preserve compatible 44.1 kHz and 48 kHz AAC audio on the compliant-media path.
|
|
12
|
+
|
|
3
13
|
## 0.0.12
|
|
4
14
|
|
|
5
15
|
* Bound the WebCodecs encoder queue so long videos cannot exhaust WebKit memory.
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
// straight-to-video@0.0.
|
|
1
|
+
// straight-to-video@0.0.14 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 -----
|
|
@@ -15,6 +16,7 @@ const TARGET_AUDIO_BITRATE = 96_000
|
|
|
15
16
|
const TARGET_AUDIO_SR = 48_000
|
|
16
17
|
const TARGET_AUDIO_CHANNELS = 2
|
|
17
18
|
const MAX_VIDEO_ENCODER_QUEUE_SIZE = 4
|
|
19
|
+
const KEY_FRAME_INTERVAL_SECONDS = 2
|
|
18
20
|
|
|
19
21
|
// ----- Video metadata probe -----
|
|
20
22
|
async function probeVideo (file) {
|
|
@@ -40,7 +42,7 @@ async function estimateSourceVideoStats (file) {
|
|
|
40
42
|
const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS })
|
|
41
43
|
const tracks = await input.getTracks()
|
|
42
44
|
const video = tracks.find(t => typeof t.isVideoTrack === 'function' && t.isVideoTrack())
|
|
43
|
-
if (!video) return { fps: 0, bitrate: 0 }
|
|
45
|
+
if (!video) return { fps: 0, bitrate: 0, codec: null, rotation: 0 }
|
|
44
46
|
const sink = new EncodedPacketSink(video)
|
|
45
47
|
const durations = []
|
|
46
48
|
let firstTimestamp = Infinity
|
|
@@ -56,25 +58,33 @@ async function estimateSourceVideoStats (file) {
|
|
|
56
58
|
}
|
|
57
59
|
if (durations.length >= 120) break
|
|
58
60
|
}
|
|
59
|
-
if (!durations.length) return { fps: 0, bitrate: 0 }
|
|
61
|
+
if (!durations.length) return { fps: 0, bitrate: 0, codec: video.codec, rotation: video.rotation }
|
|
60
62
|
durations.sort((a, b) => a - b)
|
|
61
63
|
const duration = durations[Math.floor(durations.length / 2)]
|
|
62
64
|
const sampledDuration = lastTimestamp - firstTimestamp
|
|
63
65
|
return {
|
|
64
66
|
fps: Number.isFinite(duration) && duration > 0 ? (1 / duration) : 0,
|
|
65
|
-
bitrate: sampledDuration > 0 ? (totalBytes * 8 / sampledDuration) : 0
|
|
67
|
+
bitrate: sampledDuration > 0 ? (totalBytes * 8 / sampledDuration) : 0,
|
|
68
|
+
codec: video.codec,
|
|
69
|
+
rotation: video.rotation
|
|
66
70
|
}
|
|
67
71
|
} catch (_) {
|
|
68
|
-
return { fps: 0, bitrate: 0 }
|
|
72
|
+
return { fps: 0, bitrate: 0, codec: null, rotation: 0 }
|
|
69
73
|
}
|
|
70
74
|
}
|
|
71
75
|
|
|
72
|
-
async function determineEncodingPlan (file, { width, height }) {
|
|
76
|
+
async function determineEncodingPlan (file, { width, height, duration }) {
|
|
73
77
|
const maxFps = Math.max(width, height) <= 1920 ? 30 : 60
|
|
74
78
|
const source = await estimateSourceVideoStats(file)
|
|
79
|
+
const copyVideo = ['avc', 'hevc'].includes(source.codec) &&
|
|
80
|
+
source.rotation === 0 &&
|
|
81
|
+
Math.max(width, height) <= MAX_LONG_SIDE &&
|
|
82
|
+
source.fps >= 23 && source.fps <= 60.1 &&
|
|
83
|
+
Number(duration) > 0 && (file.size * 8 / Number(duration)) <= TARGET_VIDEO_BITRATE
|
|
75
84
|
return {
|
|
76
|
-
fps: maxFps === 30 ? 30 : (source.fps >= 45 ? 60 : 30),
|
|
77
|
-
bitrate: source.bitrate > 0 ? Math.min(TARGET_VIDEO_BITRATE, Math.round(source.bitrate)) : TARGET_VIDEO_BITRATE
|
|
85
|
+
fps: copyVideo ? source.fps : (maxFps === 30 ? 30 : (source.fps >= 45 ? 60 : 30)),
|
|
86
|
+
bitrate: source.bitrate > 0 ? Math.min(TARGET_VIDEO_BITRATE, Math.round(source.bitrate)) : TARGET_VIDEO_BITRATE,
|
|
87
|
+
copyVideo
|
|
78
88
|
}
|
|
79
89
|
}
|
|
80
90
|
|
|
@@ -139,8 +149,8 @@ async function canOptimizeVideo (file) {
|
|
|
139
149
|
const scale = Math.min(1, MAX_LONG_SIDE / Math.max(2, long))
|
|
140
150
|
const targetWidth = Math.max(2, Math.round(width * scale))
|
|
141
151
|
const targetHeight = Math.max(2, Math.round(height * scale))
|
|
142
|
-
const plan = await determineEncodingPlan(file, { width, height })
|
|
143
|
-
const sup = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, ...plan }).then(() => true).catch(() => false)
|
|
152
|
+
const plan = await determineEncodingPlan(file, { width, height, duration })
|
|
153
|
+
const sup = plan.copyVideo || await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, ...plan }).then(() => true).catch(() => false)
|
|
144
154
|
if (!sup) return { ok: false, reason: 'unsupported-video-config', message: 'No supported encoder configuration for this resolution on this device.' }
|
|
145
155
|
|
|
146
156
|
// Header sniffing when file.type is empty/incorrect
|
|
@@ -170,6 +180,14 @@ async function optimizeVideo (file, { onProgress } = {}) {
|
|
|
170
180
|
const feas = await canOptimizeVideo(file)
|
|
171
181
|
if (!feas.ok) return { changed: false, file }
|
|
172
182
|
|
|
183
|
+
if (feas.plan.copyVideo) {
|
|
184
|
+
const fastStarted = await fastStartMp4(file)
|
|
185
|
+
if (fastStarted) {
|
|
186
|
+
if (typeof onProgress === 'function') onProgress(1)
|
|
187
|
+
return { changed: true, file: fastStarted }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
173
191
|
const srcMeta = await probeVideo(file)
|
|
174
192
|
const newFile = await encodeVideo({ file, srcMeta: { w: srcMeta.width, h: srcMeta.height, duration: srcMeta.duration }, plan: feas.plan, onProgress })
|
|
175
193
|
return { changed: true, file: newFile }
|
|
@@ -189,6 +207,14 @@ function shouldDecodeViaVideoElement () {
|
|
|
189
207
|
return (navigator?.vendor || '').includes('Apple')
|
|
190
208
|
}
|
|
191
209
|
|
|
210
|
+
// Encoders only emit keyframes when asked (WebKit's VideoToolbox never adds
|
|
211
|
+
// its own), so request one every KEY_FRAME_INTERVAL_SECONDS or players get a
|
|
212
|
+
// single sync sample for the whole video and cannot recover from seeks or
|
|
213
|
+
// dropped frames.
|
|
214
|
+
function keyFramesEveryNthFrame (step) {
|
|
215
|
+
return Math.max(1, Math.round(KEY_FRAME_INTERVAL_SECONDS / step))
|
|
216
|
+
}
|
|
217
|
+
|
|
192
218
|
async function applyVideoEncoderBackpressure (encoder) {
|
|
193
219
|
while (encoder.encodeQueueSize > MAX_VIDEO_ENCODER_QUEUE_SIZE) {
|
|
194
220
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
@@ -243,6 +269,7 @@ async function encodeFramesViaVideoElement ({ file, durationCfr, step, frames, c
|
|
|
243
269
|
}
|
|
244
270
|
})
|
|
245
271
|
|
|
272
|
+
const keyFrameEvery = keyFramesEveryNthFrame(step)
|
|
246
273
|
for (let i = 0; i < frames; i++) {
|
|
247
274
|
const t = i * step
|
|
248
275
|
const drawTime = Math.min(Math.max(0, t + (step * 0.5)), Math.max(0.000001, durationCfr - 0.000001))
|
|
@@ -257,7 +284,7 @@ async function encodeFramesViaVideoElement ({ file, durationCfr, step, frames, c
|
|
|
257
284
|
|
|
258
285
|
ctx.drawImage(v, 0, 0, canvas.width, canvas.height)
|
|
259
286
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
260
|
-
ve.encode(vf, { keyFrame: i === 0 })
|
|
287
|
+
ve.encode(vf, { keyFrame: i % keyFrameEvery === 0 })
|
|
261
288
|
vf.close()
|
|
262
289
|
await applyVideoEncoderBackpressure(ve)
|
|
263
290
|
|
|
@@ -287,6 +314,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
287
314
|
sample.drawWithFit(ctx, { fit: 'fill' })
|
|
288
315
|
}
|
|
289
316
|
|
|
317
|
+
const keyFrameEvery = keyFramesEveryNthFrame(step)
|
|
290
318
|
let i = 0
|
|
291
319
|
let prev = null
|
|
292
320
|
let prevStart = 0
|
|
@@ -304,7 +332,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
304
332
|
if (displayTime < prevStart || displayTime >= end) break
|
|
305
333
|
const t = i * step
|
|
306
334
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
307
|
-
ve.encode(vf, { keyFrame: i === 0 })
|
|
335
|
+
ve.encode(vf, { keyFrame: i % keyFrameEvery === 0 })
|
|
308
336
|
vf.close()
|
|
309
337
|
await applyVideoEncoderBackpressure(ve)
|
|
310
338
|
|
|
@@ -331,7 +359,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
331
359
|
while (i < frames) {
|
|
332
360
|
const t = i * step
|
|
333
361
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
334
|
-
ve.encode(vf, { keyFrame: i === 0 })
|
|
362
|
+
ve.encode(vf, { keyFrame: i % keyFrameEvery === 0 })
|
|
335
363
|
vf.close()
|
|
336
364
|
await applyVideoEncoderBackpressure(ve)
|
|
337
365
|
|
|
@@ -396,6 +424,35 @@ function _scanBoxes (u8, start, end) {
|
|
|
396
424
|
return out
|
|
397
425
|
}
|
|
398
426
|
|
|
427
|
+
async function fastStartMp4 (file) {
|
|
428
|
+
const u8 = new Uint8Array(await file.arrayBuffer())
|
|
429
|
+
const boxes = _scanBoxes(u8, 0, u8.byteLength)
|
|
430
|
+
const ftyp = boxes.find(b => b.type === 'ftyp')
|
|
431
|
+
const mdat = boxes.find(b => b.type === 'mdat')
|
|
432
|
+
const moov = boxes.find(b => b.type === 'moov')
|
|
433
|
+
if (!ftyp || !mdat || !moov || ftyp.offset !== 0 || moov.offset < mdat.offset) return null
|
|
434
|
+
|
|
435
|
+
const relocatedMoov = u8.slice(moov.offset, moov.offset + moov.size)
|
|
436
|
+
const dv = new DataView(relocatedMoov.buffer, relocatedMoov.byteOffset, relocatedMoov.byteLength)
|
|
437
|
+
for (let i = 0; i < relocatedMoov.byteLength - 16; i++) {
|
|
438
|
+
if (relocatedMoov[i + 4] !== 0x73 || relocatedMoov[i + 5] !== 0x74 || relocatedMoov[i + 6] !== 0x63 || relocatedMoov[i + 7] !== 0x6f) continue
|
|
439
|
+
const count = dv.getUint32(i + 12)
|
|
440
|
+
if (i + 16 + count * 4 > relocatedMoov.byteLength) return null
|
|
441
|
+
for (let j = 0; j < count; j++) {
|
|
442
|
+
const offset = i + 16 + j * 4
|
|
443
|
+
dv.setUint32(offset, dv.getUint32(offset) + moov.size)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const payload = _concat(
|
|
448
|
+
u8.slice(ftyp.offset, ftyp.offset + ftyp.size),
|
|
449
|
+
relocatedMoov,
|
|
450
|
+
...boxes.filter(box => box !== ftyp && box !== moov).map(box => u8.slice(box.offset, box.offset + box.size))
|
|
451
|
+
)
|
|
452
|
+
const dot = file.name.lastIndexOf('.')
|
|
453
|
+
return new File([payload], `${file.name.substring(0, dot)}-optimized.mp4`, { type: 'video/mp4', lastModified: Date.now() })
|
|
454
|
+
}
|
|
455
|
+
|
|
399
456
|
function _esdTag (tag, payload) {
|
|
400
457
|
return _concat(new Uint8Array([tag, 0x80, 0x80, 0x80, payload.byteLength]), payload)
|
|
401
458
|
}
|
|
@@ -452,6 +509,7 @@ function _extractTrack (u8, dv, trakBox) {
|
|
|
452
509
|
const stszBox = stblKids.find(b => b.type === 'stsz')
|
|
453
510
|
const stcoBox = stblKids.find(b => b.type === 'stco')
|
|
454
511
|
const stssBox = stblKids.find(b => b.type === 'stss')
|
|
512
|
+
const cttsBox = stblKids.find(b => b.type === 'ctts')
|
|
455
513
|
if (!stsdBox || !sttsBox || !stscBox || !stszBox || !stcoBox) return null
|
|
456
514
|
|
|
457
515
|
const sampleCount = dv.getUint32(stszBox.offset + 16)
|
|
@@ -463,6 +521,9 @@ function _extractTrack (u8, dv, trakBox) {
|
|
|
463
521
|
const stscBody = u8.slice(stscBox.offset + 12, stscBox.offset + stscBox.size)
|
|
464
522
|
const stszBody = u8.slice(stszBox.offset + 12, stszBox.offset + stszBox.size)
|
|
465
523
|
const stssBody = stssBox ? u8.slice(stssBox.offset + 12, stssBox.offset + stssBox.size) : null
|
|
524
|
+
// Copied whole (header included) to keep its version: mediabunny writes
|
|
525
|
+
// version 1 (signed offsets), which _full would misdeclare as version 0
|
|
526
|
+
const cttsRaw = cttsBox ? u8.slice(cttsBox.offset, cttsBox.offset + cttsBox.size) : null
|
|
466
527
|
|
|
467
528
|
const entryOff = stsdBox.offset + 16
|
|
468
529
|
const entrySize = dv.getUint32(entryOff)
|
|
@@ -500,7 +561,7 @@ function _extractTrack (u8, dv, trakBox) {
|
|
|
500
561
|
tkDur, tkW, tkH, mdTs, mdDur, mdLang,
|
|
501
562
|
videoCodecConfig, audioSpecificConfig,
|
|
502
563
|
audioChannels, audioSampleSize, audioSampleRate,
|
|
503
|
-
sttsBody, stscBody, stszBody, stssBody,
|
|
564
|
+
sttsBody, stscBody, stszBody, stssBody, cttsRaw,
|
|
504
565
|
stcoEntries, sampleCount, bitrate
|
|
505
566
|
}
|
|
506
567
|
}
|
|
@@ -569,6 +630,7 @@ function _buildTrak (t) {
|
|
|
569
630
|
const stss = t.stssBody ? _full('stss', 0, 0, t.stssBody) : null
|
|
570
631
|
|
|
571
632
|
const stblParts = [stsd, stts]
|
|
633
|
+
if (t.cttsRaw) stblParts.push(t.cttsRaw)
|
|
572
634
|
if (stss) stblParts.push(stss)
|
|
573
635
|
stblParts.push(stsc, stsz, stco)
|
|
574
636
|
if (t.isAudio) {
|
|
@@ -651,77 +713,125 @@ async function encodeVideo ({ file, srcMeta, plan, onProgress }) {
|
|
|
651
713
|
const targetWidth = Math.max(2, Number(plan?.width) || Math.round(w * scale))
|
|
652
714
|
const targetHeight = Math.max(2, Number(plan?.height) || Math.round(h * scale))
|
|
653
715
|
|
|
654
|
-
const fallbackPlan = plan || await determineEncodingPlan(file, { width: w, height: h })
|
|
716
|
+
const fallbackPlan = plan || await determineEncodingPlan(file, { width: w, height: h, duration: durationCfr })
|
|
655
717
|
const targetFps = Math.max(1, Number(fallbackPlan.fps))
|
|
656
718
|
const step = 1 / Math.max(1, targetFps)
|
|
657
719
|
const frames = Math.max(1, Math.floor(durationCfr / step))
|
|
658
720
|
|
|
659
721
|
const output = new Output({ format: new Mp4OutputFormat({ fastStart: 'in-memory' }), target: new BufferTarget() })
|
|
660
|
-
const
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
sampleRate: TARGET_AUDIO_SR,
|
|
679
|
-
onEncodedPacket: (_packet, meta) => {
|
|
680
|
-
const aot = 2; const idx = 3; const b0 = (aot << 3) | (idx >> 1); const b1 = ((idx & 1) << 7) | (TARGET_AUDIO_CHANNELS << 3)
|
|
681
|
-
meta.decoderConfig = { codec: 'mp4a.40.2', numberOfChannels: TARGET_AUDIO_CHANNELS, sampleRate: TARGET_AUDIO_SR, description: new Uint8Array([b0, b1]) }
|
|
722
|
+
const passthroughInput = fallbackPlan.copyVideo ? new Input({ source: new BlobSource(file), formats: ALL_FORMATS }) : null
|
|
723
|
+
const passthroughTracks = passthroughInput ? await passthroughInput.getTracks() : []
|
|
724
|
+
const passthroughTrack = passthroughTracks.find(t => typeof t.isVideoTrack === 'function' && t.isVideoTrack())
|
|
725
|
+
const passthroughAudioTrack = passthroughTrack && passthroughTracks.find(t =>
|
|
726
|
+
typeof t.isAudioTrack === 'function' && t.isAudioTrack() &&
|
|
727
|
+
t.codec === 'aac' && [1, 2].includes(t.numberOfChannels) && [44_100, 48_000].includes(t.sampleRate)
|
|
728
|
+
)
|
|
729
|
+
const encoder = passthroughTrack ? null : await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps: targetFps, bitrate: fallbackPlan.bitrate })
|
|
730
|
+
const videoTrack = new EncodedVideoPacketSource(passthroughTrack?.codec || encoder.codecId)
|
|
731
|
+
output.addVideoTrack(videoTrack, passthroughTrack ? { rotation: passthroughTrack.rotation } : { frameRate: targetFps })
|
|
732
|
+
|
|
733
|
+
let audioBuffer = null
|
|
734
|
+
if (!passthroughAudioTrack) {
|
|
735
|
+
const _warn = console.warn
|
|
736
|
+
console.warn = (...args) => {
|
|
737
|
+
const m = args && args[0]
|
|
738
|
+
if (typeof m === 'string' && m.includes('Unsupported audio codec') && m.includes('apac')) return
|
|
739
|
+
_warn.apply(console, args)
|
|
682
740
|
}
|
|
683
|
-
|
|
741
|
+
audioBuffer = await decodeAudioPCM(file, { duration: durationCfr })
|
|
742
|
+
console.warn = _warn
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const audioSource = passthroughAudioTrack
|
|
746
|
+
? new EncodedAudioPacketSource('aac')
|
|
747
|
+
: new AudioSampleSource({
|
|
748
|
+
codec: 'aac',
|
|
749
|
+
bitrate: TARGET_AUDIO_BITRATE,
|
|
750
|
+
bitrateMode: 'constant',
|
|
751
|
+
numberOfChannels: TARGET_AUDIO_CHANNELS,
|
|
752
|
+
sampleRate: TARGET_AUDIO_SR,
|
|
753
|
+
onEncodedPacket: (_packet, meta) => {
|
|
754
|
+
const aot = 2; const idx = 3; const b0 = (aot << 3) | (idx >> 1); const b1 = ((idx & 1) << 7) | (TARGET_AUDIO_CHANNELS << 3)
|
|
755
|
+
meta.decoderConfig = { codec: 'mp4a.40.2', numberOfChannels: TARGET_AUDIO_CHANNELS, sampleRate: TARGET_AUDIO_SR, description: new Uint8Array([b0, b1]) }
|
|
756
|
+
}
|
|
757
|
+
})
|
|
684
758
|
output.addAudioTrack(audioSource)
|
|
685
759
|
|
|
686
760
|
await output.start()
|
|
761
|
+
const passthroughAudioPromise = passthroughAudioTrack && (async () => {
|
|
762
|
+
const sink = new EncodedPacketSink(passthroughAudioTrack)
|
|
763
|
+
const decoderConfig = await passthroughAudioTrack.getDecoderConfig()
|
|
764
|
+
const firstTimestamp = await passthroughAudioTrack.getFirstTimestamp()
|
|
765
|
+
for await (const packet of sink.packets()) {
|
|
766
|
+
await audioSource.add(packet.clone({ timestamp: Math.max(0, packet.timestamp - firstTimestamp) }), { decoderConfig: decoderConfig || undefined })
|
|
767
|
+
}
|
|
768
|
+
audioSource.close()
|
|
769
|
+
})()
|
|
687
770
|
|
|
688
|
-
let
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
771
|
+
let videoDuration = durationCfr
|
|
772
|
+
if (passthroughTrack) {
|
|
773
|
+
const sink = new EncodedPacketSink(passthroughTrack)
|
|
774
|
+
const decoderConfig = await passthroughTrack.getDecoderConfig()
|
|
775
|
+
const firstTimestamp = await passthroughTrack.getFirstTimestamp()
|
|
776
|
+
videoDuration = 0
|
|
777
|
+
for await (const packet of sink.packets(undefined, undefined, { verifyKeyPackets: true })) {
|
|
778
|
+
const normalizedPacket = packet.clone({ timestamp: Math.max(0, packet.timestamp - firstTimestamp) })
|
|
779
|
+
await videoTrack.add(normalizedPacket, { decoderConfig: decoderConfig || undefined })
|
|
780
|
+
videoDuration = Math.max(videoDuration, normalizedPacket.timestamp + normalizedPacket.duration)
|
|
781
|
+
if (typeof onProgress === 'function') {
|
|
782
|
+
try {
|
|
783
|
+
onProgress(Math.min(1, videoDuration / durationCfr))
|
|
784
|
+
} catch (err) {
|
|
785
|
+
console.warn('straight-to-video: onProgress callback threw; ignoring error', err)
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
videoTrack.close()
|
|
790
|
+
} else {
|
|
791
|
+
let codecDesc = null
|
|
792
|
+
const pendingPackets = []
|
|
793
|
+
const ve = new VideoEncoder({
|
|
794
|
+
output: (chunk, meta) => {
|
|
795
|
+
if (!codecDesc && meta?.decoderConfig?.description) codecDesc = meta.decoderConfig.description
|
|
796
|
+
pendingPackets.push({ chunk })
|
|
797
|
+
},
|
|
798
|
+
error: () => {}
|
|
799
|
+
})
|
|
800
|
+
ve.configure(encoder.config)
|
|
801
|
+
|
|
802
|
+
const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight
|
|
803
|
+
const ctx = canvas.getContext('2d', { alpha: false })
|
|
804
|
+
|
|
805
|
+
await (shouldDecodeViaVideoElement()
|
|
806
|
+
? encodeFramesViaVideoElement({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress })
|
|
807
|
+
: encodeFramesViaVideoSampleSink({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress }))
|
|
808
|
+
await ve.flush()
|
|
809
|
+
|
|
810
|
+
const muxCount = Math.min(frames, pendingPackets.length)
|
|
811
|
+
videoDuration = muxCount * step
|
|
812
|
+
const keyFrameEvery = keyFramesEveryNthFrame(step)
|
|
813
|
+
for (let i = 0; i < muxCount; i++) {
|
|
814
|
+
const { chunk } = pendingPackets[i]
|
|
815
|
+
const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data)
|
|
816
|
+
const ts = i * step; const dur = step
|
|
817
|
+
// WebKit labels requested keyframes as delta chunks, so trust the
|
|
818
|
+
// request cadence over chunk.type when marking sync samples
|
|
819
|
+
const pkt = new EncodedPacket(data, i % keyFrameEvery === 0 || chunk.type === 'key' ? 'key' : 'delta', ts, dur)
|
|
820
|
+
await videoTrack.add(pkt, { decoderConfig: { codec: encoder.config.codec, codedWidth: targetWidth, codedHeight: targetHeight, description: codecDesc } })
|
|
821
|
+
}
|
|
715
822
|
}
|
|
716
823
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
824
|
+
if (passthroughAudioPromise) {
|
|
825
|
+
await passthroughAudioPromise
|
|
826
|
+
} else {
|
|
827
|
+
const totalVideoSamples = videoDuration * TARGET_AUDIO_SR
|
|
828
|
+
const targetSamples = Math.max(1024, Math.floor(totalVideoSamples / 1024) * 1024 - 2048)
|
|
829
|
+
const audioExact = await renderStereo48kExact(audioBuffer, targetSamples)
|
|
830
|
+
const interleaved = interleaveStereoF32(audioExact)
|
|
831
|
+
const sample = new AudioSample({ format: 'f32', sampleRate: TARGET_AUDIO_SR, numberOfChannels: TARGET_AUDIO_CHANNELS, timestamp: 0, data: interleaved })
|
|
832
|
+
await audioSource.add(sample)
|
|
833
|
+
audioSource.close()
|
|
834
|
+
}
|
|
725
835
|
await output.finalize()
|
|
726
836
|
const normalized = await normalizeMp4Container(output.target.buffer)
|
|
727
837
|
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 -----
|
|
@@ -14,6 +15,7 @@ const TARGET_AUDIO_BITRATE = 96_000
|
|
|
14
15
|
const TARGET_AUDIO_SR = 48_000
|
|
15
16
|
const TARGET_AUDIO_CHANNELS = 2
|
|
16
17
|
const MAX_VIDEO_ENCODER_QUEUE_SIZE = 4
|
|
18
|
+
const KEY_FRAME_INTERVAL_SECONDS = 2
|
|
17
19
|
|
|
18
20
|
// ----- Video metadata probe -----
|
|
19
21
|
async function probeVideo (file) {
|
|
@@ -39,7 +41,7 @@ async function estimateSourceVideoStats (file) {
|
|
|
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 { fps: 0, bitrate: 0 }
|
|
44
|
+
if (!video) return { fps: 0, bitrate: 0, codec: null, rotation: 0 }
|
|
43
45
|
const sink = new EncodedPacketSink(video)
|
|
44
46
|
const durations = []
|
|
45
47
|
let firstTimestamp = Infinity
|
|
@@ -55,25 +57,33 @@ async function estimateSourceVideoStats (file) {
|
|
|
55
57
|
}
|
|
56
58
|
if (durations.length >= 120) break
|
|
57
59
|
}
|
|
58
|
-
if (!durations.length) return { fps: 0, bitrate: 0 }
|
|
60
|
+
if (!durations.length) return { fps: 0, bitrate: 0, codec: video.codec, rotation: video.rotation }
|
|
59
61
|
durations.sort((a, b) => a - b)
|
|
60
62
|
const duration = durations[Math.floor(durations.length / 2)]
|
|
61
63
|
const sampledDuration = lastTimestamp - firstTimestamp
|
|
62
64
|
return {
|
|
63
65
|
fps: Number.isFinite(duration) && duration > 0 ? (1 / duration) : 0,
|
|
64
|
-
bitrate: sampledDuration > 0 ? (totalBytes * 8 / sampledDuration) : 0
|
|
66
|
+
bitrate: sampledDuration > 0 ? (totalBytes * 8 / sampledDuration) : 0,
|
|
67
|
+
codec: video.codec,
|
|
68
|
+
rotation: video.rotation
|
|
65
69
|
}
|
|
66
70
|
} catch (_) {
|
|
67
|
-
return { fps: 0, bitrate: 0 }
|
|
71
|
+
return { fps: 0, bitrate: 0, codec: null, rotation: 0 }
|
|
68
72
|
}
|
|
69
73
|
}
|
|
70
74
|
|
|
71
|
-
async function determineEncodingPlan (file, { width, height }) {
|
|
75
|
+
async function determineEncodingPlan (file, { width, height, duration }) {
|
|
72
76
|
const maxFps = Math.max(width, height) <= 1920 ? 30 : 60
|
|
73
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
|
|
74
83
|
return {
|
|
75
|
-
fps: maxFps === 30 ? 30 : (source.fps >= 45 ? 60 : 30),
|
|
76
|
-
bitrate: source.bitrate > 0 ? Math.min(TARGET_VIDEO_BITRATE, Math.round(source.bitrate)) : TARGET_VIDEO_BITRATE
|
|
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
|
|
77
87
|
}
|
|
78
88
|
}
|
|
79
89
|
|
|
@@ -138,8 +148,8 @@ async function canOptimizeVideo (file) {
|
|
|
138
148
|
const scale = Math.min(1, MAX_LONG_SIDE / Math.max(2, long))
|
|
139
149
|
const targetWidth = Math.max(2, Math.round(width * scale))
|
|
140
150
|
const targetHeight = Math.max(2, Math.round(height * scale))
|
|
141
|
-
const plan = await determineEncodingPlan(file, { width, height })
|
|
142
|
-
const sup = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, ...plan }).then(() => true).catch(() => false)
|
|
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)
|
|
143
153
|
if (!sup) return { ok: false, reason: 'unsupported-video-config', message: 'No supported encoder configuration for this resolution on this device.' }
|
|
144
154
|
|
|
145
155
|
// Header sniffing when file.type is empty/incorrect
|
|
@@ -169,6 +179,14 @@ async function optimizeVideo (file, { onProgress } = {}) {
|
|
|
169
179
|
const feas = await canOptimizeVideo(file)
|
|
170
180
|
if (!feas.ok) return { changed: false, file }
|
|
171
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
|
+
|
|
172
190
|
const srcMeta = await probeVideo(file)
|
|
173
191
|
const newFile = await encodeVideo({ file, srcMeta: { w: srcMeta.width, h: srcMeta.height, duration: srcMeta.duration }, plan: feas.plan, onProgress })
|
|
174
192
|
return { changed: true, file: newFile }
|
|
@@ -188,6 +206,14 @@ function shouldDecodeViaVideoElement () {
|
|
|
188
206
|
return (navigator?.vendor || '').includes('Apple')
|
|
189
207
|
}
|
|
190
208
|
|
|
209
|
+
// Encoders only emit keyframes when asked (WebKit's VideoToolbox never adds
|
|
210
|
+
// its own), so request one every KEY_FRAME_INTERVAL_SECONDS or players get a
|
|
211
|
+
// single sync sample for the whole video and cannot recover from seeks or
|
|
212
|
+
// dropped frames.
|
|
213
|
+
function keyFramesEveryNthFrame (step) {
|
|
214
|
+
return Math.max(1, Math.round(KEY_FRAME_INTERVAL_SECONDS / step))
|
|
215
|
+
}
|
|
216
|
+
|
|
191
217
|
async function applyVideoEncoderBackpressure (encoder) {
|
|
192
218
|
while (encoder.encodeQueueSize > MAX_VIDEO_ENCODER_QUEUE_SIZE) {
|
|
193
219
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
@@ -242,6 +268,7 @@ async function encodeFramesViaVideoElement ({ file, durationCfr, step, frames, c
|
|
|
242
268
|
}
|
|
243
269
|
})
|
|
244
270
|
|
|
271
|
+
const keyFrameEvery = keyFramesEveryNthFrame(step)
|
|
245
272
|
for (let i = 0; i < frames; i++) {
|
|
246
273
|
const t = i * step
|
|
247
274
|
const drawTime = Math.min(Math.max(0, t + (step * 0.5)), Math.max(0.000001, durationCfr - 0.000001))
|
|
@@ -256,7 +283,7 @@ async function encodeFramesViaVideoElement ({ file, durationCfr, step, frames, c
|
|
|
256
283
|
|
|
257
284
|
ctx.drawImage(v, 0, 0, canvas.width, canvas.height)
|
|
258
285
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
259
|
-
ve.encode(vf, { keyFrame: i === 0 })
|
|
286
|
+
ve.encode(vf, { keyFrame: i % keyFrameEvery === 0 })
|
|
260
287
|
vf.close()
|
|
261
288
|
await applyVideoEncoderBackpressure(ve)
|
|
262
289
|
|
|
@@ -286,6 +313,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
286
313
|
sample.drawWithFit(ctx, { fit: 'fill' })
|
|
287
314
|
}
|
|
288
315
|
|
|
316
|
+
const keyFrameEvery = keyFramesEveryNthFrame(step)
|
|
289
317
|
let i = 0
|
|
290
318
|
let prev = null
|
|
291
319
|
let prevStart = 0
|
|
@@ -303,7 +331,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
303
331
|
if (displayTime < prevStart || displayTime >= end) break
|
|
304
332
|
const t = i * step
|
|
305
333
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
306
|
-
ve.encode(vf, { keyFrame: i === 0 })
|
|
334
|
+
ve.encode(vf, { keyFrame: i % keyFrameEvery === 0 })
|
|
307
335
|
vf.close()
|
|
308
336
|
await applyVideoEncoderBackpressure(ve)
|
|
309
337
|
|
|
@@ -330,7 +358,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
330
358
|
while (i < frames) {
|
|
331
359
|
const t = i * step
|
|
332
360
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
333
|
-
ve.encode(vf, { keyFrame: i === 0 })
|
|
361
|
+
ve.encode(vf, { keyFrame: i % keyFrameEvery === 0 })
|
|
334
362
|
vf.close()
|
|
335
363
|
await applyVideoEncoderBackpressure(ve)
|
|
336
364
|
|
|
@@ -395,6 +423,35 @@ function _scanBoxes (u8, start, end) {
|
|
|
395
423
|
return out
|
|
396
424
|
}
|
|
397
425
|
|
|
426
|
+
async function fastStartMp4 (file) {
|
|
427
|
+
const u8 = new Uint8Array(await file.arrayBuffer())
|
|
428
|
+
const boxes = _scanBoxes(u8, 0, u8.byteLength)
|
|
429
|
+
const ftyp = boxes.find(b => b.type === 'ftyp')
|
|
430
|
+
const mdat = boxes.find(b => b.type === 'mdat')
|
|
431
|
+
const moov = boxes.find(b => b.type === 'moov')
|
|
432
|
+
if (!ftyp || !mdat || !moov || ftyp.offset !== 0 || moov.offset < mdat.offset) return null
|
|
433
|
+
|
|
434
|
+
const relocatedMoov = u8.slice(moov.offset, moov.offset + moov.size)
|
|
435
|
+
const dv = new DataView(relocatedMoov.buffer, relocatedMoov.byteOffset, relocatedMoov.byteLength)
|
|
436
|
+
for (let i = 0; i < relocatedMoov.byteLength - 16; i++) {
|
|
437
|
+
if (relocatedMoov[i + 4] !== 0x73 || relocatedMoov[i + 5] !== 0x74 || relocatedMoov[i + 6] !== 0x63 || relocatedMoov[i + 7] !== 0x6f) continue
|
|
438
|
+
const count = dv.getUint32(i + 12)
|
|
439
|
+
if (i + 16 + count * 4 > relocatedMoov.byteLength) return null
|
|
440
|
+
for (let j = 0; j < count; j++) {
|
|
441
|
+
const offset = i + 16 + j * 4
|
|
442
|
+
dv.setUint32(offset, dv.getUint32(offset) + moov.size)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const payload = _concat(
|
|
447
|
+
u8.slice(ftyp.offset, ftyp.offset + ftyp.size),
|
|
448
|
+
relocatedMoov,
|
|
449
|
+
...boxes.filter(box => box !== ftyp && box !== moov).map(box => u8.slice(box.offset, box.offset + box.size))
|
|
450
|
+
)
|
|
451
|
+
const dot = file.name.lastIndexOf('.')
|
|
452
|
+
return new File([payload], `${file.name.substring(0, dot)}-optimized.mp4`, { type: 'video/mp4', lastModified: Date.now() })
|
|
453
|
+
}
|
|
454
|
+
|
|
398
455
|
function _esdTag (tag, payload) {
|
|
399
456
|
return _concat(new Uint8Array([tag, 0x80, 0x80, 0x80, payload.byteLength]), payload)
|
|
400
457
|
}
|
|
@@ -451,6 +508,7 @@ function _extractTrack (u8, dv, trakBox) {
|
|
|
451
508
|
const stszBox = stblKids.find(b => b.type === 'stsz')
|
|
452
509
|
const stcoBox = stblKids.find(b => b.type === 'stco')
|
|
453
510
|
const stssBox = stblKids.find(b => b.type === 'stss')
|
|
511
|
+
const cttsBox = stblKids.find(b => b.type === 'ctts')
|
|
454
512
|
if (!stsdBox || !sttsBox || !stscBox || !stszBox || !stcoBox) return null
|
|
455
513
|
|
|
456
514
|
const sampleCount = dv.getUint32(stszBox.offset + 16)
|
|
@@ -462,6 +520,9 @@ function _extractTrack (u8, dv, trakBox) {
|
|
|
462
520
|
const stscBody = u8.slice(stscBox.offset + 12, stscBox.offset + stscBox.size)
|
|
463
521
|
const stszBody = u8.slice(stszBox.offset + 12, stszBox.offset + stszBox.size)
|
|
464
522
|
const stssBody = stssBox ? u8.slice(stssBox.offset + 12, stssBox.offset + stssBox.size) : null
|
|
523
|
+
// Copied whole (header included) to keep its version: mediabunny writes
|
|
524
|
+
// version 1 (signed offsets), which _full would misdeclare as version 0
|
|
525
|
+
const cttsRaw = cttsBox ? u8.slice(cttsBox.offset, cttsBox.offset + cttsBox.size) : null
|
|
465
526
|
|
|
466
527
|
const entryOff = stsdBox.offset + 16
|
|
467
528
|
const entrySize = dv.getUint32(entryOff)
|
|
@@ -499,7 +560,7 @@ function _extractTrack (u8, dv, trakBox) {
|
|
|
499
560
|
tkDur, tkW, tkH, mdTs, mdDur, mdLang,
|
|
500
561
|
videoCodecConfig, audioSpecificConfig,
|
|
501
562
|
audioChannels, audioSampleSize, audioSampleRate,
|
|
502
|
-
sttsBody, stscBody, stszBody, stssBody,
|
|
563
|
+
sttsBody, stscBody, stszBody, stssBody, cttsRaw,
|
|
503
564
|
stcoEntries, sampleCount, bitrate
|
|
504
565
|
}
|
|
505
566
|
}
|
|
@@ -568,6 +629,7 @@ function _buildTrak (t) {
|
|
|
568
629
|
const stss = t.stssBody ? _full('stss', 0, 0, t.stssBody) : null
|
|
569
630
|
|
|
570
631
|
const stblParts = [stsd, stts]
|
|
632
|
+
if (t.cttsRaw) stblParts.push(t.cttsRaw)
|
|
571
633
|
if (stss) stblParts.push(stss)
|
|
572
634
|
stblParts.push(stsc, stsz, stco)
|
|
573
635
|
if (t.isAudio) {
|
|
@@ -650,77 +712,125 @@ async function encodeVideo ({ file, srcMeta, plan, onProgress }) {
|
|
|
650
712
|
const targetWidth = Math.max(2, Number(plan?.width) || Math.round(w * scale))
|
|
651
713
|
const targetHeight = Math.max(2, Number(plan?.height) || Math.round(h * scale))
|
|
652
714
|
|
|
653
|
-
const fallbackPlan = plan || await determineEncodingPlan(file, { width: w, height: h })
|
|
715
|
+
const fallbackPlan = plan || await determineEncodingPlan(file, { width: w, height: h, duration: durationCfr })
|
|
654
716
|
const targetFps = Math.max(1, Number(fallbackPlan.fps))
|
|
655
717
|
const step = 1 / Math.max(1, targetFps)
|
|
656
718
|
const frames = Math.max(1, Math.floor(durationCfr / step))
|
|
657
719
|
|
|
658
720
|
const output = new Output({ format: new Mp4OutputFormat({ fastStart: 'in-memory' }), target: new BufferTarget() })
|
|
659
|
-
const
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
sampleRate: TARGET_AUDIO_SR,
|
|
678
|
-
onEncodedPacket: (_packet, meta) => {
|
|
679
|
-
const aot = 2; const idx = 3; const b0 = (aot << 3) | (idx >> 1); const b1 = ((idx & 1) << 7) | (TARGET_AUDIO_CHANNELS << 3)
|
|
680
|
-
meta.decoderConfig = { codec: 'mp4a.40.2', numberOfChannels: TARGET_AUDIO_CHANNELS, sampleRate: TARGET_AUDIO_SR, description: new Uint8Array([b0, b1]) }
|
|
721
|
+
const passthroughInput = fallbackPlan.copyVideo ? new Input({ source: new BlobSource(file), formats: ALL_FORMATS }) : null
|
|
722
|
+
const passthroughTracks = passthroughInput ? await passthroughInput.getTracks() : []
|
|
723
|
+
const passthroughTrack = passthroughTracks.find(t => typeof t.isVideoTrack === 'function' && t.isVideoTrack())
|
|
724
|
+
const passthroughAudioTrack = passthroughTrack && passthroughTracks.find(t =>
|
|
725
|
+
typeof t.isAudioTrack === 'function' && t.isAudioTrack() &&
|
|
726
|
+
t.codec === 'aac' && [1, 2].includes(t.numberOfChannels) && [44_100, 48_000].includes(t.sampleRate)
|
|
727
|
+
)
|
|
728
|
+
const encoder = passthroughTrack ? null : await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps: targetFps, bitrate: fallbackPlan.bitrate })
|
|
729
|
+
const videoTrack = new EncodedVideoPacketSource(passthroughTrack?.codec || encoder.codecId)
|
|
730
|
+
output.addVideoTrack(videoTrack, passthroughTrack ? { rotation: passthroughTrack.rotation } : { frameRate: targetFps })
|
|
731
|
+
|
|
732
|
+
let audioBuffer = null
|
|
733
|
+
if (!passthroughAudioTrack) {
|
|
734
|
+
const _warn = console.warn
|
|
735
|
+
console.warn = (...args) => {
|
|
736
|
+
const m = args && args[0]
|
|
737
|
+
if (typeof m === 'string' && m.includes('Unsupported audio codec') && m.includes('apac')) return
|
|
738
|
+
_warn.apply(console, args)
|
|
681
739
|
}
|
|
682
|
-
|
|
740
|
+
audioBuffer = await decodeAudioPCM(file, { duration: durationCfr })
|
|
741
|
+
console.warn = _warn
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const audioSource = passthroughAudioTrack
|
|
745
|
+
? new EncodedAudioPacketSource('aac')
|
|
746
|
+
: new AudioSampleSource({
|
|
747
|
+
codec: 'aac',
|
|
748
|
+
bitrate: TARGET_AUDIO_BITRATE,
|
|
749
|
+
bitrateMode: 'constant',
|
|
750
|
+
numberOfChannels: TARGET_AUDIO_CHANNELS,
|
|
751
|
+
sampleRate: TARGET_AUDIO_SR,
|
|
752
|
+
onEncodedPacket: (_packet, meta) => {
|
|
753
|
+
const aot = 2; const idx = 3; const b0 = (aot << 3) | (idx >> 1); const b1 = ((idx & 1) << 7) | (TARGET_AUDIO_CHANNELS << 3)
|
|
754
|
+
meta.decoderConfig = { codec: 'mp4a.40.2', numberOfChannels: TARGET_AUDIO_CHANNELS, sampleRate: TARGET_AUDIO_SR, description: new Uint8Array([b0, b1]) }
|
|
755
|
+
}
|
|
756
|
+
})
|
|
683
757
|
output.addAudioTrack(audioSource)
|
|
684
758
|
|
|
685
759
|
await output.start()
|
|
760
|
+
const passthroughAudioPromise = passthroughAudioTrack && (async () => {
|
|
761
|
+
const sink = new EncodedPacketSink(passthroughAudioTrack)
|
|
762
|
+
const decoderConfig = await passthroughAudioTrack.getDecoderConfig()
|
|
763
|
+
const firstTimestamp = await passthroughAudioTrack.getFirstTimestamp()
|
|
764
|
+
for await (const packet of sink.packets()) {
|
|
765
|
+
await audioSource.add(packet.clone({ timestamp: Math.max(0, packet.timestamp - firstTimestamp) }), { decoderConfig: decoderConfig || undefined })
|
|
766
|
+
}
|
|
767
|
+
audioSource.close()
|
|
768
|
+
})()
|
|
686
769
|
|
|
687
|
-
let
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
770
|
+
let videoDuration = durationCfr
|
|
771
|
+
if (passthroughTrack) {
|
|
772
|
+
const sink = new EncodedPacketSink(passthroughTrack)
|
|
773
|
+
const decoderConfig = await passthroughTrack.getDecoderConfig()
|
|
774
|
+
const firstTimestamp = await passthroughTrack.getFirstTimestamp()
|
|
775
|
+
videoDuration = 0
|
|
776
|
+
for await (const packet of sink.packets(undefined, undefined, { verifyKeyPackets: true })) {
|
|
777
|
+
const normalizedPacket = packet.clone({ timestamp: Math.max(0, packet.timestamp - firstTimestamp) })
|
|
778
|
+
await videoTrack.add(normalizedPacket, { decoderConfig: decoderConfig || undefined })
|
|
779
|
+
videoDuration = Math.max(videoDuration, normalizedPacket.timestamp + normalizedPacket.duration)
|
|
780
|
+
if (typeof onProgress === 'function') {
|
|
781
|
+
try {
|
|
782
|
+
onProgress(Math.min(1, videoDuration / durationCfr))
|
|
783
|
+
} catch (err) {
|
|
784
|
+
console.warn('straight-to-video: onProgress callback threw; ignoring error', err)
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
videoTrack.close()
|
|
789
|
+
} else {
|
|
790
|
+
let codecDesc = null
|
|
791
|
+
const pendingPackets = []
|
|
792
|
+
const ve = new VideoEncoder({
|
|
793
|
+
output: (chunk, meta) => {
|
|
794
|
+
if (!codecDesc && meta?.decoderConfig?.description) codecDesc = meta.decoderConfig.description
|
|
795
|
+
pendingPackets.push({ chunk })
|
|
796
|
+
},
|
|
797
|
+
error: () => {}
|
|
798
|
+
})
|
|
799
|
+
ve.configure(encoder.config)
|
|
800
|
+
|
|
801
|
+
const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight
|
|
802
|
+
const ctx = canvas.getContext('2d', { alpha: false })
|
|
803
|
+
|
|
804
|
+
await (shouldDecodeViaVideoElement()
|
|
805
|
+
? encodeFramesViaVideoElement({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress })
|
|
806
|
+
: encodeFramesViaVideoSampleSink({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress }))
|
|
807
|
+
await ve.flush()
|
|
808
|
+
|
|
809
|
+
const muxCount = Math.min(frames, pendingPackets.length)
|
|
810
|
+
videoDuration = muxCount * step
|
|
811
|
+
const keyFrameEvery = keyFramesEveryNthFrame(step)
|
|
812
|
+
for (let i = 0; i < muxCount; i++) {
|
|
813
|
+
const { chunk } = pendingPackets[i]
|
|
814
|
+
const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data)
|
|
815
|
+
const ts = i * step; const dur = step
|
|
816
|
+
// WebKit labels requested keyframes as delta chunks, so trust the
|
|
817
|
+
// request cadence over chunk.type when marking sync samples
|
|
818
|
+
const pkt = new EncodedPacket(data, i % keyFrameEvery === 0 || chunk.type === 'key' ? 'key' : 'delta', ts, dur)
|
|
819
|
+
await videoTrack.add(pkt, { decoderConfig: { codec: encoder.config.codec, codedWidth: targetWidth, codedHeight: targetHeight, description: codecDesc } })
|
|
820
|
+
}
|
|
714
821
|
}
|
|
715
822
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
823
|
+
if (passthroughAudioPromise) {
|
|
824
|
+
await passthroughAudioPromise
|
|
825
|
+
} else {
|
|
826
|
+
const totalVideoSamples = videoDuration * TARGET_AUDIO_SR
|
|
827
|
+
const targetSamples = Math.max(1024, Math.floor(totalVideoSamples / 1024) * 1024 - 2048)
|
|
828
|
+
const audioExact = await renderStereo48kExact(audioBuffer, targetSamples)
|
|
829
|
+
const interleaved = interleaveStereoF32(audioExact)
|
|
830
|
+
const sample = new AudioSample({ format: 'f32', sampleRate: TARGET_AUDIO_SR, numberOfChannels: TARGET_AUDIO_CHANNELS, timestamp: 0, data: interleaved })
|
|
831
|
+
await audioSource.add(sample)
|
|
832
|
+
audioSource.close()
|
|
833
|
+
}
|
|
724
834
|
await output.finalize()
|
|
725
835
|
const normalized = await normalizeMp4Container(output.target.buffer)
|
|
726
836
|
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.14",
|
|
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.14",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"mediabunny": "^1.27.3"
|
data/package.json
CHANGED