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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c562c9b7243f141e7524a58e115bd257d5207bd7272bf01547812e9845235e75
4
- data.tar.gz: bbe4b5f3e82e6d84fb928c9165d5c8935416ed6c04034c55db44c740db949785
3
+ metadata.gz: 0cb2a8345491c6b11662c50c5d7604a9ac78024303e066da972ece9b8c967881
4
+ data.tar.gz: 3ec93954a73ad30c65804b77ebdd5bfb7d8ebb10004913de7be5e955416e5f71
5
5
  SHA512:
6
- metadata.gz: 368159b6977e3d37b116a55a35cca1d849029657ae86b01aa8113f9385288dab86f140d98a5bbb4d9639464e7e2ef8c362e204b52c45e5e2c36927e9aaa4db93
7
- data.tar.gz: 8acd9f7af6b1293e7b2a6bd4626e6dc0897a09811684bcf8a7e27260dd48071306c1aba8b61c6b8d9a76f1aac0f5552eb8edd43f0e7e9cc9259e800531a3803d
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.12 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 -----
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 { codecId, config: usedCfg } = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps: targetFps, bitrate: fallbackPlan.bitrate })
661
- const videoTrack = new EncodedVideoPacketSource(codecId)
662
- output.addVideoTrack(videoTrack, { frameRate: targetFps })
663
-
664
- const _warn = console.warn
665
- console.warn = (...args) => {
666
- const m = args && args[0]
667
- if (typeof m === 'string' && m.includes('Unsupported audio codec') && m.includes('apac')) return
668
- _warn.apply(console, args)
669
- }
670
- const audioBuffer = await decodeAudioPCM(file, { duration: durationCfr })
671
- console.warn = _warn
672
-
673
- const audioSource = new AudioSampleSource({
674
- codec: 'aac',
675
- bitrate: TARGET_AUDIO_BITRATE,
676
- bitrateMode: 'constant',
677
- numberOfChannels: TARGET_AUDIO_CHANNELS,
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 codecDesc = null
689
- const pendingPackets = []
690
- const ve = new VideoEncoder({
691
- output: (chunk, meta) => {
692
- if (!codecDesc && meta?.decoderConfig?.description) codecDesc = meta.decoderConfig.description
693
- pendingPackets.push({ chunk })
694
- },
695
- error: () => {}
696
- })
697
- ve.configure(usedCfg)
698
-
699
- const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight
700
- const ctx = canvas.getContext('2d', { alpha: false })
701
-
702
- await (shouldDecodeViaVideoElement()
703
- ? encodeFramesViaVideoElement({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress })
704
- : encodeFramesViaVideoSampleSink({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress }))
705
- await ve.flush()
706
-
707
- const muxCount = Math.min(frames, pendingPackets.length)
708
-
709
- for (let i = 0; i < muxCount; i++) {
710
- const { chunk } = pendingPackets[i]
711
- const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data)
712
- const ts = i * step; const dur = step
713
- const pkt = new EncodedPacket(data, i === 0 || chunk.type === 'key' ? 'key' : 'delta', ts, dur)
714
- await videoTrack.add(pkt, { decoderConfig: { codec: usedCfg.codec, codedWidth: targetWidth, codedHeight: targetHeight, description: codecDesc } })
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
- const samplesPerVideoFrame = TARGET_AUDIO_SR / targetFps
718
- const totalVideoSamples = muxCount * samplesPerVideoFrame
719
- const targetSamples = Math.max(1024, Math.floor(totalVideoSamples / 1024) * 1024 - 2048)
720
- const audioExact = await renderStereo48kExact(audioBuffer, targetSamples)
721
- const interleaved = interleaveStereoF32(audioExact)
722
- const sample = new AudioSample({ format: 'f32', sampleRate: TARGET_AUDIO_SR, numberOfChannels: TARGET_AUDIO_CHANNELS, timestamp: 0, data: interleaved })
723
- await audioSource.add(sample)
724
- audioSource.close()
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 { codecId, config: usedCfg } = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps: targetFps, bitrate: fallbackPlan.bitrate })
660
- const videoTrack = new EncodedVideoPacketSource(codecId)
661
- output.addVideoTrack(videoTrack, { frameRate: targetFps })
662
-
663
- const _warn = console.warn
664
- console.warn = (...args) => {
665
- const m = args && args[0]
666
- if (typeof m === 'string' && m.includes('Unsupported audio codec') && m.includes('apac')) return
667
- _warn.apply(console, args)
668
- }
669
- const audioBuffer = await decodeAudioPCM(file, { duration: durationCfr })
670
- console.warn = _warn
671
-
672
- const audioSource = new AudioSampleSource({
673
- codec: 'aac',
674
- bitrate: TARGET_AUDIO_BITRATE,
675
- bitrateMode: 'constant',
676
- numberOfChannels: TARGET_AUDIO_CHANNELS,
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 codecDesc = null
688
- const pendingPackets = []
689
- const ve = new VideoEncoder({
690
- output: (chunk, meta) => {
691
- if (!codecDesc && meta?.decoderConfig?.description) codecDesc = meta.decoderConfig.description
692
- pendingPackets.push({ chunk })
693
- },
694
- error: () => {}
695
- })
696
- ve.configure(usedCfg)
697
-
698
- const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight
699
- const ctx = canvas.getContext('2d', { alpha: false })
700
-
701
- await (shouldDecodeViaVideoElement()
702
- ? encodeFramesViaVideoElement({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress })
703
- : encodeFramesViaVideoSampleSink({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress }))
704
- await ve.flush()
705
-
706
- const muxCount = Math.min(frames, pendingPackets.length)
707
-
708
- for (let i = 0; i < muxCount; i++) {
709
- const { chunk } = pendingPackets[i]
710
- const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data)
711
- const ts = i * step; const dur = step
712
- const pkt = new EncodedPacket(data, i === 0 || chunk.type === 'key' ? 'key' : 'delta', ts, dur)
713
- await videoTrack.add(pkt, { decoderConfig: { codec: usedCfg.codec, codedWidth: targetWidth, codedHeight: targetHeight, description: codecDesc } })
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
- const samplesPerVideoFrame = TARGET_AUDIO_SR / targetFps
717
- const totalVideoSamples = muxCount * samplesPerVideoFrame
718
- const targetSamples = Math.max(1024, Math.floor(totalVideoSamples / 1024) * 1024 - 2048)
719
- const audioExact = await renderStereo48kExact(audioBuffer, targetSamples)
720
- const interleaved = interleaveStereoF32(audioExact)
721
- const sample = new AudioSample({ format: 'f32', sampleRate: TARGET_AUDIO_SR, numberOfChannels: TARGET_AUDIO_CHANNELS, timestamp: 0, data: interleaved })
722
- await audioSource.add(sample)
723
- audioSource.close()
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)
@@ -1,3 +1,3 @@
1
1
  module StraightToVideo
2
- VERSION = "0.0.12"
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.12",
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.12",
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.12",
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.12
4
+ version: 0.0.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Searls