straight_to_video 0.0.6 → 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 +10 -0
- data/app/assets/javascripts/straight-to-video.js +109 -76
- data/index.html +13 -2
- data/index.js +108 -75
- 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,15 @@
|
|
|
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
|
+
|
|
9
|
+
## 0.0.7
|
|
10
|
+
|
|
11
|
+
- guard against a race condition for forms that subscribe to change events but don't disable submission while optimize is already underway
|
|
12
|
+
|
|
3
13
|
## 0.0.6
|
|
4
14
|
|
|
5
15
|
- fix iOS Safari Stimulus controller hang by making `seeked` waits robust
|
|
@@ -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,56 +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 { v.load() } catch (_) {}
|
|
237
|
-
})
|
|
238
226
|
const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight
|
|
239
227
|
const ctx = canvas.getContext('2d', { alpha: false })
|
|
240
228
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
+
}
|
|
256
272
|
}
|
|
257
273
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
274
|
+
if (typeof prev.close === 'function') prev.close()
|
|
275
|
+
prev = sample
|
|
276
|
+
prevStart = ts
|
|
277
|
+
if (i >= frames) break
|
|
278
|
+
}
|
|
262
279
|
|
|
263
|
-
|
|
264
|
-
|
|
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++
|
|
265
288
|
}
|
|
289
|
+
if (typeof prev.close === 'function') prev.close()
|
|
266
290
|
}
|
|
267
291
|
await ve.flush()
|
|
268
|
-
URL.revokeObjectURL(url)
|
|
269
292
|
|
|
270
293
|
const muxCount = Math.min(frames, pendingPackets.length)
|
|
271
294
|
|
|
@@ -352,8 +375,11 @@ function registerStraightToVideoController (app, opts = {}) {
|
|
|
352
375
|
}
|
|
353
376
|
|
|
354
377
|
async _processFileInput (fileInput) {
|
|
355
|
-
|
|
356
|
-
const
|
|
378
|
+
if (!this._pendingProcesses) this._pendingProcesses = new WeakMap()
|
|
379
|
+
const existing = this._pendingProcesses.get(fileInput)
|
|
380
|
+
if (existing) return existing
|
|
381
|
+
|
|
382
|
+
const job = (async () => {
|
|
357
383
|
this._markFlag(fileInput, 'processing')
|
|
358
384
|
fileInput.disabled = true
|
|
359
385
|
try {
|
|
@@ -372,6 +398,13 @@ function registerStraightToVideoController (app, opts = {}) {
|
|
|
372
398
|
fileInput.disabled = false
|
|
373
399
|
this._unmarkFlag(fileInput, 'processing')
|
|
374
400
|
}
|
|
401
|
+
})()
|
|
402
|
+
|
|
403
|
+
this._pendingProcesses.set(fileInput, job)
|
|
404
|
+
job.finally(() => {
|
|
405
|
+
if (this._pendingProcesses?.get(fileInput) === job) this._pendingProcesses.delete(fileInput)
|
|
406
|
+
})
|
|
407
|
+
return job
|
|
375
408
|
}
|
|
376
409
|
|
|
377
410
|
_fire (el, name, detail = {}) {
|
data/index.html
CHANGED
|
@@ -200,7 +200,14 @@
|
|
|
200
200
|
|
|
201
201
|
// Single‑button flow: open picker then auto‑submit
|
|
202
202
|
tryBtn.addEventListener('click', () => {
|
|
203
|
-
if (fileEl.showPicker) {
|
|
203
|
+
if (fileEl.showPicker) {
|
|
204
|
+
try {
|
|
205
|
+
fileEl.showPicker()
|
|
206
|
+
return
|
|
207
|
+
} catch (err) {
|
|
208
|
+
console.warn('straight-to-video demo: file input showPicker() failed; falling back to click()', err)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
204
211
|
fileEl.click()
|
|
205
212
|
})
|
|
206
213
|
|
|
@@ -211,7 +218,11 @@
|
|
|
211
218
|
sizes.classList.remove('hidden')
|
|
212
219
|
p.classList.remove('hidden')
|
|
213
220
|
pct && (pct.textContent = '0%')
|
|
214
|
-
try {
|
|
221
|
+
try {
|
|
222
|
+
form.requestSubmit()
|
|
223
|
+
} catch (err) {
|
|
224
|
+
console.warn('straight-to-video demo: form.requestSubmit() failed; user may need to submit manually', err)
|
|
225
|
+
}
|
|
215
226
|
})
|
|
216
227
|
|
|
217
228
|
// Mirror controller events into the UI
|
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,56 +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 { v.load() } catch (_) {}
|
|
236
|
-
})
|
|
237
225
|
const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight
|
|
238
226
|
const ctx = canvas.getContext('2d', { alpha: false })
|
|
239
227
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
+
}
|
|
255
271
|
}
|
|
256
272
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
273
|
+
if (typeof prev.close === 'function') prev.close()
|
|
274
|
+
prev = sample
|
|
275
|
+
prevStart = ts
|
|
276
|
+
if (i >= frames) break
|
|
277
|
+
}
|
|
261
278
|
|
|
262
|
-
|
|
263
|
-
|
|
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++
|
|
264
287
|
}
|
|
288
|
+
if (typeof prev.close === 'function') prev.close()
|
|
265
289
|
}
|
|
266
290
|
await ve.flush()
|
|
267
|
-
URL.revokeObjectURL(url)
|
|
268
291
|
|
|
269
292
|
const muxCount = Math.min(frames, pendingPackets.length)
|
|
270
293
|
|
|
@@ -351,8 +374,11 @@ function registerStraightToVideoController (app, opts = {}) {
|
|
|
351
374
|
}
|
|
352
375
|
|
|
353
376
|
async _processFileInput (fileInput) {
|
|
354
|
-
|
|
355
|
-
const
|
|
377
|
+
if (!this._pendingProcesses) this._pendingProcesses = new WeakMap()
|
|
378
|
+
const existing = this._pendingProcesses.get(fileInput)
|
|
379
|
+
if (existing) return existing
|
|
380
|
+
|
|
381
|
+
const job = (async () => {
|
|
356
382
|
this._markFlag(fileInput, 'processing')
|
|
357
383
|
fileInput.disabled = true
|
|
358
384
|
try {
|
|
@@ -371,6 +397,13 @@ function registerStraightToVideoController (app, opts = {}) {
|
|
|
371
397
|
fileInput.disabled = false
|
|
372
398
|
this._unmarkFlag(fileInput, 'processing')
|
|
373
399
|
}
|
|
400
|
+
})()
|
|
401
|
+
|
|
402
|
+
this._pendingProcesses.set(fileInput, job)
|
|
403
|
+
job.finally(() => {
|
|
404
|
+
if (this._pendingProcesses?.get(fileInput) === job) this._pendingProcesses.delete(fileInput)
|
|
405
|
+
})
|
|
406
|
+
return job
|
|
374
407
|
}
|
|
375
408
|
|
|
376
409
|
_fire (el, name, detail = {}) {
|
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: []
|