straight_to_video 0.0.13 → 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 +5 -0
- data/app/assets/javascripts/straight-to-video.js +25 -6
- data/index.js +24 -5
- 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,10 @@
|
|
|
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
|
+
|
|
3
8
|
## 0.0.13
|
|
4
9
|
|
|
5
10
|
* Fast-start already-compliant MP4 and MOV uploads without re-encoding their media packets.
|
|
@@ -1,4 +1,4 @@
|
|
|
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 -----
|
|
@@ -16,6 +16,7 @@ const TARGET_AUDIO_BITRATE = 96_000
|
|
|
16
16
|
const TARGET_AUDIO_SR = 48_000
|
|
17
17
|
const TARGET_AUDIO_CHANNELS = 2
|
|
18
18
|
const MAX_VIDEO_ENCODER_QUEUE_SIZE = 4
|
|
19
|
+
const KEY_FRAME_INTERVAL_SECONDS = 2
|
|
19
20
|
|
|
20
21
|
// ----- Video metadata probe -----
|
|
21
22
|
async function probeVideo (file) {
|
|
@@ -206,6 +207,14 @@ function shouldDecodeViaVideoElement () {
|
|
|
206
207
|
return (navigator?.vendor || '').includes('Apple')
|
|
207
208
|
}
|
|
208
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
|
+
|
|
209
218
|
async function applyVideoEncoderBackpressure (encoder) {
|
|
210
219
|
while (encoder.encodeQueueSize > MAX_VIDEO_ENCODER_QUEUE_SIZE) {
|
|
211
220
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
@@ -260,6 +269,7 @@ async function encodeFramesViaVideoElement ({ file, durationCfr, step, frames, c
|
|
|
260
269
|
}
|
|
261
270
|
})
|
|
262
271
|
|
|
272
|
+
const keyFrameEvery = keyFramesEveryNthFrame(step)
|
|
263
273
|
for (let i = 0; i < frames; i++) {
|
|
264
274
|
const t = i * step
|
|
265
275
|
const drawTime = Math.min(Math.max(0, t + (step * 0.5)), Math.max(0.000001, durationCfr - 0.000001))
|
|
@@ -274,7 +284,7 @@ async function encodeFramesViaVideoElement ({ file, durationCfr, step, frames, c
|
|
|
274
284
|
|
|
275
285
|
ctx.drawImage(v, 0, 0, canvas.width, canvas.height)
|
|
276
286
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
277
|
-
ve.encode(vf, { keyFrame: i === 0 })
|
|
287
|
+
ve.encode(vf, { keyFrame: i % keyFrameEvery === 0 })
|
|
278
288
|
vf.close()
|
|
279
289
|
await applyVideoEncoderBackpressure(ve)
|
|
280
290
|
|
|
@@ -304,6 +314,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
304
314
|
sample.drawWithFit(ctx, { fit: 'fill' })
|
|
305
315
|
}
|
|
306
316
|
|
|
317
|
+
const keyFrameEvery = keyFramesEveryNthFrame(step)
|
|
307
318
|
let i = 0
|
|
308
319
|
let prev = null
|
|
309
320
|
let prevStart = 0
|
|
@@ -321,7 +332,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
321
332
|
if (displayTime < prevStart || displayTime >= end) break
|
|
322
333
|
const t = i * step
|
|
323
334
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
324
|
-
ve.encode(vf, { keyFrame: i === 0 })
|
|
335
|
+
ve.encode(vf, { keyFrame: i % keyFrameEvery === 0 })
|
|
325
336
|
vf.close()
|
|
326
337
|
await applyVideoEncoderBackpressure(ve)
|
|
327
338
|
|
|
@@ -348,7 +359,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
348
359
|
while (i < frames) {
|
|
349
360
|
const t = i * step
|
|
350
361
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
351
|
-
ve.encode(vf, { keyFrame: i === 0 })
|
|
362
|
+
ve.encode(vf, { keyFrame: i % keyFrameEvery === 0 })
|
|
352
363
|
vf.close()
|
|
353
364
|
await applyVideoEncoderBackpressure(ve)
|
|
354
365
|
|
|
@@ -498,6 +509,7 @@ function _extractTrack (u8, dv, trakBox) {
|
|
|
498
509
|
const stszBox = stblKids.find(b => b.type === 'stsz')
|
|
499
510
|
const stcoBox = stblKids.find(b => b.type === 'stco')
|
|
500
511
|
const stssBox = stblKids.find(b => b.type === 'stss')
|
|
512
|
+
const cttsBox = stblKids.find(b => b.type === 'ctts')
|
|
501
513
|
if (!stsdBox || !sttsBox || !stscBox || !stszBox || !stcoBox) return null
|
|
502
514
|
|
|
503
515
|
const sampleCount = dv.getUint32(stszBox.offset + 16)
|
|
@@ -509,6 +521,9 @@ function _extractTrack (u8, dv, trakBox) {
|
|
|
509
521
|
const stscBody = u8.slice(stscBox.offset + 12, stscBox.offset + stscBox.size)
|
|
510
522
|
const stszBody = u8.slice(stszBox.offset + 12, stszBox.offset + stszBox.size)
|
|
511
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
|
|
512
527
|
|
|
513
528
|
const entryOff = stsdBox.offset + 16
|
|
514
529
|
const entrySize = dv.getUint32(entryOff)
|
|
@@ -546,7 +561,7 @@ function _extractTrack (u8, dv, trakBox) {
|
|
|
546
561
|
tkDur, tkW, tkH, mdTs, mdDur, mdLang,
|
|
547
562
|
videoCodecConfig, audioSpecificConfig,
|
|
548
563
|
audioChannels, audioSampleSize, audioSampleRate,
|
|
549
|
-
sttsBody, stscBody, stszBody, stssBody,
|
|
564
|
+
sttsBody, stscBody, stszBody, stssBody, cttsRaw,
|
|
550
565
|
stcoEntries, sampleCount, bitrate
|
|
551
566
|
}
|
|
552
567
|
}
|
|
@@ -615,6 +630,7 @@ function _buildTrak (t) {
|
|
|
615
630
|
const stss = t.stssBody ? _full('stss', 0, 0, t.stssBody) : null
|
|
616
631
|
|
|
617
632
|
const stblParts = [stsd, stts]
|
|
633
|
+
if (t.cttsRaw) stblParts.push(t.cttsRaw)
|
|
618
634
|
if (stss) stblParts.push(stss)
|
|
619
635
|
stblParts.push(stsc, stsz, stco)
|
|
620
636
|
if (t.isAudio) {
|
|
@@ -793,11 +809,14 @@ async function encodeVideo ({ file, srcMeta, plan, onProgress }) {
|
|
|
793
809
|
|
|
794
810
|
const muxCount = Math.min(frames, pendingPackets.length)
|
|
795
811
|
videoDuration = muxCount * step
|
|
812
|
+
const keyFrameEvery = keyFramesEveryNthFrame(step)
|
|
796
813
|
for (let i = 0; i < muxCount; i++) {
|
|
797
814
|
const { chunk } = pendingPackets[i]
|
|
798
815
|
const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data)
|
|
799
816
|
const ts = i * step; const dur = step
|
|
800
|
-
|
|
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)
|
|
801
820
|
await videoTrack.add(pkt, { decoderConfig: { codec: encoder.config.codec, codedWidth: targetWidth, codedHeight: targetHeight, description: codecDesc } })
|
|
802
821
|
}
|
|
803
822
|
}
|
data/index.js
CHANGED
|
@@ -15,6 +15,7 @@ const TARGET_AUDIO_BITRATE = 96_000
|
|
|
15
15
|
const TARGET_AUDIO_SR = 48_000
|
|
16
16
|
const TARGET_AUDIO_CHANNELS = 2
|
|
17
17
|
const MAX_VIDEO_ENCODER_QUEUE_SIZE = 4
|
|
18
|
+
const KEY_FRAME_INTERVAL_SECONDS = 2
|
|
18
19
|
|
|
19
20
|
// ----- Video metadata probe -----
|
|
20
21
|
async function probeVideo (file) {
|
|
@@ -205,6 +206,14 @@ function shouldDecodeViaVideoElement () {
|
|
|
205
206
|
return (navigator?.vendor || '').includes('Apple')
|
|
206
207
|
}
|
|
207
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
|
+
|
|
208
217
|
async function applyVideoEncoderBackpressure (encoder) {
|
|
209
218
|
while (encoder.encodeQueueSize > MAX_VIDEO_ENCODER_QUEUE_SIZE) {
|
|
210
219
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
@@ -259,6 +268,7 @@ async function encodeFramesViaVideoElement ({ file, durationCfr, step, frames, c
|
|
|
259
268
|
}
|
|
260
269
|
})
|
|
261
270
|
|
|
271
|
+
const keyFrameEvery = keyFramesEveryNthFrame(step)
|
|
262
272
|
for (let i = 0; i < frames; i++) {
|
|
263
273
|
const t = i * step
|
|
264
274
|
const drawTime = Math.min(Math.max(0, t + (step * 0.5)), Math.max(0.000001, durationCfr - 0.000001))
|
|
@@ -273,7 +283,7 @@ async function encodeFramesViaVideoElement ({ file, durationCfr, step, frames, c
|
|
|
273
283
|
|
|
274
284
|
ctx.drawImage(v, 0, 0, canvas.width, canvas.height)
|
|
275
285
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
276
|
-
ve.encode(vf, { keyFrame: i === 0 })
|
|
286
|
+
ve.encode(vf, { keyFrame: i % keyFrameEvery === 0 })
|
|
277
287
|
vf.close()
|
|
278
288
|
await applyVideoEncoderBackpressure(ve)
|
|
279
289
|
|
|
@@ -303,6 +313,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
303
313
|
sample.drawWithFit(ctx, { fit: 'fill' })
|
|
304
314
|
}
|
|
305
315
|
|
|
316
|
+
const keyFrameEvery = keyFramesEveryNthFrame(step)
|
|
306
317
|
let i = 0
|
|
307
318
|
let prev = null
|
|
308
319
|
let prevStart = 0
|
|
@@ -320,7 +331,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
320
331
|
if (displayTime < prevStart || displayTime >= end) break
|
|
321
332
|
const t = i * step
|
|
322
333
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
323
|
-
ve.encode(vf, { keyFrame: i === 0 })
|
|
334
|
+
ve.encode(vf, { keyFrame: i % keyFrameEvery === 0 })
|
|
324
335
|
vf.close()
|
|
325
336
|
await applyVideoEncoderBackpressure(ve)
|
|
326
337
|
|
|
@@ -347,7 +358,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
347
358
|
while (i < frames) {
|
|
348
359
|
const t = i * step
|
|
349
360
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
350
|
-
ve.encode(vf, { keyFrame: i === 0 })
|
|
361
|
+
ve.encode(vf, { keyFrame: i % keyFrameEvery === 0 })
|
|
351
362
|
vf.close()
|
|
352
363
|
await applyVideoEncoderBackpressure(ve)
|
|
353
364
|
|
|
@@ -497,6 +508,7 @@ function _extractTrack (u8, dv, trakBox) {
|
|
|
497
508
|
const stszBox = stblKids.find(b => b.type === 'stsz')
|
|
498
509
|
const stcoBox = stblKids.find(b => b.type === 'stco')
|
|
499
510
|
const stssBox = stblKids.find(b => b.type === 'stss')
|
|
511
|
+
const cttsBox = stblKids.find(b => b.type === 'ctts')
|
|
500
512
|
if (!stsdBox || !sttsBox || !stscBox || !stszBox || !stcoBox) return null
|
|
501
513
|
|
|
502
514
|
const sampleCount = dv.getUint32(stszBox.offset + 16)
|
|
@@ -508,6 +520,9 @@ function _extractTrack (u8, dv, trakBox) {
|
|
|
508
520
|
const stscBody = u8.slice(stscBox.offset + 12, stscBox.offset + stscBox.size)
|
|
509
521
|
const stszBody = u8.slice(stszBox.offset + 12, stszBox.offset + stszBox.size)
|
|
510
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
|
|
511
526
|
|
|
512
527
|
const entryOff = stsdBox.offset + 16
|
|
513
528
|
const entrySize = dv.getUint32(entryOff)
|
|
@@ -545,7 +560,7 @@ function _extractTrack (u8, dv, trakBox) {
|
|
|
545
560
|
tkDur, tkW, tkH, mdTs, mdDur, mdLang,
|
|
546
561
|
videoCodecConfig, audioSpecificConfig,
|
|
547
562
|
audioChannels, audioSampleSize, audioSampleRate,
|
|
548
|
-
sttsBody, stscBody, stszBody, stssBody,
|
|
563
|
+
sttsBody, stscBody, stszBody, stssBody, cttsRaw,
|
|
549
564
|
stcoEntries, sampleCount, bitrate
|
|
550
565
|
}
|
|
551
566
|
}
|
|
@@ -614,6 +629,7 @@ function _buildTrak (t) {
|
|
|
614
629
|
const stss = t.stssBody ? _full('stss', 0, 0, t.stssBody) : null
|
|
615
630
|
|
|
616
631
|
const stblParts = [stsd, stts]
|
|
632
|
+
if (t.cttsRaw) stblParts.push(t.cttsRaw)
|
|
617
633
|
if (stss) stblParts.push(stss)
|
|
618
634
|
stblParts.push(stsc, stsz, stco)
|
|
619
635
|
if (t.isAudio) {
|
|
@@ -792,11 +808,14 @@ async function encodeVideo ({ file, srcMeta, plan, onProgress }) {
|
|
|
792
808
|
|
|
793
809
|
const muxCount = Math.min(frames, pendingPackets.length)
|
|
794
810
|
videoDuration = muxCount * step
|
|
811
|
+
const keyFrameEvery = keyFramesEveryNthFrame(step)
|
|
795
812
|
for (let i = 0; i < muxCount; i++) {
|
|
796
813
|
const { chunk } = pendingPackets[i]
|
|
797
814
|
const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data)
|
|
798
815
|
const ts = i * step; const dur = step
|
|
799
|
-
|
|
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)
|
|
800
819
|
await videoTrack.add(pkt, { decoderConfig: { codec: encoder.config.codec, codedWidth: targetWidth, codedHeight: targetHeight, description: codecDesc } })
|
|
801
820
|
}
|
|
802
821
|
}
|
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