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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db199c55c9da4fd2927332a51b0bacc98c8ee0c4422218f9bdbbf2f280dd5648
4
- data.tar.gz: 67c67d5170d3d7be451f2d050d2f6b2063f225f1c7dbbd8b3fc82462ddccee2f
3
+ metadata.gz: 0cb2a8345491c6b11662c50c5d7604a9ac78024303e066da972ece9b8c967881
4
+ data.tar.gz: 3ec93954a73ad30c65804b77ebdd5bfb7d8ebb10004913de7be5e955416e5f71
5
5
  SHA512:
6
- metadata.gz: 7a85c866bf129896f1c49d5ae9e323f7e7e13cd52f8e92c8aa64d80bc716c1f5545f7f9376f3c4c9ca5a3fcfa188cd247f04cc90650c0b1a66f09eb23dd7cf22
7
- data.tar.gz: ec227f671fd6e10196b91cbd9ccc46abed79a317ea0d822ebccf5aa36488098d2a52efe7770081e225c95d4c1e2c91b225642632e3c8e837b616121ab75fe135
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.13 vendored by the straight_to_video gem
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
- const pkt = new EncodedPacket(data, i === 0 || chunk.type === 'key' ? 'key' : 'delta', ts, dur)
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
- const pkt = new EncodedPacket(data, i === 0 || chunk.type === 'key' ? 'key' : 'delta', ts, dur)
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
  }
@@ -1,3 +1,3 @@
1
1
  module StraightToVideo
2
- VERSION = "0.0.13"
2
+ VERSION = "0.0.14"
3
3
  end
data/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "straight-to-video",
3
- "version": "0.0.13",
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.13",
9
+ "version": "0.0.14",
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.13",
3
+ "version": "0.0.14",
4
4
  "description": "Browser-based, hardware-accelerated video upload optimization",
5
5
  "type": "module",
6
6
  "exports": {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: straight_to_video
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.13
4
+ version: 0.0.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Searls