straight_to_video 0.0.7 → 0.0.9

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.
@@ -1,11 +1,11 @@
1
- // straight-to-video@0.0.7 vendored by the straight_to_video gem
1
+ // straight-to-video@0.0.9 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,6 +170,10 @@ async function selectVideoEncoderConfig ({ width, height, fps }) {
140
170
  return { codecId: 'avc', config: supA.config }
141
171
  }
142
172
 
173
+ function shouldDecodeViaVideoElement () {
174
+ return (navigator?.vendor || '').includes('Apple')
175
+ }
176
+
143
177
  async function waitForFrameReady (video, budgetMs) {
144
178
  if (typeof video.requestVideoFrameCallback !== 'function') return false
145
179
  return await new Promise((resolve) => {
@@ -163,16 +197,144 @@ async function seekOnce (video, time) {
163
197
  })
164
198
  }
165
199
 
166
- async function encodeVideo ({ file, srcMeta, onProgress }) {
200
+ async function encodeFramesViaVideoElement ({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress }) {
201
+ const url = URL.createObjectURL(file)
202
+ const v = document.createElement('video')
203
+ v.muted = true; v.preload = 'auto'; v.playsInline = true
204
+ await new Promise((resolve, reject) => {
205
+ const onLoaded = () => {
206
+ v.removeEventListener('loadedmetadata', onLoaded)
207
+ v.removeEventListener('error', onError)
208
+ resolve()
209
+ }
210
+ const onError = () => {
211
+ v.removeEventListener('loadedmetadata', onLoaded)
212
+ v.removeEventListener('error', onError)
213
+ reject(new Error('video load failed'))
214
+ }
215
+ v.addEventListener('loadedmetadata', onLoaded)
216
+ v.addEventListener('error', onError)
217
+ v.src = url
218
+ try {
219
+ v.load()
220
+ } catch (err) {
221
+ console.warn('straight-to-video: video.load() threw; continuing without explicit load()', err)
222
+ }
223
+ })
224
+
225
+ for (let i = 0; i < frames; i++) {
226
+ const t = i * step
227
+ const drawTime = Math.min(Math.max(0, t + (step * 0.5)), Math.max(0.000001, durationCfr - 0.000001))
228
+ await seekOnce(v, drawTime)
229
+ const budgetMs = Math.min(34, Math.max(17, Math.round(step * 1000)))
230
+ const presented = await waitForFrameReady(v, budgetMs)
231
+ if (!presented && i === 0) {
232
+ const nudge = Math.min(step * 0.25, 0.004)
233
+ const target = Math.min(drawTime + nudge, Math.max(0.000001, durationCfr - 0.000001))
234
+ await seekOnce(v, target)
235
+ }
236
+
237
+ ctx.drawImage(v, 0, 0, canvas.width, canvas.height)
238
+ const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
239
+ ve.encode(vf, { keyFrame: i === 0 })
240
+ vf.close()
241
+
242
+ if (typeof onProgress === 'function') {
243
+ try {
244
+ onProgress(Math.min(1, (i + 1) / frames))
245
+ } catch (err) {
246
+ console.warn('straight-to-video: onProgress callback threw; ignoring error', err)
247
+ }
248
+ }
249
+ }
250
+
251
+ URL.revokeObjectURL(url)
252
+ }
253
+
254
+ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress }) {
255
+ const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS })
256
+ const tracks = await input.getTracks()
257
+ const video = tracks.find(t => typeof t.isVideoTrack === 'function' && t.isVideoTrack())
258
+ if (!video || !(await video.canDecode())) throw new Error('video track is not decodable by this browser')
259
+ const sink = new VideoSampleSink(video)
260
+ const draw = (sample) => {
261
+ if (ctx.resetTransform) ctx.resetTransform()
262
+ else ctx.setTransform(1, 0, 0, 1, 0, 0)
263
+ ctx.fillStyle = 'black'
264
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
265
+ sample.drawWithFit(ctx, { fit: 'fill' })
266
+ }
267
+
268
+ let i = 0
269
+ let prev = null
270
+ let prevStart = 0
271
+
272
+ for await (const sample of sink.samples(0, durationCfr + step)) {
273
+ const ts = Math.max(0, Number(sample.timestamp))
274
+ if (!prev) { prev = sample; prevStart = ts; continue }
275
+
276
+ const end = Math.max(prevStart, ts)
277
+ const displayTime = (i + 0.5) * step
278
+ if (displayTime < end) {
279
+ draw(prev)
280
+ while (i < frames) {
281
+ const displayTime = (i + 0.5) * step
282
+ if (displayTime < prevStart || displayTime >= end) break
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
+
288
+ if (typeof onProgress === 'function') {
289
+ try {
290
+ onProgress(Math.min(1, (i + 1) / frames))
291
+ } catch (err) {
292
+ console.warn('straight-to-video: onProgress callback threw; ignoring error', err)
293
+ }
294
+ }
295
+
296
+ i++
297
+ }
298
+ }
299
+
300
+ if (typeof prev.close === 'function') prev.close()
301
+ prev = sample
302
+ prevStart = ts
303
+ if (i >= frames) break
304
+ }
305
+
306
+ if (prev) {
307
+ draw(prev)
308
+ while (i < frames) {
309
+ const t = i * step
310
+ const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
311
+ ve.encode(vf, { keyFrame: i === 0 })
312
+ vf.close()
313
+
314
+ if (typeof onProgress === 'function') {
315
+ try {
316
+ onProgress(Math.min(1, (i + 1) / frames))
317
+ } catch (err) {
318
+ console.warn('straight-to-video: onProgress callback threw; ignoring error', err)
319
+ }
320
+ }
321
+
322
+ i++
323
+ }
324
+ if (typeof prev.close === 'function') prev.close()
325
+ }
326
+ }
327
+
328
+ async function encodeVideo ({ file, srcMeta, plan, onProgress }) {
167
329
  const w = srcMeta.w
168
330
  const h = srcMeta.h
169
331
  const durationCfr = Number(srcMeta.duration)
170
332
  const long = Math.max(w, h)
171
333
  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))
