straight_to_video 0.0.7 → 0.0.8
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 +6 -0
- data/app/assets/javascripts/straight-to-video.js +97 -80
- data/index.js +96 -79
- data/lib/straight_to_video/version.rb +1 -1
- data/package-lock.json +8 -2
- data/package.json +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0c035f1721bc3449252bf94c3c3160990e111e5260ea7ae48e5b6dfb80fd6119
|
|
4
|
+
data.tar.gz: 711a94bd20dc309ea33d7c4052997d01c0825e4a3c112bc5ba8c20908666e6e5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b7894364d7de02dd76aa797623ce4c35d622a4837947a4b35092f20c9c77553ad657cfcca7b8ca737bba9b74a76d17f9a77fbc2a0cd6fa1d06016e9d1dd057b0
|
|
7
|
+
data.tar.gz: 4f88cfbd33080993ed8aa0448c6b0576bc1435763ff291b4eddebb835f5d7b38bb3893311a2ebc79374996ef6ca6942865e21cd60f67b7893631f2f58eaa5827
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.8
|
|
4
|
+
|
|
5
|
+
- fix periodic frame jutter by selecting source frames using mid-frame timestamps instead of boundary timestamps
|
|
6
|
+
- improve cadence on some iPhone MOVs by decoding frames via WebCodecs (Mediabunny `VideoSampleSink`) instead of per-frame `<video>` seeks
|
|
7
|
+
- avoid accidental 30→60fps upsampling by choosing 60fps only for high-FPS sources
|
|
8
|
+
|
|
3
9
|
## 0.0.7
|
|
4
10
|
|
|
5
11
|
- guard against a race condition for forms that subscribe to change events but don't disable submission while optimize is already underway
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// straight-to-video@0.0.
|
|
1
|
+
// straight-to-video@0.0.8 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
|
|
8
|
+
AudioSampleSource, AudioSample, EncodedVideoPacketSource, EncodedPacket, EncodedPacketSink, VideoSampleSink
|
|
9
9
|
} from 'mediabunny'
|
|
10
10
|
|
|
11
11
|
// ----- Constants -----
|
|
@@ -33,6 +33,36 @@ async function probeVideo (file) {
|
|
|
33
33
|
})
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
async function estimateSourceVideoFps (file) {
|
|
37
|
+
try {
|
|
38
|
+
const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS })
|
|
39
|
+
const tracks = await input.getTracks()
|
|
40
|
+
const video = tracks.find(t => typeof t.isVideoTrack === 'function' && t.isVideoTrack())
|
|
41
|
+
if (!video) return 0
|
|
42
|
+
const sink = new EncodedPacketSink(video)
|
|
43
|
+
const durations = []
|
|
44
|
+
for await (const packet of sink.packets(undefined, undefined, { metadataOnly: true })) {
|
|
45
|
+
const dur = Number(packet?.duration)
|
|
46
|
+
if (packet.timestamp >= 0 && Number.isFinite(dur) && dur > 0) durations.push(dur)
|
|
47
|
+
if (durations.length >= 120) break
|
|
48
|
+
}
|
|
49
|
+
if (!durations.length) return 0
|
|
50
|
+
durations.sort((a, b) => a - b)
|
|
51
|
+
const dur = durations[Math.floor(durations.length / 2)]
|
|
52
|
+
return Number.isFinite(dur) && dur > 0 ? (1 / dur) : 0
|
|
53
|
+
} catch (_) {
|
|
54
|
+
return 0
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function determineTargetFps (file, { width, height }) {
|
|
59
|
+
const maxFps = Math.max(width, height) <= 1920 ? 30 : 60
|
|
60
|
+
if (maxFps === 30) return 30
|
|
61
|
+
|
|
62
|
+
const fps = await estimateSourceVideoFps(file)
|
|
63
|
+
return fps >= 45 ? 60 : 30
|
|
64
|
+
}
|
|
65
|
+
|
|
36
66
|
// ----- Audio helpers -----
|
|
37
67
|
async function decodeAudioPCM (file, { duration }) {
|
|
38
68
|
const totalFrames = Math.max(1, Math.ceil(Number(duration) * TARGET_AUDIO_SR))
|
|
@@ -94,7 +124,7 @@ async function canOptimizeVideo (file) {
|
|
|
94
124
|
const scale = Math.min(1, MAX_LONG_SIDE / Math.max(2, long))
|
|
95
125
|
const targetWidth = Math.max(2, Math.round(width * scale))
|
|
96
126
|
const targetHeight = Math.max(2, Math.round(height * scale))
|
|
97
|
-
const fps =
|
|
127
|
+
const fps = await determineTargetFps(file, { width, height })
|
|
98
128
|
const sup = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps }).then(() => true).catch(() => false)
|
|
99
129
|
if (!sup) return { ok: false, reason: 'unsupported-video-config', message: 'No supported encoder configuration for this resolution on this device.' }
|
|
100
130
|
|
|
@@ -111,7 +141,7 @@ async function canOptimizeVideo (file) {
|
|
|
111
141
|
const hasEbml = buf.length >= 4 && buf[0] === 0x1A && buf[1] === 0x45 && buf[2] === 0xDF && buf[3] === 0xA3
|
|
112
142
|
if (!(hasFtyp || hasEbml)) return { ok: false, reason: 'unknown-container', message: 'Unrecognized container; expected MP4/MOV or WebM.' }
|
|
113
143
|
}
|
|
114
|
-
return { ok: true, reason: 'ok', message: 'ok' }
|
|
144
|
+
return { ok: true, reason: 'ok', message: 'ok', plan: { width: targetWidth, height: targetHeight, fps } }
|
|
115
145
|
} catch (e) {
|
|
116
146
|
return { ok: false, reason: 'probe-failed', message: String(e?.message || e) }
|
|
117
147
|
}
|
|
@@ -120,13 +150,13 @@ async function canOptimizeVideo (file) {
|
|
|
120
150
|
async function optimizeVideo (file, { onProgress } = {}) {
|
|
121
151
|
if (!(file instanceof File)) return { changed: false, file }
|
|
122
152
|
const type = file.type || ''
|
|
123
|
-
if (!/^video\//i.test(type)) return { changed: false, file }
|
|
153
|
+
if (type && !/^video\//i.test(type)) return { changed: false, file }
|
|
124
154
|
if (typeof window === 'undefined' || !('VideoEncoder' in window)) return { changed: false, file }
|
|
125
155
|
const feas = await canOptimizeVideo(file)
|
|
126
156
|
if (!feas.ok) return { changed: false, file }
|
|
127
157
|
|
|
128
158
|
const srcMeta = await probeVideo(file)
|
|
129
|
-
const newFile = await encodeVideo({ file, srcMeta: { w: srcMeta.width, h: srcMeta.height, duration: srcMeta.duration }, onProgress })
|
|
159
|
+
const newFile = await encodeVideo({ file, srcMeta: { w: srcMeta.width, h: srcMeta.height, duration: srcMeta.duration }, plan: feas.plan, onProgress })
|
|
130
160
|
return { changed: true, file: newFile }
|
|
131
161
|
}
|
|
132
162
|
|
|
@@ -140,39 +170,16 @@ async function selectVideoEncoderConfig ({ width, height, fps }) {
|
|
|
140
170
|
return { codecId: 'avc', config: supA.config }
|
|
141
171
|
}
|
|
142
172
|
|
|
143
|
-
async function
|
|
144
|
-
if (typeof video.requestVideoFrameCallback !== 'function') return false
|
|
145
|
-
return await new Promise((resolve) => {
|
|
146
|
-
let settled = false
|
|
147
|
-
const to = setTimeout(() => { if (!settled) { settled = true; resolve(false) } }, Math.max(1, budgetMs || 17))
|
|
148
|
-
video.requestVideoFrameCallback(() => { if (!settled) { settled = true; clearTimeout(to); resolve(true) } })
|
|
149
|
-
})
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async function seekOnce (video, time) {
|
|
153
|
-
if (!video) return
|
|
154
|
-
const t = Number.isFinite(time) ? time : 0
|
|
155
|
-
if (Math.abs(video.currentTime - t) < 1e-6) return
|
|
156
|
-
await new Promise((resolve) => {
|
|
157
|
-
const onSeeked = () => {
|
|
158
|
-
video.removeEventListener('seeked', onSeeked)
|
|
159
|
-
resolve()
|
|
160
|
-
}
|
|
161
|
-
video.addEventListener('seeked', onSeeked, { once: true })
|
|
162
|
-
video.currentTime = t
|
|
163
|
-
})
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async function encodeVideo ({ file, srcMeta, onProgress }) {
|
|
173
|
+
async function encodeVideo ({ file, srcMeta, plan, onProgress }) {
|
|
167
174
|
const w = srcMeta.w
|
|
168
175
|
const h = srcMeta.h
|
|
169
176
|
const durationCfr = Number(srcMeta.duration)
|
|
170
177
|
const long = Math.max(w, h)
|
|
171
178
|
const scale = Math.min(1, MAX_LONG_SIDE / Math.max(2, long))
|
|
172
|
-
const targetWidth = Math.max(2, Math.round(w * scale))
|
|
173
|
-
const targetHeight = Math.max(2, Math.round(h * scale))
|
|
179
|
+
const targetWidth = Math.max(2, Number(plan?.width) || Math.round(w * scale))
|
|
180
|
+
const targetHeight = Math.max(2, Number(plan?.height) || Math.round(h * scale))
|
|
174
181
|
|
|
175
|
-
const targetFps = Math.max(
|
|
182
|
+
const targetFps = Math.max(1, Number(plan?.fps) || await determineTargetFps(file, { width: w, height: h }))
|
|
176
183
|
const step = 1 / Math.max(1, targetFps)
|
|
177
184
|
const frames = Math.max(1, Math.floor(durationCfr / step))
|
|
178
185
|
|
|
@@ -216,62 +223,72 @@ async function encodeVideo ({ file, srcMeta, onProgress }) {
|
|
|
216
223
|
})
|
|
217
224
|
ve.configure(usedCfg)
|
|
218
225
|
|
|
219
|
-
const url = URL.createObjectURL(file)
|
|
220
|
-
const v = document.createElement('video')
|
|
221
|
-
v.muted = true; v.preload = 'auto'; v.playsInline = true
|
|
222
|
-
await new Promise((resolve, reject) => {
|
|
223
|
-
const onLoaded = () => {
|
|
224
|
-
v.removeEventListener('loadedmetadata', onLoaded)
|
|
225
|
-
v.removeEventListener('error', onError)
|
|
226
|
-
resolve()
|
|
227
|
-
}
|
|
228
|
-
const onError = () => {
|
|
229
|
-
v.removeEventListener('loadedmetadata', onLoaded)
|
|
230
|
-
v.removeEventListener('error', onError)
|
|
231
|
-
reject(new Error('video load failed'))
|
|
232
|
-
}
|
|
233
|
-
v.addEventListener('loadedmetadata', onLoaded)
|
|
234
|
-
v.addEventListener('error', onError)
|
|
235
|
-
v.src = url
|
|
236
|
-
try {
|
|
237
|
-
v.load()
|
|
238
|
-
} catch (err) {
|
|
239
|
-
console.warn('straight-to-video: video.load() threw; continuing without explicit load()', err)
|
|
240
|
-
}
|
|
241
|
-
})
|
|
242
226
|
const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight
|
|
243
227
|
const ctx = canvas.getContext('2d', { alpha: false })
|
|
244
228
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
229
|
+
const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS })
|
|
230
|
+
const tracks = await input.getTracks()
|
|
231
|
+
const video = tracks.find(t => typeof t.isVideoTrack === 'function' && t.isVideoTrack())
|
|
232
|
+
if (!video || !(await video.canDecode())) throw new Error('video track is not decodable by this browser')
|
|
233
|
+
const sink = new VideoSampleSink(video)
|
|
234
|
+
const draw = (sample) => {
|
|
235
|
+
if (ctx.resetTransform) ctx.resetTransform()
|
|
236
|
+
else ctx.setTransform(1, 0, 0, 1, 0, 0)
|
|
237
|
+
ctx.fillStyle = 'black'
|
|
238
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
239
|
+
sample.drawWithFit(ctx, { fit: 'fill' })
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let i = 0
|
|
243
|
+
let prev = null
|
|
244
|
+
let prevStart = 0
|
|
245
|
+
|
|
246
|
+
for await (const sample of sink.samples(0, durationCfr + step)) {
|
|
247
|
+
const ts = Math.max(0, Number(sample.timestamp))
|
|
248
|
+
if (!prev) { prev = sample; prevStart = ts; continue }
|
|
249
|
+
|
|
250
|
+
const end = Math.max(prevStart, ts)
|
|
251
|
+
const displayTime = (i + 0.5) * step
|
|
252
|
+
if (displayTime < end) {
|
|
253
|
+
draw(prev)
|
|
254
|
+
while (i < frames) {
|
|
255
|
+
const displayTime = (i + 0.5) * step
|
|
256
|
+
if (displayTime < prevStart || displayTime >= end) break
|
|
257
|
+
const t = i * step
|
|
258
|
+
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
259
|
+
ve.encode(vf, { keyFrame: i === 0 })
|
|
260
|
+
vf.close()
|
|
261
|
+
|
|
262
|
+
if (typeof onProgress === 'function') {
|
|
263
|
+
try {
|
|
264
|
+
onProgress(Math.min(1, (i + 1) / frames))
|
|
265
|
+
} catch (err) {
|
|
266
|
+
console.warn('straight-to-video: onProgress callback threw; ignoring error', err)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
i++
|
|
271
|
+
}
|
|
258
272
|
}
|
|
259
273
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
274
|
+
if (typeof prev.close === 'function') prev.close()
|
|
275
|
+
prev = sample
|
|
276
|
+
prevStart = ts
|
|
277
|
+
if (i >= frames) break
|
|
278
|
+
}
|
|
264
279
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
280
|
+
if (prev) {
|
|
281
|
+
draw(prev)
|
|
282
|
+
while (i < frames) {
|
|
283
|
+
const t = i * step
|
|
284
|
+
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
285
|
+
ve.encode(vf, { keyFrame: i === 0 })
|
|
286
|
+
vf.close()
|
|
287
|
+
i++
|
|
271
288
|
}
|
|
289
|
+
if (typeof prev.close === 'function') prev.close()
|
|
272
290
|
}
|
|
273
291
|
await ve.flush()
|
|
274
|
-
URL.revokeObjectURL(url)
|
|
275
292
|
|
|
276
293
|
const muxCount = Math.min(frames, pendingPackets.length)
|
|
277
294
|
|
data/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import {
|
|
5
5
|
Input, ALL_FORMATS, BlobSource, AudioBufferSink,
|
|
6
6
|
Output, Mp4OutputFormat, BufferTarget,
|
|
7
|
-
AudioSampleSource, AudioSample, EncodedVideoPacketSource, EncodedPacket
|
|
7
|
+
AudioSampleSource, AudioSample, EncodedVideoPacketSource, EncodedPacket, EncodedPacketSink, VideoSampleSink
|
|
8
8
|
} from 'mediabunny'
|
|
9
9
|
|
|
10
10
|
// ----- Constants -----
|
|
@@ -32,6 +32,36 @@ async function probeVideo (file) {
|
|
|
32
32
|
})
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
async function estimateSourceVideoFps (file) {
|
|
36
|
+
try {
|
|
37
|
+
const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS })
|
|
38
|
+
const tracks = await input.getTracks()
|
|
39
|
+
const video = tracks.find(t => typeof t.isVideoTrack === 'function' && t.isVideoTrack())
|
|
40
|
+
if (!video) return 0
|
|
41
|
+
const sink = new EncodedPacketSink(video)
|
|
42
|
+
const durations = []
|
|
43
|
+
for await (const packet of sink.packets(undefined, undefined, { metadataOnly: true })) {
|
|
44
|
+
const dur = Number(packet?.duration)
|
|
45
|
+
if (packet.timestamp >= 0 && Number.isFinite(dur) && dur > 0) durations.push(dur)
|
|
46
|
+
if (durations.length >= 120) break
|
|
47
|
+
}
|
|
48
|
+
if (!durations.length) return 0
|
|
49
|
+
durations.sort((a, b) => a - b)
|
|
50
|
+
const dur = durations[Math.floor(durations.length / 2)]
|
|
51
|
+
return Number.isFinite(dur) && dur > 0 ? (1 / dur) : 0
|
|
52
|
+
} catch (_) {
|
|
53
|
+
return 0
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function determineTargetFps (file, { width, height }) {
|
|
58
|
+
const maxFps = Math.max(width, height) <= 1920 ? 30 : 60
|
|
59
|
+
if (maxFps === 30) return 30
|
|
60
|
+
|
|
61
|
+
const fps = await estimateSourceVideoFps(file)
|
|
62
|
+
return fps >= 45 ? 60 : 30
|
|
63
|
+
}
|
|
64
|
+
|
|
35
65
|
// ----- Audio helpers -----
|
|
36
66
|
async function decodeAudioPCM (file, { duration }) {
|
|
37
67
|
const totalFrames = Math.max(1, Math.ceil(Number(duration) * TARGET_AUDIO_SR))
|
|
@@ -93,7 +123,7 @@ async function canOptimizeVideo (file) {
|
|
|
93
123
|
const scale = Math.min(1, MAX_LONG_SIDE / Math.max(2, long))
|
|
94
124
|
const targetWidth = Math.max(2, Math.round(width * scale))
|
|
95
125
|
const targetHeight = Math.max(2, Math.round(height * scale))
|
|
96
|
-
const fps =
|
|
126
|
+
const fps = await determineTargetFps(file, { width, height })
|
|
97
127
|
const sup = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps }).then(() => true).catch(() => false)
|
|
98
128
|
if (!sup) return { ok: false, reason: 'unsupported-video-config', message: 'No supported encoder configuration for this resolution on this device.' }
|
|
99
129
|
|
|
@@ -110,7 +140,7 @@ async function canOptimizeVideo (file) {
|
|
|
110
140
|
const hasEbml = buf.length >= 4 && buf[0] === 0x1A && buf[1] === 0x45 && buf[2] === 0xDF && buf[3] === 0xA3
|
|
111
141
|
if (!(hasFtyp || hasEbml)) return { ok: false, reason: 'unknown-container', message: 'Unrecognized container; expected MP4/MOV or WebM.' }
|
|
112
142
|
}
|
|
113
|
-
return { ok: true, reason: 'ok', message: 'ok' }
|
|
143
|
+
return { ok: true, reason: 'ok', message: 'ok', plan: { width: targetWidth, height: targetHeight, fps } }
|
|
114
144
|
} catch (e) {
|
|
115
145
|
return { ok: false, reason: 'probe-failed', message: String(e?.message || e) }
|
|
116
146
|
}
|
|
@@ -119,13 +149,13 @@ async function canOptimizeVideo (file) {
|
|
|
119
149
|
async function optimizeVideo (file, { onProgress } = {}) {
|
|
120
150
|
if (!(file instanceof File)) return { changed: false, file }
|
|
121
151
|
const type = file.type || ''
|
|
122
|
-
if (!/^video\//i.test(type)) return { changed: false, file }
|
|
152
|
+
if (type && !/^video\//i.test(type)) return { changed: false, file }
|
|
123
153
|
if (typeof window === 'undefined' || !('VideoEncoder' in window)) return { changed: false, file }
|
|
124
154
|
const feas = await canOptimizeVideo(file)
|
|
125
155
|
if (!feas.ok) return { changed: false, file }
|
|
126
156
|
|
|
127
157
|
const srcMeta = await probeVideo(file)
|
|
128
|
-
const newFile = await encodeVideo({ file, srcMeta: { w: srcMeta.width, h: srcMeta.height, duration: srcMeta.duration }, onProgress })
|
|
158
|
+
const newFile = await encodeVideo({ file, srcMeta: { w: srcMeta.width, h: srcMeta.height, duration: srcMeta.duration }, plan: feas.plan, onProgress })
|
|
129
159
|
return { changed: true, file: newFile }
|
|
130
160
|
}
|
|
131
161
|
|
|
@@ -139,39 +169,16 @@ async function selectVideoEncoderConfig ({ width, height, fps }) {
|
|
|
139
169
|
return { codecId: 'avc', config: supA.config }
|
|
140
170
|
}
|
|
141
171
|
|
|
142
|
-
async function
|
|
143
|
-
if (typeof video.requestVideoFrameCallback !== 'function') return false
|
|
144
|
-
return await new Promise((resolve) => {
|
|
145
|
-
let settled = false
|
|
146
|
-
const to = setTimeout(() => { if (!settled) { settled = true; resolve(false) } }, Math.max(1, budgetMs || 17))
|
|
147
|
-
video.requestVideoFrameCallback(() => { if (!settled) { settled = true; clearTimeout(to); resolve(true) } })
|
|
148
|
-
})
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
async function seekOnce (video, time) {
|
|
152
|
-
if (!video) return
|
|
153
|
-
const t = Number.isFinite(time) ? time : 0
|
|
154
|
-
if (Math.abs(video.currentTime - t) < 1e-6) return
|
|
155
|
-
await new Promise((resolve) => {
|
|
156
|
-
const onSeeked = () => {
|
|
157
|
-
video.removeEventListener('seeked', onSeeked)
|
|
158
|
-
resolve()
|
|
159
|
-
}
|
|
160
|
-
video.addEventListener('seeked', onSeeked, { once: true })
|
|
161
|
-
video.currentTime = t
|
|
162
|
-
})
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
async function encodeVideo ({ file, srcMeta, onProgress }) {
|
|
172
|
+
async function encodeVideo ({ file, srcMeta, plan, onProgress }) {
|
|
166
173
|
const w = srcMeta.w
|
|
167
174
|
const h = srcMeta.h
|
|
168
175
|
const durationCfr = Number(srcMeta.duration)
|
|
169
176
|
const long = Math.max(w, h)
|
|
170
177
|
const scale = Math.min(1, MAX_LONG_SIDE / Math.max(2, long))
|
|
171
|
-
const targetWidth = Math.max(2, Math.round(w * scale))
|
|
172
|
-
const targetHeight = Math.max(2, Math.round(h * scale))
|
|
178
|
+
const targetWidth = Math.max(2, Number(plan?.width) || Math.round(w * scale))
|
|
179
|
+
const targetHeight = Math.max(2, Number(plan?.height) || Math.round(h * scale))
|
|
173
180
|
|
|
174
|
-
const targetFps = Math.max(
|
|
181
|
+
const targetFps = Math.max(1, Number(plan?.fps) || await determineTargetFps(file, { width: w, height: h }))
|
|
175
182
|
const step = 1 / Math.max(1, targetFps)
|
|
176
183
|
const frames = Math.max(1, Math.floor(durationCfr / step))
|
|
177
184
|
|
|
@@ -215,62 +222,72 @@ async function encodeVideo ({ file, srcMeta, onProgress }) {
|
|
|
215
222
|
})
|
|
216
223
|
ve.configure(usedCfg)
|
|
217
224
|
|
|
218
|
-
const url = URL.createObjectURL(file)
|
|
219
|
-
const v = document.createElement('video')
|
|
220
|
-
v.muted = true; v.preload = 'auto'; v.playsInline = true
|
|
221
|
-
await new Promise((resolve, reject) => {
|
|
222
|
-
const onLoaded = () => {
|
|
223
|
-
v.removeEventListener('loadedmetadata', onLoaded)
|
|
224
|
-
v.removeEventListener('error', onError)
|
|
225
|
-
resolve()
|
|
226
|
-
}
|
|
227
|
-
const onError = () => {
|
|
228
|
-
v.removeEventListener('loadedmetadata', onLoaded)
|
|
229
|
-
v.removeEventListener('error', onError)
|
|
230
|
-
reject(new Error('video load failed'))
|
|
231
|
-
}
|
|
232
|
-
v.addEventListener('loadedmetadata', onLoaded)
|
|
233
|
-
v.addEventListener('error', onError)
|
|
234
|
-
v.src = url
|
|
235
|
-
try {
|
|
236
|
-
v.load()
|
|
237
|
-
} catch (err) {
|
|
238
|
-
console.warn('straight-to-video: video.load() threw; continuing without explicit load()', err)
|
|
239
|
-
}
|
|
240
|
-
})
|
|
241
225
|
const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight
|
|
242
226
|
const ctx = canvas.getContext('2d', { alpha: false })
|
|
243
227
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
228
|
+
const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS })
|
|
229
|
+
const tracks = await input.getTracks()
|
|
230
|
+
const video = tracks.find(t => typeof t.isVideoTrack === 'function' && t.isVideoTrack())
|
|
231
|
+
if (!video || !(await video.canDecode())) throw new Error('video track is not decodable by this browser')
|
|
232
|
+
const sink = new VideoSampleSink(video)
|
|
233
|
+
const draw = (sample) => {
|
|
234
|
+
if (ctx.resetTransform) ctx.resetTransform()
|
|
235
|
+
else ctx.setTransform(1, 0, 0, 1, 0, 0)
|
|
236
|
+
ctx.fillStyle = 'black'
|
|
237
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
238
|
+
sample.drawWithFit(ctx, { fit: 'fill' })
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let i = 0
|
|
242
|
+
let prev = null
|
|
243
|
+
let prevStart = 0
|
|
244
|
+
|
|
245
|
+
for await (const sample of sink.samples(0, durationCfr + step)) {
|
|
246
|
+
const ts = Math.max(0, Number(sample.timestamp))
|
|
247
|
+
if (!prev) { prev = sample; prevStart = ts; continue }
|
|
248
|
+
|
|
249
|
+
const end = Math.max(prevStart, ts)
|
|
250
|
+
const displayTime = (i + 0.5) * step
|
|
251
|
+
if (displayTime < end) {
|
|
252
|
+
draw(prev)
|
|
253
|
+
while (i < frames) {
|
|
254
|
+
const displayTime = (i + 0.5) * step
|
|
255
|
+
if (displayTime < prevStart || displayTime >= end) break
|
|
256
|
+
const t = i * step
|
|
257
|
+
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
258
|
+
ve.encode(vf, { keyFrame: i === 0 })
|
|
259
|
+
vf.close()
|
|
260
|
+
|
|
261
|
+
if (typeof onProgress === 'function') {
|
|
262
|
+
try {
|
|
263
|
+
onProgress(Math.min(1, (i + 1) / frames))
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.warn('straight-to-video: onProgress callback threw; ignoring error', err)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
i++
|
|
270
|
+
}
|
|
257
271
|
}
|
|
258
272
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
273
|
+
if (typeof prev.close === 'function') prev.close()
|
|
274
|
+
prev = sample
|
|
275
|
+
prevStart = ts
|
|
276
|
+
if (i >= frames) break
|
|
277
|
+
}
|
|
263
278
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
279
|
+
if (prev) {
|
|
280
|
+
draw(prev)
|
|
281
|
+
while (i < frames) {
|
|
282
|
+
const t = i * step
|
|
283
|
+
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
284
|
+
ve.encode(vf, { keyFrame: i === 0 })
|
|
285
|
+
vf.close()
|
|
286
|
+
i++
|
|
270
287
|
}
|
|
288
|
+
if (typeof prev.close === 'function') prev.close()
|
|
271
289
|
}
|
|
272
290
|
await ve.flush()
|
|
273
|
-
URL.revokeObjectURL(url)
|
|
274
291
|
|
|
275
292
|
const muxCount = Math.min(frames, pendingPackets.length)
|
|
276
293
|
|
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.8",
|
|
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.8",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"mediabunny": "^1.24.4"
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"@hotwired/stimulus": "^3.2.0"
|
|
20
|
+
},
|
|
21
|
+
"peerDependenciesMeta": {
|
|
22
|
+
"@hotwired/stimulus": {
|
|
23
|
+
"optional": true
|
|
24
|
+
}
|
|
20
25
|
}
|
|
21
26
|
},
|
|
22
27
|
"node_modules/@hotwired/stimulus": {
|
|
@@ -24,6 +29,7 @@
|
|
|
24
29
|
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz",
|
|
25
30
|
"integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==",
|
|
26
31
|
"license": "MIT",
|
|
32
|
+
"optional": true,
|
|
27
33
|
"peer": true
|
|
28
34
|
},
|
|
29
35
|
"node_modules/@playwright/test": {
|
data/package.json
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
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.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Justin Searls
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 2025-12-26 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: railties
|
|
@@ -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: 3.6.
|
|
80
|
+
rubygems_version: 3.6.2
|
|
81
81
|
specification_version: 4
|
|
82
82
|
summary: Browser-based, hardware-accelerated video upload optimization
|
|
83
83
|
test_files: []
|