straight_to_video 0.0.12 → 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: c562c9b7243f141e7524a58e115bd257d5207bd7272bf01547812e9845235e75
4
- data.tar.gz: bbe4b5f3e82e6d84fb928c9165d5c8935416ed6c04034c55db44c740db949785
3
+ metadata.gz: db199c55c9da4fd2927332a51b0bacc98c8ee0c4422218f9bdbbf2f280dd5648
4
+ data.tar.gz: 67c67d5170d3d7be451f2d050d2f6b2063f225f1c7dbbd8b3fc82462ddccee2f
5
5
  SHA512:
6
- metadata.gz: 368159b6977e3d37b116a55a35cca1d849029657ae86b01aa8113f9385288dab86f140d98a5bbb4d9639464e7e2ef8c362e204b52c45e5e2c36927e9aaa4db93
7
- data.tar.gz: 8acd9f7af6b1293e7b2a6bd4626e6dc0897a09811684bcf8a7e27260dd48071306c1aba8b61c6b8d9a76f1aac0f5552eb8edd43f0e7e9cc9259e800531a3803d
6
+ metadata.gz: 7a85c866bf129896f1c49d5ae9e323f7e7e13cd52f8e92c8aa64d80bc716c1f5545f7f9376f3c4c9ca5a3fcfa188cd247f04cc90650c0b1a66f09eb23dd7cf22
7
+ data.tar.gz: ec227f671fd6e10196b91cbd9ccc46abed79a317ea0d822ebccf5aa36488098d2a52efe7770081e225c95d4c1e2c91b225642632e3c8e837b616121ab75fe135
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
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
+
3
8
  ## 0.0.12
4
9
 
5
10
  * 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.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 -----
@@ -40,7 +41,7 @@ async function estimateSourceVideoStats (file) {
40
41
  const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS })
41
42
  const tracks = await input.getTracks()
42
43
  const video = tracks.find(t => typeof t.isVideoTrack === 'function' && t.isVideoTrack())
43
- if (!video) return { fps: 0, bitrate: 0 }
44
+ if (!video) return { fps: 0, bitrate: 0, codec: null, rotation: 0 }
44
45
  const sink = new EncodedPacketSink(video)
45
46
  const durations = []
46
47
  let firstTimestamp = Infinity
@@ -56,25 +57,33 @@ async function estimateSourceVideoStats (file) {
56
57
  }
57
58
  if (durations.length >= 120) break
58
59
  }
59
- if (!durations.length) return { fps: 0, bitrate: 0 }
60
+ if (!durations.length) return { fps: 0, bitrate: 0, codec: video.codec, rotation: video.rotation }
60
61
  durations.sort((a, b) => a - b)
61
62
  const duration = durations[Math.floor(durations.length / 2)]
62
63
  const sampledDuration = lastTimestamp - firstTimestamp
63
64
  return {
64
65
  fps: Number.isFinite(duration) && duration > 0 ? (1 / duration) : 0,
65
- bitrate: sampledDuration > 0 ? (totalBytes * 8 / sampledDuration) : 0
66
+ bitrate: sampledDuration > 0 ? (totalBytes * 8 / sampledDuration) : 0,
67
+ codec: video.codec,
68
+ rotation: video.rotation
66
69
  }
67
70
  } catch (_) {
68
- return { fps: 0, bitrate: 0 }
71
+ return { fps: 0, bitrate: 0, codec: null, rotation: 0 }
69
72
  }
70
73
  }
71
74
 
72
- async function determineEncodingPlan (file, { width, height }) {
75
+ async function determineEncodingPlan (file, { width, height, duration }) {
73
76
  const maxFps = Math.max(width, height) <= 1920 ? 30 : 60
74
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
75
83
  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
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
78
87
  }
79
88
  }
80
89
 
@@ -139,8 +148,8 @@ async function canOptimizeVideo (file) {
139
148
  const scale = Math.min(1, MAX_LONG_SIDE / Math.max(2, long))
140
149
  const targetWidth = Math.max(2, Math.round(width * scale))
141
150
  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)
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)
144
153
  if (!sup) return { ok: false, reason: 'unsupported-video-config', message: 'No supported encoder configuration for this resolution on this device.' }
145
154
 
146
155
  // Header sniffing when file.type is empty/incorrect
@@ -170,6 +179,14 @@ async function optimizeVideo (file, { onProgress } = {}) {
170
179
  const feas = await canOptimizeVideo(file)
171
180
  if (!feas.ok) return { changed: false, file }
172
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
+
173
190
  const srcMeta = await probeVideo(file)
174
191
  const newFile = await encodeVideo({ file, srcMeta: { w: srcMeta.width, h: srcMeta.height, duration: srcMeta.duration }, plan: feas.plan, onProgress })
175
192
  return { changed: true, file: newFile }
@@ -396,6 +413,35 @@ function _scanBoxes (u8, start, end) {
396
413
  return out
397
414
  }
398
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
+
399
445
  function _esdTag (tag, payload) {
400
446
  return _concat(new Uint8Array([tag, 0x80, 0x80, 0x80, payload.byteLength]), payload)
401
447
  }
@@ -651,77 +697,122 @@ async function encodeVideo ({ file, srcMeta, plan, onProgress }) {
651
697
  const targetWidth = Math.max(2, Number(plan?.width) || Math.round(w * scale))
652
698
  const targetHeight = Math.max(2, Number(plan?.height) || Math.round(h * scale))
653
699
 
654
- const fallbackPlan = plan || await determineEncodingPlan(file, { width: w, height: h })
700
+ const fallbackPlan = plan || await determineEncodingPlan(file, { width: w, height: h, duration: durationCfr })
655
701
  const targetFps = Math.max(1, Number(fallbackPlan.fps))
656
702
  const step = 1 / Math.max(1, targetFps)
657
703
  const frames = Math.max(1, Math.floor(durationCfr / step))
658
704
 
659
705
  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]) }
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)
682
724
  }
