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 +4 -4
- data/CHANGELOG.md +5 -0
- data/app/assets/javascripts/straight-to-video.js +161 -70
- data/index.js +160 -69
- data/lib/straight_to_video/version.rb +1 -1
- data/package-lock.json +2 -2
- data/package.json +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: db199c55c9da4fd2927332a51b0bacc98c8ee0c4422218f9bdbbf2f280dd5648
|
|
4
|
+
data.tar.gz: 67c67d5170d3d7be451f2d050d2f6b2063f225f1c7dbbd8b3fc82462ddccee2f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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)
|
data/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "straight-to-video",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "straight-to-video",
|
|
9
|
-
"version": "0.0.
|
|
9
|
+
"version": "0.0.13",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"mediabunny": "^1.27.3"
|
data/package.json
CHANGED