334
+ const targetWidth = Math.max(2, Number(plan?.width) || Math.round(w * scale))
335
+ const targetHeight = Math.max(2, Number(plan?.height) || Math.round(h * scale))
174
336
 
175
- const targetFps = Math.max(w, h) <= 1920 ? 30 : 60
337
+ const targetFps = Math.max(1, Number(plan?.fps) || await determineTargetFps(file, { width: w, height: h }))
176
338
  const step = 1 / Math.max(1, targetFps)
177
339
  const frames = Math.max(1, Math.floor(durationCfr / step))
178
340
 
@@ -216,62 +378,13 @@ async function encodeVideo ({ file, srcMeta, onProgress }) {
216
378
  })
217
379
  ve.configure(usedCfg)
218
380
 
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
381
  const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight
243
382
  const ctx = canvas.getContext('2d', { alpha: false })
244
383
 
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)
258
- }
259
-
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()
264
-
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
- }
271
- }
272
- }
384
+ await (shouldDecodeViaVideoElement()
385
+ ? encodeFramesViaVideoElement({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress })
386
+ : encodeFramesViaVideoSampleSink({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress }))
273
387
  await ve.flush()
274
- URL.revokeObjectURL(url)
275
388
 
276
389
  const muxCount = Math.min(frames, pendingPackets.length)
277
390
 
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,6 +169,10 @@ async function selectVideoEncoderConfig ({ width, height, fps }) {
139
169
  return { codecId: 'avc', config: supA.config }
140
170
  }
141
171
 
