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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7412c0ad6de37671f90aad3c474a2dfc2f846e656de524248791ec318ad598b7
4
- data.tar.gz: 4032432057ff6b71831f3e509e4300f34a8f662c70b977923ec60edfce2d15ae
3
+ metadata.gz: 0c035f1721bc3449252bf94c3c3160990e111e5260ea7ae48e5b6dfb80fd6119
4
+ data.tar.gz: 711a94bd20dc309ea33d7c4052997d01c0825e4a3c112bc5ba8c20908666e6e5
5
5
  SHA512:
6
- metadata.gz: 6c1d31ff446323e0304df7abfa4d02d7a52b883149a97f810753b55944447e5001a9ec810fa37525a8a0e5be7662c552df5c70d740d82b8ba367389c2d86d1ac
7
- data.tar.gz: d8244161a7cd07734f24f7bf24037131db2c7ccaceb54e89330474edc575d00c32d82b24317ae24561e9bd42f1191b06d986c19ae770e829f81ef973598cc330
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.7 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,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
- for (let i = 0; i < frames; i++) {
246
- const t = i * step
247
- const targetTime = Math.min(Math.max(0, t), Math.max(0.000001, durationCfr - 0.000001))
248
- const drawTime = i === 0
249
- ? Math.min(Math.max(0, t + (step * 0.5)), Math.max(0.000001, durationCfr - 0.000001))
250
- : targetTime
251
- await seekOnce(v, drawTime)
252
- const budgetMs = Math.min(34, Math.max(17, Math.round(step * 1000)))
253
- const presented = await waitForFrameReady(v, budgetMs)
254
- if (!presented && i === 0) {
255
- const nudge = Math.min(step * 0.25, 0.004)
256
- const target = Math.min(drawTime + nudge, Math.max(0.000001, durationCfr - 0.000001))
257
- 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
+ }
258
272
  }
259
273
 
260
- ctx.drawImage(v, 0, 0, canvas.width, canvas.height)
261
- const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
262
- ve.encode(vf, { keyFrame: i === 0 })
263
- vf.close()
274
+ if (typeof prev.close === 'function') prev.close()
275
+ prev = sample
276
+ prevStart = ts
277
+ if (i >= frames) break
278
+ }
264
279
 
265
- if (typeof onProgress === 'function') {
266
- try {
267
- onProgress(Math.min(1, (i + 1) / frames))
268
- } catch (err) {
269
- console.warn('straight-to-video: onProgress callback threw; ignoring error', err)
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 = 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,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
- for (let i = 0; i < frames; i++) {
245
- const t = i * step
246
- const targetTime = Math.min(Math.max(0, t), Math.max(0.000001, durationCfr - 0.000001))
247
- const drawTime = i === 0
248
- ? Math.min(Math.max(0, t + (step * 0.5)), Math.max(0.000001, durationCfr - 0.000001))
249
- : targetTime
250
- await seekOnce(v, drawTime)
251
- const budgetMs = Math.min(34, Math.max(17, Math.round(step * 1000)))
252
- const presented = await waitForFrameReady(v, budgetMs)
253
- if (!presented && i === 0) {
254
- const nudge = Math.min(step * 0.25, 0.004)
255
- const target = Math.min(drawTime + nudge, Math.max(0.000001, durationCfr - 0.000001))
256
- 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
+ }
257
271
  }
258
272
 
259
- ctx.drawImage(v, 0, 0, canvas.width, canvas.height)
260
- const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
261
- ve.encode(vf, { keyFrame: i === 0 })
262
- vf.close()
273
+ if (typeof prev.close === 'function') prev.close()
274
+ prev = sample
275
+ prevStart = ts
276
+ if (i >= frames) break
277
+ }
263
278
 
264
- if (typeof onProgress === 'function') {
265
- try {
266
- onProgress(Math.min(1, (i + 1) / frames))
267
- } catch (err) {
268
- console.warn('straight-to-video: onProgress callback threw; ignoring error', err)
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
 
@@ -1,3 +1,3 @@
1
1
  module StraightToVideo
2
- VERSION = "0.0.7"
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.7",
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.7",
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.7",
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.7
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: []