straight_to_video 0.0.3
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 +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +7 -0
- data/README.md +156 -0
- data/Rakefile +4 -0
- data/app/assets/javascripts/mediabunny.min.mjs +110 -0
- data/app/assets/javascripts/straight-to-video.js +360 -0
- data/assets/img/backdrop.webp +0 -0
- data/assets/img/logo.webp +0 -0
- data/config/importmap.rb +2 -0
- data/index.html +247 -0
- data/index.js +359 -0
- data/lib/straight_to_video/engine.rb +22 -0
- data/lib/straight_to_video/version.rb +3 -0
- data/lib/straight_to_video.rb +2 -0
- data/package-lock.json +146 -0
- data/package.json +47 -0
- data/playwright.config.mjs +33 -0
- data/script/release +75 -0
- data/script/test +14 -0
- data/script/upgrade +9 -0
- data/script/vendor +52 -0
- metadata +83 -0
data/index.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// straight-to-video - https://github.com/searlsco/straight-to-video
|
|
2
|
+
|
|
3
|
+
// ----- External imports -----
|
|
4
|
+
import {
|
|
5
|
+
Input, ALL_FORMATS, BlobSource, AudioBufferSink,
|
|
6
|
+
Output, Mp4OutputFormat, BufferTarget,
|
|
7
|
+
AudioSampleSource, AudioSample, EncodedVideoPacketSource, EncodedPacket
|
|
8
|
+
} from 'mediabunny'
|
|
9
|
+
|
|
10
|
+
// ----- Constants -----
|
|
11
|
+
const MAX_LONG_SIDE = 1920
|
|
12
|
+
const TARGET_AUDIO_BITRATE = 96_000
|
|
13
|
+
const TARGET_AUDIO_SR = 48_000
|
|
14
|
+
const TARGET_AUDIO_CHANNELS = 2
|
|
15
|
+
|
|
16
|
+
// ----- Video metadata probe -----
|
|
17
|
+
async function probeVideo (file) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const url = URL.createObjectURL(file)
|
|
20
|
+
const v = document.createElement('video')
|
|
21
|
+
v.preload = 'metadata'
|
|
22
|
+
v.muted = true
|
|
23
|
+
v.src = url
|
|
24
|
+
v.onloadedmetadata = () => {
|
|
25
|
+
const width = v.videoWidth
|
|
26
|
+
const height = v.videoHeight
|
|
27
|
+
const duration = v.duration
|
|
28
|
+
URL.revokeObjectURL(url)
|
|
29
|
+
resolve({ width, height, duration })
|
|
30
|
+
}
|
|
31
|
+
v.onerror = () => { URL.revokeObjectURL(url); reject(v.error || new Error('failed to load metadata')) }
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ----- Audio helpers -----
|
|
36
|
+
async function decodeAudioPCM (file, { duration }) {
|
|
37
|
+
const totalFrames = Math.max(1, Math.ceil(Number(duration) * TARGET_AUDIO_SR))
|
|
38
|
+
const tracks = await (async () => {
|
|
39
|
+
try {
|
|
40
|
+
const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS })
|
|
41
|
+
return await input.getTracks()
|
|
42
|
+
} catch (_) {
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
})()
|
|
46
|
+
const audio = tracks.find(t => typeof t.isAudioTrack === 'function' && t.isAudioTrack())
|
|
47
|
+
if (!audio) return new AudioBuffer({ length: totalFrames, sampleRate: TARGET_AUDIO_SR, numberOfChannels: TARGET_AUDIO_CHANNELS })
|
|
48
|
+
|
|
49
|
+
const ctx = new OfflineAudioContext({ numberOfChannels: TARGET_AUDIO_CHANNELS, length: totalFrames, sampleRate: TARGET_AUDIO_SR })
|
|
50
|
+
const sink = new AudioBufferSink(audio)
|
|
51
|
+
for await (const { buffer, timestamp } of sink.buffers(0, Number(duration))) {
|
|
52
|
+
const src = ctx.createBufferSource()
|
|
53
|
+
src.buffer = buffer
|
|
54
|
+
src.connect(ctx.destination)
|
|
55
|
+
src.start(Math.max(0, Number(timestamp)))
|
|
56
|
+
}
|
|
57
|
+
return await ctx.startRendering()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function renderStereo48kExact (buffer, exactFrames) {
|
|
61
|
+
const frames = Math.max(1024, Number(exactFrames))
|
|
62
|
+
const ctx = new OfflineAudioContext({ numberOfChannels: TARGET_AUDIO_CHANNELS, length: frames, sampleRate: TARGET_AUDIO_SR })
|
|
63
|
+
const src = ctx.createBufferSource()
|
|
64
|
+
src.buffer = buffer
|
|
65
|
+
src.connect(ctx.destination)
|
|
66
|
+
src.start(0)
|
|
67
|
+
return await ctx.startRendering()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function interleaveStereoF32 (buffer) {
|
|
71
|
+
const len = buffer.length
|
|
72
|
+
const out = new Float32Array(len * TARGET_AUDIO_CHANNELS)
|
|
73
|
+
const ch0 = buffer.getChannelData(0)
|
|
74
|
+
const ch1 = buffer.getChannelData(1)
|
|
75
|
+
for (let i = 0, j = 0; i < len; i++, j += 2) {
|
|
76
|
+
out[j] = ch0[i]
|
|
77
|
+
out[j + 1] = ch1[i]
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ----- Video pipeline -----
|
|
83
|
+
async function canOptimizeVideo (file) {
|
|
84
|
+
if (!(file instanceof File)) return { ok: false, reason: 'not-a-file', message: 'Argument provided is not a File.' }
|
|
85
|
+
const env = typeof window !== 'undefined'
|
|
86
|
+
&& 'VideoEncoder' in window
|
|
87
|
+
&& 'OfflineAudioContext' in window
|
|
88
|
+
&& typeof document?.createElement === 'function'
|
|
89
|
+
if (!env) return { ok: false, reason: 'unsupported-environment', message: 'Browser does not support WebCodecs or OfflineAudioContext.' }
|
|
90
|
+
try {
|
|
91
|
+
const { width, height, duration } = await probeVideo(file)
|
|
92
|
+
const long = Math.max(width, height)
|
|
93
|
+
const scale = Math.min(1, MAX_LONG_SIDE / Math.max(2, long))
|
|
94
|
+
const targetWidth = Math.max(2, Math.round(width * scale))
|
|
95
|
+
const targetHeight = Math.max(2, Math.round(height * scale))
|
|
96
|
+
const fps = Math.max(width, height) <= 1920 ? 30 : 60
|
|
97
|
+
const sup = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps }).then(() => true).catch(() => false)
|
|
98
|
+
if (!sup) return { ok: false, reason: 'unsupported-video-config', message: 'No supported encoder configuration for this resolution on this device.' }
|
|
99
|
+
|
|
100
|
+
// Header sniffing when file.type is empty/incorrect
|
|
101
|
+
const type = String(file.type || '').toLowerCase()
|
|
102
|
+
if (!type) {
|
|
103
|
+
const blob = file.slice(0, 4096)
|
|
104
|
+
const buf = new Uint8Array(await blob.arrayBuffer())
|
|
105
|
+
const asAscii = (u8) => String.fromCharCode(...u8)
|
|
106
|
+
// MP4/MOV ftyp signature typically at offset 4..
|
|
107
|
+
const ascii = asAscii(buf)
|
|
108
|
+
const hasFtyp = ascii.includes('ftyp')
|
|
109
|
+
// WebM/Matroska: EBML header 1A 45 DF A3
|
|
110
|
+
const hasEbml = buf.length >= 4 && buf[0] === 0x1A && buf[1] === 0x45 && buf[2] === 0xDF && buf[3] === 0xA3
|
|
111
|
+
if (!(hasFtyp || hasEbml)) return { ok: false, reason: 'unknown-container', message: 'Unrecognized container; expected MP4/MOV or WebM.' }
|
|
112
|
+
}
|
|
113
|
+
return { ok: true, reason: 'ok', message: 'ok' }
|
|
114
|
+
} catch (e) {
|
|
115
|
+
return { ok: false, reason: 'probe-failed', message: String(e?.message || e) }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function optimizeVideo (file, { onProgress } = {}) {
|
|
120
|
+
if (!(file instanceof File)) return { changed: false, file }
|
|
121
|
+
const type = file.type || ''
|
|
122
|
+
if (!/^video\//i.test(type)) return { changed: false, file }
|
|
123
|
+
if (typeof window === 'undefined' || !('VideoEncoder' in window)) return { changed: false, file }
|
|
124
|
+
const feas = await canOptimizeVideo(file)
|
|
125
|
+
if (!feas.ok) return { changed: false, file }
|
|
126
|
+
|
|
127
|
+
const srcMeta = await probeVideo(file)
|
|
128
|
+
const newFile = await encodeVideo({ file, srcMeta: { w: srcMeta.width, h: srcMeta.height, duration: srcMeta.duration }, onProgress })
|
|
129
|
+
return { changed: true, file: newFile }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function selectVideoEncoderConfig ({ width, height, fps }) {
|
|
133
|
+
const hevc = { codec: 'hvc1.1.4.L123.B0', width, height, framerate: fps, hardwareAcceleration: 'prefer-hardware', hevc: { format: 'hevc' } }
|
|
134
|
+
const supH = await VideoEncoder.isConfigSupported(hevc).catch(() => ({ supported: false }))
|
|
135
|
+
if (supH.supported) return { codecId: 'hevc', config: supH.config }
|
|
136
|
+
|
|
137
|
+
const avc = { codec: 'avc1.64002A', width, height, framerate: fps, hardwareAcceleration: 'prefer-hardware', avc: { format: 'avc' } }
|
|
138
|
+
const supA = await VideoEncoder.isConfigSupported(avc)
|
|
139
|
+
return { codecId: 'avc', config: supA.config }
|
|
140
|
+
}
|
|
141
|
+
|
|
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 encodeVideo ({ file, srcMeta, onProgress }) {
|
|
152
|
+
const w = srcMeta.w
|
|
153
|
+
const h = srcMeta.h
|
|
154
|
+
const durationCfr = Number(srcMeta.duration)
|
|
155
|
+
const long = Math.max(w, h)
|
|
156
|
+
const scale = Math.min(1, MAX_LONG_SIDE / Math.max(2, long))
|
|
157
|
+
const targetWidth = Math.max(2, Math.round(w * scale))
|
|
158
|
+
const targetHeight = Math.max(2, Math.round(h * scale))
|
|
159
|
+
|
|
160
|
+
const targetFps = Math.max(w, h) <= 1920 ? 30 : 60
|
|
161
|
+
const step = 1 / Math.max(1, targetFps)
|
|
162
|
+
const frames = Math.max(1, Math.floor(durationCfr / step))
|
|
163
|
+
|
|
164
|
+
const output = new Output({ format: new Mp4OutputFormat({ fastStart: 'in-memory' }), target: new BufferTarget() })
|
|
165
|
+
const { codecId, config: usedCfg } = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps: targetFps })
|
|
166
|
+
const videoTrack = new EncodedVideoPacketSource(codecId)
|
|
167
|
+
output.addVideoTrack(videoTrack, { frameRate: targetFps })
|
|
168
|
+
|
|
169
|
+
const _warn = console.warn
|
|
170
|
+
console.warn = (...args) => {
|
|
171
|
+
const m = args && args[0]
|
|
172
|
+
if (typeof m === 'string' && m.includes('Unsupported audio codec') && m.includes('apac')) return
|
|
173
|
+
_warn.apply(console, args)
|
|
174
|
+
}
|
|
175
|
+
const audioBuffer = await decodeAudioPCM(file, { duration: durationCfr })
|
|
176
|
+
console.warn = _warn
|
|
177
|
+
|
|
178
|
+
const audioSource = new AudioSampleSource({
|
|
179
|
+
codec: 'aac',
|
|
180
|
+
bitrate: TARGET_AUDIO_BITRATE,
|
|
181
|
+
bitrateMode: 'constant',
|
|
182
|
+
numberOfChannels: TARGET_AUDIO_CHANNELS,
|
|
183
|
+
sampleRate: TARGET_AUDIO_SR,
|
|
184
|
+
onEncodedPacket: (_packet, meta) => {
|
|
185
|
+
const aot = 2; const idx = 3; const b0 = (aot << 3) | (idx >> 1); const b1 = ((idx & 1) << 7) | (TARGET_AUDIO_CHANNELS << 3)
|
|
186
|
+
meta.decoderConfig = { codec: 'mp4a.40.2', numberOfChannels: TARGET_AUDIO_CHANNELS, sampleRate: TARGET_AUDIO_SR, description: new Uint8Array([b0, b1]) }
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
output.addAudioTrack(audioSource)
|
|
190
|
+
|
|
191
|
+
await output.start()
|
|
192
|
+
|
|
193
|
+
let codecDesc = null
|
|
194
|
+
const pendingPackets = []
|
|
195
|
+
const ve = new VideoEncoder({
|
|
196
|
+
output: (chunk, meta) => {
|
|
197
|
+
if (!codecDesc && meta?.decoderConfig?.description) codecDesc = meta.decoderConfig.description
|
|
198
|
+
pendingPackets.push({ chunk })
|
|
199
|
+
},
|
|
200
|
+
error: () => {}
|
|
201
|
+
})
|
|
202
|
+
ve.configure(usedCfg)
|
|
203
|
+
|
|
204
|
+
const url = URL.createObjectURL(file)
|
|
205
|
+
const v = document.createElement('video')
|
|
206
|
+
v.muted = true; v.preload = 'auto'; v.playsInline = true
|
|
207
|
+
v.src = url
|
|
208
|
+
await new Promise((resolve, reject) => { v.onloadedmetadata = resolve; v.onerror = () => reject(new Error('video load failed')) })
|
|
209
|
+
const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight
|
|
210
|
+
const ctx = canvas.getContext('2d', { alpha: false })
|
|
211
|
+
|
|
212
|
+
for (let i = 0; i < frames; i++) {
|
|
213
|
+
const t = i * step
|
|
214
|
+
const targetTime = Math.min(Math.max(0, t), Math.max(0.000001, durationCfr - 0.000001))
|
|
215
|
+
const drawTime = i === 0
|
|
216
|
+
? Math.min(Math.max(0, t + (step * 0.5)), Math.max(0.000001, durationCfr - 0.000001))
|
|
217
|
+
: targetTime
|
|
218
|
+
|
|
219
|
+
await new Promise((resolve) => { v.currentTime = drawTime; v.onseeked = () => resolve() })
|
|
220
|
+
const budgetMs = Math.min(34, Math.max(17, Math.round(step * 1000)))
|
|
221
|
+
const presented = await waitForFrameReady(v, budgetMs)
|
|
222
|
+
if (!presented && i === 0) {
|
|
223
|
+
const nudge = Math.min(step * 0.25, 0.004)
|
|
224
|
+
await new Promise((resolve) => { v.currentTime = Math.min(drawTime + nudge, Math.max(0.000001, durationCfr - 0.000001)); v.onseeked = () => resolve() })
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
ctx.drawImage(v, 0, 0, canvas.width, canvas.height)
|
|
228
|
+
const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
|
|
229
|
+
ve.encode(vf, { keyFrame: i === 0 })
|
|
230
|
+
vf.close()
|
|
231
|
+
|
|
232
|
+
if (typeof onProgress === 'function') {
|
|
233
|
+
try { onProgress(Math.min(1, (i + 1) / frames)) } catch (_) {}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
await ve.flush()
|
|
237
|
+
URL.revokeObjectURL(url)
|
|
238
|
+
|
|
239
|
+
const muxCount = Math.min(frames, pendingPackets.length)
|
|
240
|
+
|
|
241
|
+
for (let i = 0; i < muxCount; i++) {
|
|
242
|
+
const { chunk } = pendingPackets[i]
|
|
243
|
+
const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data)
|
|
244
|
+
const ts = i * step; const dur = step
|
|
245
|
+
const pkt = new EncodedPacket(data, chunk.type === 'key' ? 'key' : 'delta', ts, dur)
|
|
246
|
+
await videoTrack.add(pkt, { decoderConfig: { codec: usedCfg.codec, codedWidth: targetWidth, codedHeight: targetHeight, description: codecDesc } })
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const samplesPerVideoFrame = TARGET_AUDIO_SR / targetFps
|
|
250
|
+
const totalVideoSamples = muxCount * samplesPerVideoFrame
|
|
251
|
+
const targetSamples = Math.max(1024, Math.floor(totalVideoSamples / 1024) * 1024 - 2048)
|
|
252
|
+
const audioExact = await renderStereo48kExact(audioBuffer, targetSamples)
|
|
253
|
+
const interleaved = interleaveStereoF32(audioExact)
|
|
254
|
+
const sample = new AudioSample({ format: 'f32', sampleRate: TARGET_AUDIO_SR, numberOfChannels: TARGET_AUDIO_CHANNELS, timestamp: 0, data: interleaved })
|
|
255
|
+
await audioSource.add(sample)
|
|
256
|
+
audioSource.close()
|
|
257
|
+
|
|
258
|
+
await output.finalize()
|
|
259
|
+
const { buffer } = output.target
|
|
260
|
+
const payload = new Uint8Array(buffer)
|
|
261
|
+
const nm = file.name; const dot = nm.lastIndexOf('.')
|
|
262
|
+
const newName = `${nm.substring(0, dot)}-optimized.mp4`
|
|
263
|
+
return new File([payload], newName, { type: 'video/mp4', lastModified: Date.now() })
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ----- Controller registration (optional) -----
|
|
267
|
+
function registerStraightToVideoController (app, opts = {}) {
|
|
268
|
+
const { Controller, name = 'straight-to-video' } = opts || {}
|
|
269
|
+
if (!Controller) {
|
|
270
|
+
throw new Error('registerStraightToVideoController requires a Controller class from @hotwired/stimulus. Call as registerStraightToVideoController(app, { Controller, name? }).')
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
class StraightToVideoController extends Controller {
|
|
274
|
+
static get targets () { return ['fileInput'] }
|
|
275
|
+
static get values () { return { submitting: Boolean } }
|
|
276
|
+
|
|
277
|
+
connect () {
|
|
278
|
+
this._onWindowSubmitCapture = (e) => this._onWindowSubmitCaptureHandler(e)
|
|
279
|
+
window.addEventListener('submit', this._onWindowSubmitCapture, { capture: true })
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
disconnect () {
|
|
283
|
+
if (this._onWindowSubmitCapture) window.removeEventListener('submit', this._onWindowSubmitCapture, { capture: true })
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async change (e) {
|
|
287
|
+
const fileInput = e.target
|
|
288
|
+
if (!fileInput?.files?.length || this.submittingValue || this._hasFlag(fileInput, 'processing')) return
|
|
289
|
+
this._unmarkFlag(fileInput, 'processed')
|
|
290
|
+
delete fileInput.dataset.summary
|
|
291
|
+
await this._processFileInput(fileInput)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async _onWindowSubmitCaptureHandler (e) {
|
|
295
|
+
if (e.target !== this.element) return
|
|
296
|
+
const toProcess = this.fileInputTargets.filter((fi) => fi?.files?.length && !this._hasFlag(fi, 'processed'))
|
|
297
|
+
if (toProcess.length === 0) return
|
|
298
|
+
|
|
299
|
+
e.preventDefault()
|
|
300
|
+
e.stopPropagation()
|
|
301
|
+
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation()
|
|
302
|
+
|
|
303
|
+
this.submittingValue = true
|
|
304
|
+
await Promise.allSettled(toProcess.map((fi) => this._processFileInput(fi)))
|
|
305
|
+
this.submittingValue = false
|
|
306
|
+
this._resubmit(e.submitter)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
_swapFile (input, newFile) {
|
|
310
|
+
const dt = new DataTransfer()
|
|
311
|
+
dt.items.add(newFile)
|
|
312
|
+
input.files = dt.files
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
_hasFlag (input, flag) { return input.dataset[flag] === '1' }
|
|
316
|
+
_markFlag (input, flag) { input.dataset[flag] = '1' }
|
|
317
|
+
_unmarkFlag (input, flag) { delete input.dataset[flag] }
|
|
318
|
+
|
|
319
|
+
submittingValueChanged () {
|
|
320
|
+
const controls = this.element.querySelectorAll('input, select, textarea, button')
|
|
321
|
+
controls.forEach(el => { el.disabled = this.submittingValue })
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async _processFileInput (fileInput) {
|
|
325
|
+
this._markFlag(fileInput, 'processing')
|
|
326
|
+
fileInput.disabled = true
|
|
327
|
+
try {
|
|
328
|
+
const original = fileInput.files[0]
|
|
329
|
+
const { changed, file } = await optimizeVideo(original, {
|
|
330
|
+
onProgress: (ratio) => this._fire(fileInput, 'progress', { progress: Math.round(ratio * 100) })
|
|
331
|
+
})
|
|
332
|
+
if (changed) this._swapFile(fileInput, file)
|
|
333
|
+
this._markFlag(fileInput, 'processed')
|
|
334
|
+
this._fire(fileInput, 'done', { changed })
|
|
335
|
+
} catch (err) {
|
|
336
|
+
console.error(err)
|
|
337
|
+
this._markFlag(fileInput, 'processed')
|
|
338
|
+
this._fire(fileInput, 'error', { error: err })
|
|
339
|
+
} finally {
|
|
340
|
+
fileInput.disabled = false
|
|
341
|
+
this._unmarkFlag(fileInput, 'processing')
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
_fire (el, name, detail = {}) {
|
|
346
|
+
el.dispatchEvent(new CustomEvent(`straight-to-video:${name}`, { bubbles: true, cancelable: true, detail }))
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
_resubmit (submitter) {
|
|
350
|
+
setTimeout(() => { submitter ? this.element.requestSubmit(submitter) : this.element.requestSubmit() }, 0)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
app.register(name, StraightToVideoController)
|
|
355
|
+
return StraightToVideoController
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Public API
|
|
359
|
+
export { canOptimizeVideo, optimizeVideo, registerStraightToVideoController}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "importmap-rails"
|
|
2
|
+
|
|
3
|
+
module StraightToVideo
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
initializer "straight_to_video.importmap", before: "importmap" do |app|
|
|
6
|
+
next unless app.config.respond_to?(:importmap)
|
|
7
|
+
|
|
8
|
+
app.config.importmap.paths << root.join("config/importmap.rb")
|
|
9
|
+
app.config.importmap.cache_sweepers << root.join("app/assets/javascripts")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Sprockets-only safety: ensure assets are precompiled
|
|
13
|
+
initializer "straight_to_video.assets" do |app|
|
|
14
|
+
next unless defined?(Sprockets::Railtie)
|
|
15
|
+
|
|
16
|
+
app.config.assets.precompile += %w[
|
|
17
|
+
straight_to_video.js
|
|
18
|
+
mediabunny.min.mjs
|
|
19
|
+
]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/package-lock.json
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "straight-to-video",
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"lockfileVersion": 3,
|
|
5
|
+
"requires": true,
|
|
6
|
+
"packages": {
|
|
7
|
+
"": {
|
|
8
|
+
"name": "straight-to-video",
|
|
9
|
+
"version": "0.0.3",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"mediabunny": "^1.24.4"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@playwright/test": "^1.56.1",
|
|
16
|
+
"busboy": "^1.6.0"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@hotwired/stimulus": "^3.2.0"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"node_modules/@hotwired/stimulus": {
|
|
23
|
+
"version": "3.2.2",
|
|
24
|
+
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz",
|
|
25
|
+
"integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"peer": true
|
|
28
|
+
},
|
|
29
|
+
"node_modules/@playwright/test": {
|
|
30
|
+
"version": "1.56.1",
|
|
31
|
+
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
|
|
32
|
+
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
|
|
33
|
+
"dev": true,
|
|
34
|
+
"license": "Apache-2.0",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"playwright": "1.56.1"
|
|
37
|
+
},
|
|
38
|
+
"bin": {
|
|
39
|
+
"playwright": "cli.js"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"node_modules/@types/dom-mediacapture-transform": {
|
|
46
|
+
"version": "0.1.11",
|
|
47
|
+
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz",
|
|
48
|
+
"integrity": "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==",
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@types/dom-webcodecs": "*"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"node_modules/@types/dom-webcodecs": {
|
|
55
|
+
"version": "0.1.13",
|
|
56
|
+
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz",
|
|
57
|
+
"integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==",
|
|
58
|
+
"license": "MIT"
|
|
59
|
+
},
|
|
60
|
+
"node_modules/busboy": {
|
|
61
|
+
"version": "1.6.0",
|
|
62
|
+
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
|
63
|
+
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
|
64
|
+
"dev": true,
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"streamsearch": "^1.1.0"
|
|
67
|
+
},
|
|
68
|
+
"engines": {
|
|
69
|
+
"node": ">=10.16.0"
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"node_modules/fsevents": {
|
|
73
|
+
"version": "2.3.2",
|
|
74
|
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
|
75
|
+
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
76
|
+
"dev": true,
|
|
77
|
+
"hasInstallScript": true,
|
|
78
|
+
"license": "MIT",
|
|
79
|
+
"optional": true,
|
|
80
|
+
"os": [
|
|
81
|
+
"darwin"
|
|
82
|
+
],
|
|
83
|
+
"engines": {
|
|
84
|
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"node_modules/mediabunny": {
|
|
88
|
+
"version": "1.24.4",
|
|
89
|
+
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.24.4.tgz",
|
|
90
|
+
"integrity": "sha512-dpWYBPTtMg152yNLXZQ7xb6hsXdYbKp9EuK8qq4npS+SZ08FVc1XHlXYhrOm31T+tUVJKgm95Yaqy69wTpZP9Q==",
|
|
91
|
+
"license": "MPL-2.0",
|
|
92
|
+
"workspaces": [
|
|
93
|
+
"packages/*"
|
|
94
|
+
],
|
|
95
|
+
"dependencies": {
|
|
96
|
+
"@types/dom-mediacapture-transform": "^0.1.11",
|
|
97
|
+
"@types/dom-webcodecs": "0.1.13"
|
|
98
|
+
},
|
|
99
|
+
"funding": {
|
|
100
|
+
"type": "individual",
|
|
101
|
+
"url": "https://github.com/sponsors/Vanilagy"
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
"node_modules/playwright": {
|
|
105
|
+
"version": "1.56.1",
|
|
106
|
+
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
|
|
107
|
+
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
|
|
108
|
+
"dev": true,
|
|
109
|
+
"license": "Apache-2.0",
|
|
110
|
+
"dependencies": {
|
|
111
|
+
"playwright-core": "1.56.1"
|
|
112
|
+
},
|
|
113
|
+
"bin": {
|
|
114
|
+
"playwright": "cli.js"
|
|
115
|
+
},
|
|
116
|
+
"engines": {
|
|
117
|
+
"node": ">=18"
|
|
118
|
+
},
|
|
119
|
+
"optionalDependencies": {
|
|
120
|
+
"fsevents": "2.3.2"
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
"node_modules/playwright-core": {
|
|
124
|
+
"version": "1.56.1",
|
|
125
|
+
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
|
|
126
|
+
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
|
|
127
|
+
"dev": true,
|
|
128
|
+
"license": "Apache-2.0",
|
|
129
|
+
"bin": {
|
|
130
|
+
"playwright-core": "cli.js"
|
|
131
|
+
},
|
|
132
|
+
"engines": {
|
|
133
|
+
"node": ">=18"
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
"node_modules/streamsearch": {
|
|
137
|
+
"version": "1.1.0",
|
|
138
|
+
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
|
139
|
+
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
|
140
|
+
"dev": true,
|
|
141
|
+
"engines": {
|
|
142
|
+
"node": ">=10.0.0"
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
data/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "straight-to-video",
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "Browser-based, hardware-accelerated video upload optimization",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"peerDependencies": {
|
|
10
|
+
"@hotwired/stimulus": "^3.2.0"
|
|
11
|
+
},
|
|
12
|
+
"peerDependenciesMeta": {
|
|
13
|
+
"@hotwired/stimulus": {
|
|
14
|
+
"optional": true
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"mediabunny": "^1.24.4"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"webcodecs",
|
|
22
|
+
"stimulus",
|
|
23
|
+
"video",
|
|
24
|
+
"upload",
|
|
25
|
+
"instagram"
|
|
26
|
+
],
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/searlsco/straight-to-video.git"
|
|
30
|
+
},
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"files": [
|
|
33
|
+
"index.js",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE.txt",
|
|
36
|
+
"CHANGELOG.md",
|
|
37
|
+
"assets/img/logo.webp"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"dev": "node test/pages/server.js",
|
|
41
|
+
"test": "script/test"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@playwright/test": "^1.56.1",
|
|
45
|
+
"busboy": "^1.6.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { defineConfig, devices } from '@playwright/test'
|
|
2
|
+
|
|
3
|
+
// Default to headed (WebCodecs availability). Set HEADLESS=1 to run headless.
|
|
4
|
+
const headless = process.env.HEADLESS === '1'
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
testDir: 'test/playwright',
|
|
8
|
+
testMatch: '**/*-test.mjs',
|
|
9
|
+
reporter: 'list',
|
|
10
|
+
timeout: 30_000,
|
|
11
|
+
expect: { timeout: 5_000 },
|
|
12
|
+
use: {
|
|
13
|
+
headless,
|
|
14
|
+
baseURL: 'http://localhost:8080'
|
|
15
|
+
},
|
|
16
|
+
webServer: {
|
|
17
|
+
command: 'node test/pages/server.js',
|
|
18
|
+
url: 'http://localhost:8080',
|
|
19
|
+
reuseExistingServer: true,
|
|
20
|
+
stdout: 'pipe',
|
|
21
|
+
stderr: 'pipe'
|
|
22
|
+
},
|
|
23
|
+
projects: [
|
|
24
|
+
{
|
|
25
|
+
name: 'webkit',
|
|
26
|
+
use: { ...devices['Desktop Safari'], headless }
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'chrome',
|
|
30
|
+
use: { ...devices['Desktop Chrome'], channel: 'chrome', headless }
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
})
|