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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 57371915cd38c1ecac814d16a43244275216661f31b639a80022685672984e22
4
- data.tar.gz: bb04ab7274edbb61031a270c8f650872b0b2338a7a1fc93a54948c8f3a3fcd04
3
+ metadata.gz: 0c035f1721bc3449252bf94c3c3160990e111e5260ea7ae48e5b6dfb80fd6119
4
+ data.tar.gz: 711a94bd20dc309ea33d7c4052997d01c0825e4a3c112bc5ba8c20908666e6e5
5
5
  SHA512:
6
- metadata.gz: 92b3e2d474ba435ac6679c26755a9d62bb5388cd3fbb984fc6b31506287b4ced1be3a7c3621efdaf1812a9feae7f60287caa9e91e3601731c60c0a450e4d8824
7
- data.tar.gz: 550b54b8fb004e7b6fea54282f00053fe47fd4bdefd20a5e87817c91495756adfe38c40d72888de883b0fbc049ad9615eb3f4f1783b05d01d92fc418356645cb
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.6 vendored by the straight_to_video gem
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 = Math.max(width, height) <= 1920 ? 30 : 60
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 waitForFrameReady (video, budgetMs) {
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(w, h) <= 1920 ? 30 : 60
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
- for (let i = 0; i < frames; i++) {
242
- const t = i * step
243
- const targetTime = Math.min(Math.max(0, t), Math.max(0.000001, durationCfr - 0.000001))
244
- const drawTime = i === 0
245
- ? Math.min(Math.max(0, t + (step * 0.5)), Math.max(0.000001, durationCfr - 0.000001))
246
- : targetTime
247
- if (i === 0) {}
248
- await seekOnce(v, drawTime)
249
- if (i === 0) {}
250
- const budgetMs = Math.min(34, Math.max(17, Math.round(step * 1000)))
251
- const presented = await waitForFrameReady(v, budgetMs)
252
- if (!presented && i === 0) {
253
- const nudge = Math.min(step * 0.25, 0.004)
254
- const target = Math.min(drawTime + nudge, Math.max(0.000001, durationCfr - 0.000001))
255
- await seekOnce(v, target)
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
- ctx.drawImage(v, 0, 0, canvas.width, canvas.height)
259
- const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
260
- ve.encode(vf, { keyFrame: i === 0 })
261
- vf.close()
274
+ if (typeof prev.close === 'function') prev.close()
275
+ prev = sample
276
+ prevStart = ts
277
+ if (i >= frames) break
278
+ }
262
279
 
263
- if (typeof onProgress === 'function') {
264
- try { onProgress(Math.min(1, (i + 1) / frames)) } catch (_) {}
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
- const ua = typeof navigator !== 'undefined' && navigator.userAgent ? navigator.userAgent : ''
356
- const isIos = /iP(hone|ad|od)/.test(ua)
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) { try { fileEl.showPicker(); return } catch (_) {} }
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 { form.requestSubmit() } catch (_) {}
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 = Math.max(width, height) <= 1920 ? 30 : 60
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 waitForFrameReady (video, budgetMs) {
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(w, h) <= 1920 ? 30 : 60
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
- for (let i = 0; i < frames; i++) {
241
- const t = i * step
242
- const targetTime = Math.min(Math.max(0, t), Math.max(0.000001, durationCfr - 0.000001))
243
- const drawTime = i === 0
244
- ? Math.min(Math.max(0, t + (step * 0.5)), Math.max(0.000001, durationCfr - 0.000001))
245
- : targetTime
246
- if (i === 0) {}
247
- await seekOnce(v, drawTime)
248
- if (i === 0) {}
249
- const budgetMs = Math.min(34, Math.max(17, Math.round(step * 1000)))
250
- const presented = await waitForFrameReady(v, budgetMs)
251
- if (!presented && i === 0) {
252
- const nudge = Math.min(step * 0.25, 0.004)
253
- const target = Math.min(drawTime + nudge, Math.max(0.000001, durationCfr - 0.000001))
254
- await seekOnce(v, target)
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
- ctx.drawImage(v, 0, 0, canvas.width, canvas.height)
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()
273
+ if (typeof prev.close === 'function') prev.close()
274
+ prev = sample
275
+ prevStart = ts
276
+ if (i >= frames) break
277
+ }
261
278
 
262
- if (typeof onProgress === 'function') {
263
- try { onProgress(Math.min(1, (i + 1) / frames)) } catch (_) {}
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
- const ua = typeof navigator !== 'undefined' && navigator.userAgent ? navigator.userAgent : ''
355
- const isIos = /iP(hone|ad|od)/.test(ua)
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 = {}) {
@@ -1,3 +1,3 @@
1
1
  module StraightToVideo
2
- VERSION = "0.0.6"
2
+ VERSION = "0.0.8"
3
3
  end
data/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "straight-to-video",
3
- "version": "0.0.6",
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.6",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "straight-to-video",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "Browser-based, hardware-accelerated video upload optimization",
5
5
  "type": "module",
6
6
  "exports": {
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.6
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: 1980-01-02 00:00:00.000000000 Z
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.9
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: []