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.
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
@@ -0,0 +1,3 @@
1
+ module StraightToVideo
2
+ VERSION = "0.0.3"
3
+ end
@@ -0,0 +1,2 @@
1
+ require_relative "straight_to_video/version"
2
+ require_relative "straight_to_video/engine" if defined?(Rails)
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
+ })