172
+ function shouldDecodeViaVideoElement () {
173
+ return (navigator?.vendor || '').includes('Apple')
174
+ }
175
+
142
176
  async function waitForFrameReady (video, budgetMs) {
143
177
  if (typeof video.requestVideoFrameCallback !== 'function') return false
144
178
  return await new Promise((resolve) => {
@@ -162,16 +196,144 @@ async function seekOnce (video, time) {
162
196
  })
163
197
  }
164
198
 
165
- async function encodeVideo ({ file, srcMeta, onProgress }) {
199
+ async function encodeFramesViaVideoElement ({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress }) {
200
+ const url = URL.createObjectURL(file)
201
+ const v = document.createElement('video')
202
+ v.muted = true; v.preload = 'auto'; v.playsInline = true
203
+ await new Promise((resolve, reject) => {
204
+ const onLoaded = () => {
205
+ v.removeEventListener('loadedmetadata', onLoaded)
206
+ v.removeEventListener('error', onError)
207
+ resolve()
208
+ }
209
+ const onError = () => {
210
+ v.removeEventListener('loadedmetadata', onLoaded)
211
+ v.removeEventListener('error', onError)
212
+ reject(new Error('video load failed'))
213
+ }
214
+ v.addEventListener('loadedmetadata', onLoaded)
215
+ v.addEventListener('error', onError)
216
+ v.src = url
217
+ try {
218
+ v.load()
219
+ } catch (err) {
220
+ console.warn('straight-to-video: video.load() threw; continuing without explicit load()', err)
221
+ }
222
+ })
223
+
224
+ for (let i = 0; i < frames; i++) {
225
+ const t = i * step
226
+ const drawTime = Math.min(Math.max(0, t + (step * 0.5)), Math.max(0.000001, durationCfr - 0.000001))
227
+ await seekOnce(v, drawTime)
228
+ const budgetMs = Math.min(34, Math.max(17, Math.round(step * 1000)))
229
+ const presented = await waitForFrameReady(v, budgetMs)
230
+ if (!presented && i === 0) {
231
+ const nudge = Math.min(step * 0.25, 0.004)
232
+ const target = Math.min(drawTime + nudge, Math.max(0.000001, durationCfr - 0.000001))
233
+ await seekOnce(v, target)
234
+ }
235
+
236
+ ctx.drawImage(v, 0, 0, canvas.width, canvas.height)
237
+ const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
238
+ ve.encode(vf, { keyFrame: i === 0 })
239
+ vf.close()
240
+
241
+ if (typeof onProgress === 'function') {
242
+ try {
243
+ onProgress(Math.min(1, (i + 1) / frames))
244
+ } catch (err) {
245
+ console.warn('straight-to-video: onProgress callback threw; ignoring error', err)
246
+ }
247
+ }
248
+ }
249
+
250
+ URL.revokeObjectURL(url)
251
+ }
252
+
253
+ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress }) {
254
+ const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS })
255
+ const tracks = await input.getTracks()
256
+ const video = tracks.find(t => typeof t.isVideoTrack === 'function' && t.isVideoTrack())
257
+ if (!video || !(await video.canDecode())) throw new Error('video track is not decodable by this browser')
258
+ const sink = new VideoSampleSink(video)
259
+ const draw = (sample) => {
260
+ if (ctx.resetTransform) ctx.resetTransform()
261
+ else ctx.setTransform(1, 0, 0, 1, 0, 0)
262
+ ctx.fillStyle = 'black'
263
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
264
+ sample.drawWithFit(ctx, { fit: 'fill' })
265
+ }
266
+
267
+ let i = 0
268
+ let prev = null
269
+ let prevStart = 0
270
+
271
+ for await (const sample of sink.samples(0, durationCfr + step)) {
272
+ const ts = Math.max(0, Number(sample.timestamp))
273
+ if (!prev) { prev = sample; prevStart = ts; continue }
274
+
275
+ const end = Math.max(prevStart, ts)
276
+ const displayTime = (i + 0.5) * step
277
+ if (displayTime < end) {
278
+ draw(prev)
279
+ while (i < frames) {
280
+ const displayTime = (i + 0.5) * step
281
+ if (displayTime < prevStart || displayTime >= end) break
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
+
287
+ if (typeof onProgress === 'function') {
288
+ try {
289
+ onProgress(Math.min(1, (i + 1) / frames))
290
+ } catch (err) {
291
+ console.warn('straight-to-video: onProgress callback threw; ignoring error', err)
292
+ }
293
+ }
294
+
295
+ i++
296
+ }
297
+ }
298
+
299
+ if (typeof prev.close === 'function') prev.close()
300
+ prev = sample
301
+ prevStart = ts
302
+ if (i >= frames) break
303
+ }
304
+
305
+ if (prev) {
306
+ draw(prev)
307
+ while (i < frames) {
308
+ const t = i * step
309
+ const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
310
+ ve.encode(vf, { keyFrame: i === 0 })
311
+ vf.close()
312
+
313
+ if (typeof onProgress === 'function') {
314
+ try {
315
+ onProgress(Math.min(1, (i + 1) / frames))
316
+ } catch (err) {
317
+ console.warn('straight-to-video: onProgress callback threw; ignoring error', err)
318
+ }
319
+ }
320
+
321
+ i++
322
+ }
323
+ if (typeof prev.close === 'function') prev.close()
324
+ }
325
+ }
326
+
327
+ async function encodeVideo ({ file, srcMeta, plan, onProgress }) {
166
328
  const w = srcMeta.w
167
329
  const h = srcMeta.h
168
330
  const durationCfr = Number(srcMeta.duration)
169
331
  const long = Math.max(w, h)
170
332
  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))
333
+ const targetWidth = Math.max(2, Number(plan?.width) || Math.round(w * scale))
334
+ const targetHeight = Math.max(2, Number(plan?.height) || Math.round(h * scale))
173
335
 
174
- const targetFps = Math.max(w, h) <= 1920 ? 30 : 60
336
+ const targetFps = Math.max(1, Number(plan?.fps) || await determineTargetFps(file, { width: w, height: h }))
175
337
  const step = 1 / Math.max(1, targetFps)
176
338
  const frames = Math.max(1, Math.floor(durationCfr / step))
177
339
 
@@ -215,62 +377,13 @@ async function encodeVideo ({ file, srcMeta, onProgress }) {
215
377
  })
216
378
  ve.configure(usedCfg)
217
379
 
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
380
  const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight
242
381
  const ctx = canvas.getContext('2d', { alpha: false })
243
382
 
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)
257
- }
258
-
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()
263
-
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
- }
270
- }
271
- }
383
+ await (shouldDecodeViaVideoElement()
384
+ ? encodeFramesViaVideoElement({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress })
385
+ : encodeFramesViaVideoSampleSink({ file, durationCfr, step, frames, canvas, ctx, ve, onProgress }))
272
386
  await ve.flush()
273
- URL.revokeObjectURL(url)
274
387
 
275
388
  const muxCount = Math.min(frames, pendingPackets.length)
276
389
 
@@ -1,3 +1,3 @@
1
1
  module StraightToVideo
2
- VERSION = "0.0.7"
2
+ VERSION = "0.0.9"
3
3
  end