683
- })
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
+ })
684
742
  output.addAudioTrack(audioSource)
685
743
 
686
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
+ })()
687
754
 
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 } })
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
+ }
715
803
  }
716
804
 
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()
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
+ }
725
816
  await output.finalize()
726
817
  const normalized = await normalizeMp4Container(output.target.buffer)
727
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 -----
@@ -39,7 +40,7 @@ async function estimateSourceVideoStats (file) {
39
40
  const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS })
40
41
  const tracks = await input.getTracks()
41
42
  const video = tracks.find(t => typeof t.isVideoTrack === 'function' && t.isVideoTrack())
42
- if (!video) return { fps: 0, bitrate: 0 }
43
+ if (!video) return { fps: 0, bitrate: 0, codec: null, rotation: 0 }
43
44
  const sink = new EncodedPacketSink(video)
44
45
  const durations = []
45
46
  let firstTimestamp = Infinity
@@ -55,25 +56,33 @@ async function estimateSourceVideoStats (file) {
55
56
  }
56
57
  if (durations.length >= 120) break
57
58
  }
58
- if (!durations.length) return { fps: 0, bitrate: 0 }
59
+ if (!durations.length) return { fps: 0, bitrate: 0, codec: video.codec, rotation: video.rotation }
59
60
  durations.sort((a, b) => a - b)
60
61
  const duration = durations[Math.floor(durations.length / 2)]
61
62
  const sampledDuration = lastTimestamp - firstTimestamp
62
63
  return {
63
64
  fps: Number.isFinite(duration) && duration > 0 ? (1 / duration) : 0,
64
- bitrate: sampledDuration > 0 ? (totalBytes * 8 / sampledDuration) : 0
65
+ bitrate: sampledDuration > 0 ? (totalBytes * 8 / sampledDuration) : 0,
66
+ codec: video.codec,
67
+ rotation: video.rotation
65
68
  }
66
69
  } catch (_) {
67
- return { fps: 0, bitrate: 0 }
70
+ return { fps: 0, bitrate: 0, codec: null, rotation: 0 }
68
71
  }
69
72
  }
70
73
 
71
- async function determineEncodingPlan (file, { width, height }) {
74
+ async function determineEncodingPlan (file, { width, height, duration }) {
72
75
  const maxFps = Math.max(width, height) <= 1920 ? 30 : 60
73
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
74
82
  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
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
77
86
  }
78
87
  }
79
88
 
@@ -138,8 +147,8 @@ async function canOptimizeVideo (file) {
138
147
  const scale = Math.min(1, MAX_LONG_SIDE / Math.max(2, long))
139
148
  const targetWidth = Math.max(2, Math.round(width * scale))
140
149
  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)
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)
143
152
  if (!sup) return { ok: false, reason: 'unsupported-video-config', message: 'No supported encoder configuration for this resolution on this device.' }
144
153
 
145
154
  // Header sniffing when file.type is empty/incorrect
@@ -169,6 +178,14 @@ async function optimizeVideo (file, { onProgress } = {}) {
169
178
  const feas = await canOptimizeVideo(file)
170
179
  if (!feas.ok) return { changed: false, file }
171
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
+
172
189
  const srcMeta = await probeVideo(file)
173
190
  const newFile = await encodeVideo({ file, srcMeta: { w: srcMeta.width, h: srcMeta.height, duration: srcMeta.duration }, plan: feas.plan, onProgress })
174
191
  return { changed: true, file: newFile }
@@ -395,6 +412,35 @@ function _scanBoxes (u8, start, end) {
395
412
  return out
396
413
  }
397
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
+
398
444
  function _esdTag (tag, payload) {
399
445
  return _concat(new Uint8Array([tag, 0x80, 0x80, 0x80, payload.byteLength]), payload)
400
446
  }
@@ -650,77 +696,122 @@ async function encodeVideo ({ file, srcMeta, plan, onProgress }) {
650
696
  const targetWidth = Math.max(2, Number(plan?.width) || Math.round(w * scale))
651
697
  const targetHeight = Math.max(2, Number(plan?.height) || Math.round(h * scale))
652
698
 
653
- const fallbackPlan = plan || await determineEncodingPlan(file, { width: w, height: h })
699
+ const fallbackPlan = plan || await determineEncodingPlan(file, { width: w, height: h, duration: durationCfr })
654
700
  const targetFps = Math.max(1, Number(fallbackPlan.fps))
655
701
  const step = 1 / Math.max(1, targetFps)
656
702
  const frames = Math.max(1, Math.floor(durationCfr / step))
657
703
 
658
704
  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]) }
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)
681
723
  }
682
- })
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
+ })
683
741
  output.addAudioTrack(audioSource)
684
742
 
685
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
+ })()
686
753
 
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 } })
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
+ }
714
802
  }
715
803
 
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()
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
+ }
724
815
  await output.finalize()
725
816
  const normalized = await normalizeMp4Container(output.target.buffer)
726
817
  const payload = new Uint8Array(normalized)
@@ -1,3 +1,3 @@
1
1
  module StraightToVideo
2
- VERSION = "0.0.12"
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.12",
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.12",
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.12",
3
+ "version": "0.0.13",
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.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Searls