straight_to_video 0.0.10 → 0.0.12
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 +11 -0
- data/app/assets/javascripts/straight-to-video.js +48 -23
- data/index.js +47 -22
- data/lib/straight_to_video/version.rb +1 -1
- data/package-lock.json +2 -2
- data/package.json +2 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c562c9b7243f141e7524a58e115bd257d5207bd7272bf01547812e9845235e75
|
|
4
|
+
data.tar.gz: bbe4b5f3e82e6d84fb928c9165d5c8935416ed6c04034c55db44c740db949785
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 368159b6977e3d37b116a55a35cca1d849029657ae86b01aa8113f9385288dab86f140d98a5bbb4d9639464e7e2ef8c362e204b52c45e5e2c36927e9aaa4db93
|
|
7
|
+
data.tar.gz: 8acd9f7af6b1293e7b2a6bd4626e6dc0897a09811684bcf8a7e27260dd48071306c1aba8b61c6b8d9a76f1aac0f5552eb8edd43f0e7e9cc9259e800531a3803d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.12
|
|
4
|
+
|
|
5
|
+
* Bound the WebCodecs encoder queue so long videos cannot exhaust WebKit memory.
|
|
6
|
+
* Keep browser encoding at or below the source video's bitrate.
|
|
7
|
+
|
|
8
|
+
## 0.0.11
|
|
9
|
+
|
|
10
|
+
* Work around WebKit labeling the requested first HEVC keyframe as a delta packet.
|
|
11
|
+
* Keep browser-encoded video below the 25 Mbps delivery limit.
|
|
12
|
+
* Accept WebKit's full-range `yuvj420p` output as progressive 4:2:0 video.
|
|
13
|
+
|
|
3
14
|
## 0.0.10
|
|
4
15
|
|
|
5
16
|
- Fix Safari macOS Tahoe bug where native video controls never auto-hide by rewriting the moov atom to match ffmpeg's conventions (zero timestamps, standard handler names, edts/elst, extended esds, btrt, sgpd/sbgp)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// straight-to-video@0.0.
|
|
1
|
+
// straight-to-video@0.0.12 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 -----
|
|
@@ -10,9 +10,11 @@ import {
|
|
|
10
10
|
|
|
11
11
|
// ----- Constants -----
|
|
12
12
|
const MAX_LONG_SIDE = 1920
|
|
13
|
+
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,47 @@ async function probeVideo (file) {
|
|
|
33
35
|
})
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
async function
|
|
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 }
|
|
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
|
|
46
|
-
if (packet.timestamp >= 0 && Number.isFinite(
|
|
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 }
|
|
50
60
|
durations.sort((a, b) => a - b)
|
|
51
|
-
const
|
|
52
|
-
|
|
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
|
+
}
|
|
53
67
|
} catch (_) {
|
|
54
|
-
return 0
|
|
68
|
+
return { fps: 0, bitrate: 0 }
|
|
55
69
|
}
|
|
56
70
|
}
|
|
57
71
|
|
|
58
|
-
async function
|
|
72
|
+
async function determineEncodingPlan (file, { width, height }) {
|
|
59
73
|
const maxFps = Math.max(width, height) <= 1920 ? 30 : 60
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
74
|
+
const source = await estimateSourceVideoStats(file)
|
|
75
|
+
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
|
|
78
|
+
}
|
|
64
79
|
}
|
|
65
80
|
|
|
66
81
|
// ----- Audio helpers -----
|
|
@@ -124,8 +139,8 @@ async function canOptimizeVideo (file) {
|
|
|
124
139
|
const scale = Math.min(1, MAX_LONG_SIDE / Math.max(2, long))
|
|
125
140
|
const targetWidth = Math.max(2, Math.round(width * scale))
|
|
126
141
|
const targetHeight = Math.max(2, Math.round(height * scale))
|
|
127
|
-
const
|
|
128
|
-
const sup = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight,
|
|
142
|
+
const plan = await determineEncodingPlan(file, { width, height })
|
|
143
|
+
const sup = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, ...plan }).then(() => true).catch(() => false)
|
|
129
144
|
if (!sup) return { ok: false, reason: 'unsupported-video-config', message: 'No supported encoder configuration for this resolution on this device.' }
|
|
130
145
|
|
|
131
146
|
// Header sniffing when file.type is empty/incorrect
|
|
@@ -141,7 +156,7 @@ async function canOptimizeVideo (file) {
|
|
|
141
156
|
const hasEbml = buf.length >= 4 && buf[0] === 0x1A && buf[1] === 0x45 && buf[2] === 0xDF && buf[3] === 0xA3
|
|
142
157
|
if (!(hasFtyp || hasEbml)) return { ok: false, reason: 'unknown-container', message: 'Unrecognized container; expected MP4/MOV or WebM.' }
|
|
143
158
|
}
|
|
144
|
-
return { ok: true, reason: 'ok', message: 'ok', plan: { width: targetWidth, height: targetHeight,
|
|
159
|
+
return { ok: true, reason: 'ok', message: 'ok', plan: { width: targetWidth, height: targetHeight, ...plan } }
|
|
145
160
|
} catch (e) {
|
|
146
161
|
return { ok: false, reason: 'probe-failed', message: String(e?.message || e) }
|
|
147
162
|
}
|
|
@@ -160,12 +175,12 @@ async function optimizeVideo (file, { onProgress } = {}) {
|
|
|
160
175
|
return { changed: true, file: newFile }
|
|
161
176
|
}
|
|
162
177
|
|
|
163
|
-
async function selectVideoEncoderConfig ({ width, height, fps }) {
|
|
164
|
-
const hevc = { codec: 'hvc1.1.4.L123.B0', width, height, framerate: fps, hardwareAcceleration: 'prefer-hardware', hevc: { format: 'hevc' } }
|
|
178
|
+
async function selectVideoEncoderConfig ({ width, height, fps, bitrate = TARGET_VIDEO_BITRATE }) {
|
|
179
|
+
const hevc = { codec: 'hvc1.1.4.L123.B0', width, height, framerate: fps, bitrate, hardwareAcceleration: 'prefer-hardware', hevc: { format: 'hevc' } }
|
|
165
180
|
const supH = await VideoEncoder.isConfigSupported(hevc).catch(() => ({ supported: false }))
|
|
166
181
|
if (supH.supported) return { codecId: 'hevc', config: supH.config }
|
|
167
182
|
|
|
168
|
-
const avc = { codec: 'avc1.64002A', width, height, framerate: fps, hardwareAcceleration: 'prefer-hardware', avc: { format: 'avc' } }
|
|
183
|
+
const avc = { codec: 'avc1.64002A', width, height, framerate: fps, bitrate, hardwareAcceleration: 'prefer-hardware', avc: { format: 'avc' } }
|
|
169
184
|
const supA = await VideoEncoder.isConfigSupported(avc)
|
|
170
185
|
return { codecId: 'avc', config: supA.config }
|
|
171
186
|
}
|
|
@@ -174,6 +189,12 @@ function shouldDecodeViaVideoElement () {
|
|
|
174
189
|
return (navigator?.vendor || '').includes('Apple')
|
|
175
190
|
}
|
|
176
191
|
|
|
192
|
+
async function applyVideoEncoderBackpressure (encoder) {
|
|
193
|
+
while (encoder.encodeQueueSize > MAX_VIDEO_ENCODER_QUEUE_SIZE) {
|
|
194
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
177
198
|
async function waitForFrameReady (video, budgetMs) {
|
|
178
199
|
if (typeof video.requestVideoFrameCallback !== 'function') return false
|
|
179
200
|
return await new Promise((resolve) => {
|
|
@@ -238,6 +259,7 @@ async function encodeFramesViaVideoElement ({ file, durationCfr, step, frames, c
|
|
|
238
259
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
239
260
|
ve.encode(vf, { keyFrame: i === 0 })
|
|
240
261
|
vf.close()
|
|
262
|
+
await applyVideoEncoderBackpressure(ve)
|
|
241
263
|
|
|
242
264
|
if (typeof onProgress === 'function') {
|
|
243
265
|
try {
|
|
@@ -284,6 +306,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
284
306
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
285
307
|
ve.encode(vf, { keyFrame: i === 0 })
|
|
286
308
|
vf.close()
|
|
309
|
+
await applyVideoEncoderBackpressure(ve)
|
|
287
310
|
|
|
288
311
|
if (typeof onProgress === 'function') {
|
|
289
312
|
try {
|
|
@@ -310,6 +333,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
310
333
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
311
334
|
ve.encode(vf, { keyFrame: i === 0 })
|
|
312
335
|
vf.close()
|
|
336
|
+
await applyVideoEncoderBackpressure(ve)
|
|
313
337
|
|
|
314
338
|
if (typeof onProgress === 'function') {
|
|
315
339
|
try {
|
|
@@ -627,12 +651,13 @@ async function encodeVideo ({ file, srcMeta, plan, onProgress }) {
|
|
|
627
651
|
const targetWidth = Math.max(2, Number(plan?.width) || Math.round(w * scale))
|
|
628
652
|
const targetHeight = Math.max(2, Number(plan?.height) || Math.round(h * scale))
|
|
629
653
|
|
|
630
|
-
const
|
|
654
|
+
const fallbackPlan = plan || await determineEncodingPlan(file, { width: w, height: h })
|
|
655
|
+
const targetFps = Math.max(1, Number(fallbackPlan.fps))
|
|
631
656
|
const step = 1 / Math.max(1, targetFps)
|
|
632
657
|
const frames = Math.max(1, Math.floor(durationCfr / step))
|
|
633
658
|
|
|
634
659
|
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 })
|
|
660
|
+
const { codecId, config: usedCfg } = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps: targetFps, bitrate: fallbackPlan.bitrate })
|
|
636
661
|
const videoTrack = new EncodedVideoPacketSource(codecId)
|
|
637
662
|
output.addVideoTrack(videoTrack, { frameRate: targetFps })
|
|
638
663
|
|
|
@@ -685,7 +710,7 @@ async function encodeVideo ({ file, srcMeta, plan, onProgress }) {
|
|
|
685
710
|
const { chunk } = pendingPackets[i]
|
|
686
711
|
const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data)
|
|
687
712
|
const ts = i * step; const dur = step
|
|
688
|
-
const pkt = new EncodedPacket(data, chunk.type === 'key' ? 'key' : 'delta', ts, dur)
|
|
713
|
+
const pkt = new EncodedPacket(data, i === 0 || chunk.type === 'key' ? 'key' : 'delta', ts, dur)
|
|
689
714
|
await videoTrack.add(pkt, { decoderConfig: { codec: usedCfg.codec, codedWidth: targetWidth, codedHeight: targetHeight, description: codecDesc } })
|
|
690
715
|
}
|
|
691
716
|
|
data/index.js
CHANGED
|
@@ -9,9 +9,11 @@ import {
|
|
|
9
9
|
|
|
10
10
|
// ----- Constants -----
|
|
11
11
|
const MAX_LONG_SIDE = 1920
|
|
12
|
+
const TARGET_VIDEO_BITRATE = 12_000_000
|
|
12
13
|
const TARGET_AUDIO_BITRATE = 96_000
|
|
13
14
|
const TARGET_AUDIO_SR = 48_000
|
|
14
15
|
const TARGET_AUDIO_CHANNELS = 2
|
|
16
|
+
const MAX_VIDEO_ENCODER_QUEUE_SIZE = 4
|
|
15
17
|
|
|
16
18
|
// ----- Video metadata probe -----
|
|
17
19
|
async function probeVideo (file) {
|
|
@@ -32,34 +34,47 @@ async function probeVideo (file) {
|
|
|
32
34
|
})
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
async function
|
|
37
|
+
async function estimateSourceVideoStats (file) {
|
|
36
38
|
try {
|
|
37
39
|
const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS })
|
|
38
40
|
const tracks = await input.getTracks()
|
|
39
41
|
const video = tracks.find(t => typeof t.isVideoTrack === 'function' && t.isVideoTrack())
|
|
40
|
-
if (!video) return 0
|
|
42
|
+
if (!video) return { fps: 0, bitrate: 0 }
|
|
41
43
|
const sink = new EncodedPacketSink(video)
|
|
42
44
|
const durations = []
|
|
45
|
+
let firstTimestamp = Infinity
|
|
46
|
+
let lastTimestamp = -Infinity
|
|
47
|
+
let totalBytes = 0
|
|
43
48
|
for await (const packet of sink.packets(undefined, undefined, { metadataOnly: true })) {
|
|
44
|
-
const
|
|
45
|
-
if (packet.timestamp >= 0 && Number.isFinite(
|
|
49
|
+
const duration = Number(packet?.duration)
|
|
50
|
+
if (packet.timestamp >= 0 && Number.isFinite(duration) && duration > 0) {
|
|
51
|
+
durations.push(duration)
|
|
52
|
+
firstTimestamp = Math.min(firstTimestamp, packet.timestamp)
|
|
53
|
+
lastTimestamp = Math.max(lastTimestamp, packet.timestamp + duration)
|
|
54
|
+
totalBytes += packet.byteLength
|
|
55
|
+
}
|
|
46
56
|
if (durations.length >= 120) break
|
|
47
57
|
}
|
|
48
|
-
if (!durations.length) return 0
|
|
58
|
+
if (!durations.length) return { fps: 0, bitrate: 0 }
|
|
49
59
|
durations.sort((a, b) => a - b)
|
|
50
|
-
const
|
|
51
|
-
|
|
60
|
+
const duration = durations[Math.floor(durations.length / 2)]
|
|
61
|
+
const sampledDuration = lastTimestamp - firstTimestamp
|
|
62
|
+
return {
|
|
63
|
+
fps: Number.isFinite(duration) && duration > 0 ? (1 / duration) : 0,
|
|
64
|
+
bitrate: sampledDuration > 0 ? (totalBytes * 8 / sampledDuration) : 0
|
|
65
|
+
}
|
|
52
66
|
} catch (_) {
|
|
53
|
-
return 0
|
|
67
|
+
return { fps: 0, bitrate: 0 }
|
|
54
68
|
}
|
|
55
69
|
}
|
|
56
70
|
|
|
57
|
-
async function
|
|
71
|
+
async function determineEncodingPlan (file, { width, height }) {
|
|
58
72
|
const maxFps = Math.max(width, height) <= 1920 ? 30 : 60
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
73
|
+
const source = await estimateSourceVideoStats(file)
|
|
74
|
+
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
|
|
77
|
+
}
|
|
63
78
|
}
|
|
64
79
|
|
|
65
80
|
// ----- Audio helpers -----
|
|
@@ -123,8 +138,8 @@ async function canOptimizeVideo (file) {
|
|
|
123
138
|
const scale = Math.min(1, MAX_LONG_SIDE / Math.max(2, long))
|
|
124
139
|
const targetWidth = Math.max(2, Math.round(width * scale))
|
|
125
140
|
const targetHeight = Math.max(2, Math.round(height * scale))
|
|
126
|
-
const
|
|
127
|
-
const sup = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight,
|
|
141
|
+
const plan = await determineEncodingPlan(file, { width, height })
|
|
142
|
+
const sup = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, ...plan }).then(() => true).catch(() => false)
|
|
128
143
|
if (!sup) return { ok: false, reason: 'unsupported-video-config', message: 'No supported encoder configuration for this resolution on this device.' }
|
|
129
144
|
|
|
130
145
|
// Header sniffing when file.type is empty/incorrect
|
|
@@ -140,7 +155,7 @@ async function canOptimizeVideo (file) {
|
|
|
140
155
|
const hasEbml = buf.length >= 4 && buf[0] === 0x1A && buf[1] === 0x45 && buf[2] === 0xDF && buf[3] === 0xA3
|
|
141
156
|
if (!(hasFtyp || hasEbml)) return { ok: false, reason: 'unknown-container', message: 'Unrecognized container; expected MP4/MOV or WebM.' }
|
|
142
157
|
}
|
|
143
|
-
return { ok: true, reason: 'ok', message: 'ok', plan: { width: targetWidth, height: targetHeight,
|
|
158
|
+
return { ok: true, reason: 'ok', message: 'ok', plan: { width: targetWidth, height: targetHeight, ...plan } }
|
|
144
159
|
} catch (e) {
|
|
145
160
|
return { ok: false, reason: 'probe-failed', message: String(e?.message || e) }
|
|
146
161
|
}
|
|
@@ -159,12 +174,12 @@ async function optimizeVideo (file, { onProgress } = {}) {
|
|
|
159
174
|
return { changed: true, file: newFile }
|
|
160
175
|
}
|
|
161
176
|
|
|
162
|
-
async function selectVideoEncoderConfig ({ width, height, fps }) {
|
|
163
|
-
const hevc = { codec: 'hvc1.1.4.L123.B0', width, height, framerate: fps, hardwareAcceleration: 'prefer-hardware', hevc: { format: 'hevc' } }
|
|
177
|
+
async function selectVideoEncoderConfig ({ width, height, fps, bitrate = TARGET_VIDEO_BITRATE }) {
|
|
178
|
+
const hevc = { codec: 'hvc1.1.4.L123.B0', width, height, framerate: fps, bitrate, hardwareAcceleration: 'prefer-hardware', hevc: { format: 'hevc' } }
|
|
164
179
|
const supH = await VideoEncoder.isConfigSupported(hevc).catch(() => ({ supported: false }))
|
|
165
180
|
if (supH.supported) return { codecId: 'hevc', config: supH.config }
|
|
166
181
|
|
|
167
|
-
const avc = { codec: 'avc1.64002A', width, height, framerate: fps, hardwareAcceleration: 'prefer-hardware', avc: { format: 'avc' } }
|
|
182
|
+
const avc = { codec: 'avc1.64002A', width, height, framerate: fps, bitrate, hardwareAcceleration: 'prefer-hardware', avc: { format: 'avc' } }
|
|
168
183
|
const supA = await VideoEncoder.isConfigSupported(avc)
|
|
169
184
|
return { codecId: 'avc', config: supA.config }
|
|
170
185
|
}
|
|
@@ -173,6 +188,12 @@ function shouldDecodeViaVideoElement () {
|
|
|
173
188
|
return (navigator?.vendor || '').includes('Apple')
|
|
174
189
|
}
|
|
175
190
|
|
|
191
|
+
async function applyVideoEncoderBackpressure (encoder) {
|
|
192
|
+
while (encoder.encodeQueueSize > MAX_VIDEO_ENCODER_QUEUE_SIZE) {
|
|
193
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
176
197
|
async function waitForFrameReady (video, budgetMs) {
|
|
177
198
|
if (typeof video.requestVideoFrameCallback !== 'function') return false
|
|
178
199
|
return await new Promise((resolve) => {
|
|
@@ -237,6 +258,7 @@ async function encodeFramesViaVideoElement ({ file, durationCfr, step, frames, c
|
|
|
237
258
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
238
259
|
ve.encode(vf, { keyFrame: i === 0 })
|
|
239
260
|
vf.close()
|
|
261
|
+
await applyVideoEncoderBackpressure(ve)
|
|
240
262
|
|
|
241
263
|
if (typeof onProgress === 'function') {
|
|
242
264
|
try {
|
|
@@ -283,6 +305,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
283
305
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
284
306
|
ve.encode(vf, { keyFrame: i === 0 })
|
|
285
307
|
vf.close()
|
|
308
|
+
await applyVideoEncoderBackpressure(ve)
|
|
286
309
|
|
|
287
310
|
if (typeof onProgress === 'function') {
|
|
288
311
|
try {
|
|
@@ -309,6 +332,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
|
|
|
309
332
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
310
333
|
ve.encode(vf, { keyFrame: i === 0 })
|
|
311
334
|
vf.close()
|
|
335
|
+
await applyVideoEncoderBackpressure(ve)
|
|
312
336
|
|
|
313
337
|
if (typeof onProgress === 'function') {
|
|
314
338
|
try {
|
|
@@ -626,12 +650,13 @@ async function encodeVideo ({ file, srcMeta, plan, onProgress }) {
|
|
|
626
650
|
const targetWidth = Math.max(2, Number(plan?.width) || Math.round(w * scale))
|
|
627
651
|
const targetHeight = Math.max(2, Number(plan?.height) || Math.round(h * scale))
|
|
628
652
|
|
|
629
|
-
const
|
|
653
|
+
const fallbackPlan = plan || await determineEncodingPlan(file, { width: w, height: h })
|
|
654
|
+
const targetFps = Math.max(1, Number(fallbackPlan.fps))
|
|
630
655
|
const step = 1 / Math.max(1, targetFps)
|
|
631
656
|
const frames = Math.max(1, Math.floor(durationCfr / step))
|
|
632
657
|
|
|
633
658
|
const output = new Output({ format: new Mp4OutputFormat({ fastStart: 'in-memory' }), target: new BufferTarget() })
|
|
634
|
-
const { codecId, config: usedCfg } = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps: targetFps })
|
|
659
|
+
const { codecId, config: usedCfg } = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps: targetFps, bitrate: fallbackPlan.bitrate })
|
|
635
660
|
const videoTrack = new EncodedVideoPacketSource(codecId)
|
|
636
661
|
output.addVideoTrack(videoTrack, { frameRate: targetFps })
|
|
637
662
|
|
|
@@ -684,7 +709,7 @@ async function encodeVideo ({ file, srcMeta, plan, onProgress }) {
|
|
|
684
709
|
const { chunk } = pendingPackets[i]
|
|
685
710
|
const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data)
|
|
686
711
|
const ts = i * step; const dur = step
|
|
687
|
-
const pkt = new EncodedPacket(data, chunk.type === 'key' ? 'key' : 'delta', ts, dur)
|
|
712
|
+
const pkt = new EncodedPacket(data, i === 0 || chunk.type === 'key' ? 'key' : 'delta', ts, dur)
|
|
688
713
|
await videoTrack.add(pkt, { decoderConfig: { codec: usedCfg.codec, codedWidth: targetWidth, codedHeight: targetHeight, description: codecDesc } })
|
|
689
714
|
}
|
|
690
715
|
|
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.12",
|
|
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.12",
|
|
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.
|
|
3
|
+
"version": "0.0.12",
|
|
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.
|
|
4
|
+
version: 0.0.12
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Justin Searls
|
|
@@ -77,7 +77,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
77
77
|
- !ruby/object:Gem::Version
|
|
78
78
|
version: '0'
|
|
79
79
|
requirements: []
|
|
80
|
-
rubygems_version:
|
|
80
|
+
rubygems_version: 3.6.9
|
|
81
81
|
specification_version: 4
|
|
82
82
|
summary: Browser-based, hardware-accelerated video upload optimization
|
|
83
83
|
test_files: []
|