straight_to_video 0.0.11 → 0.0.13

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