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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/app/assets/javascripts/mediabunny.min.js +9 -9
- data/app/assets/javascripts/straight-to-video.js +175 -62
- data/index.js +174 -61
- data/lib/straight_to_video/version.rb +1 -1
- data/package-lock.json +24 -18
- data/package.json +3 -3
- data/script/release +87 -33
- data/script/test +1 -1
- metadata +1 -1
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// straight-to-video@0.0.
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|