straight_to_video 0.0.11 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4783067cfef03451a12e35404133f518b5e73fedd306c99e47d02791022300b1
4
- data.tar.gz: 8cabcf1177d87439fd41ac63be9dc88fddecca8123f7efc70c5a849061096340
3
+ metadata.gz: c562c9b7243f141e7524a58e115bd257d5207bd7272bf01547812e9845235e75
4
+ data.tar.gz: bbe4b5f3e82e6d84fb928c9165d5c8935416ed6c04034c55db44c740db949785
5
5
  SHA512:
6
- metadata.gz: 26e1e12b9fb1cfcfcece442b49fcde59b5e19b429f70914286b59bc9efa8423761f98fccd0992807bdecbf5b50f97584e6e83ccaefd88a761d5cef2c52e8e4a8
7
- data.tar.gz: 1980a0f482a87965ef98d3287fc2ccdfe51edd3d9127d3f0903c5d0a0336bc9b19d81785a012fb8dfddcf10b444d462af666f28d02c8e11eacafb8b4484da1d9
6
+ metadata.gz: 368159b6977e3d37b116a55a35cca1d849029657ae86b01aa8113f9385288dab86f140d98a5bbb4d9639464e7e2ef8c362e204b52c45e5e2c36927e9aaa4db93
7
+ data.tar.gz: 8acd9f7af6b1293e7b2a6bd4626e6dc0897a09811684bcf8a7e27260dd48071306c1aba8b61c6b8d9a76f1aac0f5552eb8edd43f0e7e9cc9259e800531a3803d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.12
4
+
5
+ * Bound the WebCodecs encoder queue so long videos cannot exhaust WebKit memory.
6
+ * Keep browser encoding at or below the source video's bitrate.
7
+
3
8
  ## 0.0.11
4
9
 
5
10
  * Work around WebKit labeling the requested first HEVC keyframe as a delta packet.
@@ -1,4 +1,4 @@
1
- // straight-to-video@0.0.11 vendored by the straight_to_video gem
1
+ // straight-to-video@0.0.12 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 -----
@@ -14,6 +14,7 @@ const TARGET_VIDEO_BITRATE = 12_000_000
14
14
  const TARGET_AUDIO_BITRATE = 96_000
15
15
  const TARGET_AUDIO_SR = 48_000
16
16
  const TARGET_AUDIO_CHANNELS = 2
17
+ const MAX_VIDEO_ENCODER_QUEUE_SIZE = 4
17
18
 
18
19
  // ----- Video metadata probe -----
19
20
  async function probeVideo (file) {
@@ -34,34 +35,47 @@ async function probeVideo (file) {
34
35
  })
35
36
  }
36
37
 
37
- async function estimateSourceVideoFps (file) {
38
+ async function estimateSourceVideoStats (file) {
38
39
  try {
39
40
  const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS })
40
41
  const tracks = await input.getTracks()
41
42
  const video = tracks.find(t => typeof t.isVideoTrack === 'function' && t.isVideoTrack())
42
- if (!video) return 0
43
+ if (!video) return { fps: 0, bitrate: 0 }
43
44
  const sink = new EncodedPacketSink(video)
44
45
  const durations = []
46
+ let firstTimestamp = Infinity
47
+ let lastTimestamp = -Infinity
48
+ let totalBytes = 0
45
49
  for await (const packet of sink.packets(undefined, undefined, { metadataOnly: true })) {
46
- const dur = Number(packet?.duration)
47
- if (packet.timestamp >= 0 && Number.isFinite(dur) && dur > 0) durations.push(dur)
50
+ const duration = Number(packet?.duration)
51
+ if (packet.timestamp >= 0 && Number.isFinite(duration) && duration > 0) {
52
+ durations.push(duration)
53
+ firstTimestamp = Math.min(firstTimestamp, packet.timestamp)
54
+ lastTimestamp = Math.max(lastTimestamp, packet.timestamp + duration)
55
+ totalBytes += packet.byteLength
56
+ }
48
57
  if (durations.length >= 120) break
49
58
  }
50
- if (!durations.length) return 0
59
+ if (!durations.length) return { fps: 0, bitrate: 0 }
51
60
  durations.sort((a, b) => a - b)
52
- const dur = durations[Math.floor(durations.length / 2)]
53
- return Number.isFinite(dur) && dur > 0 ? (1 / dur) : 0
61
+ const duration = durations[Math.floor(durations.length / 2)]
62
+ const sampledDuration = lastTimestamp - firstTimestamp
63
+ return {
64
+ fps: Number.isFinite(duration) && duration > 0 ? (1 / duration) : 0,
65
+ bitrate: sampledDuration > 0 ? (totalBytes * 8 / sampledDuration) : 0
66
+ }
54
67
  } catch (_) {
55
- return 0
68
+ return { fps: 0, bitrate: 0 }
56
69
  }
57
70
  }
58
71
 
59
- async function determineTargetFps (file, { width, height }) {
72
+ async function determineEncodingPlan (file, { width, height }) {
60
73
  const maxFps = Math.max(width, height) <= 1920 ? 30 : 60
61
- if (maxFps === 30) return 30
62
-
63
- const fps = await estimateSourceVideoFps(file)
64
- return fps >= 45 ? 60 : 30
74
+ const source = await estimateSourceVideoStats(file)
75
+ return {
76
+ fps: maxFps === 30 ? 30 : (source.fps >= 45 ? 60 : 30),
77
+ bitrate: source.bitrate > 0 ? Math.min(TARGET_VIDEO_BITRATE, Math.round(source.bitrate)) : TARGET_VIDEO_BITRATE
78
+ }
65
79
  }
66
80
 
67
81
  // ----- Audio helpers -----
@@ -125,8 +139,8 @@ async function canOptimizeVideo (file) {
125
139
  const scale = Math.min(1, MAX_LONG_SIDE / Math.max(2, long))
126
140
  const targetWidth = Math.max(2, Math.round(width * scale))
127
141
  const targetHeight = Math.max(2, Math.round(height * scale))
128
- const fps = await determineTargetFps(file, { width, height })
129
- const sup = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps }).then(() => true).catch(() => false)
142
+ const plan = await determineEncodingPlan(file, { width, height })
143
+ const sup = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, ...plan }).then(() => true).catch(() => false)
130
144
  if (!sup) return { ok: false, reason: 'unsupported-video-config', message: 'No supported encoder configuration for this resolution on this device.' }
131
145
 
132
146
  // Header sniffing when file.type is empty/incorrect
@@ -142,7 +156,7 @@ async function canOptimizeVideo (file) {
142
156
  const hasEbml = buf.length >= 4 && buf[0] === 0x1A && buf[1] === 0x45 && buf[2] === 0xDF && buf[3] === 0xA3
143
157
  if (!(hasFtyp || hasEbml)) return { ok: false, reason: 'unknown-container', message: 'Unrecognized container; expected MP4/MOV or WebM.' }
144
158
  }
145
- return { ok: true, reason: 'ok', message: 'ok', plan: { width: targetWidth, height: targetHeight, fps } }
159
+ return { ok: true, reason: 'ok', message: 'ok', plan: { width: targetWidth, height: targetHeight, ...plan } }
146
160
  } catch (e) {
147
161
  return { ok: false, reason: 'probe-failed', message: String(e?.message || e) }
148
162
  }
@@ -161,12 +175,12 @@ async function optimizeVideo (file, { onProgress } = {}) {
161
175
  return { changed: true, file: newFile }
162
176
  }
163
177
 
164
- async function selectVideoEncoderConfig ({ width, height, fps }) {
165
- const hevc = { codec: 'hvc1.1.4.L123.B0', width, height, framerate: fps, bitrate: TARGET_VIDEO_BITRATE, hardwareAcceleration: 'prefer-hardware', hevc: { format: 'hevc' } }
178
+ async function selectVideoEncoderConfig ({ width, height, fps, bitrate = TARGET_VIDEO_BITRATE }) {
179
+ const hevc = { codec: 'hvc1.1.4.L123.B0', width, height, framerate: fps, bitrate, hardwareAcceleration: 'prefer-hardware', hevc: { format: 'hevc' } }
166
180
  const supH = await VideoEncoder.isConfigSupported(hevc).catch(() => ({ supported: false }))
167
181
  if (supH.supported) return { codecId: 'hevc', config: supH.config }
168
182
 
169
- const avc = { codec: 'avc1.64002A', width, height, framerate: fps, bitrate: TARGET_VIDEO_BITRATE, hardwareAcceleration: 'prefer-hardware', avc: { format: 'avc' } }
183
+ const avc = { codec: 'avc1.64002A', width, height, framerate: fps, bitrate, hardwareAcceleration: 'prefer-hardware', avc: { format: 'avc' } }
170
184
  const supA = await VideoEncoder.isConfigSupported(avc)
171
185
  return { codecId: 'avc', config: supA.config }
172
186
  }
@@ -175,6 +189,12 @@ function shouldDecodeViaVideoElement () {
175
189
  return (navigator?.vendor || '').includes('Apple')
176
190
  }
177
191
 
192
+ async function applyVideoEncoderBackpressure (encoder) {
193
+ while (encoder.encodeQueueSize > MAX_VIDEO_ENCODER_QUEUE_SIZE) {
194
+ await new Promise(resolve => setTimeout(resolve, 0))
195
+ }
196
+ }
197
+
178
198
  async function waitForFrameReady (video, budgetMs) {
179
199
  if (typeof video.requestVideoFrameCallback !== 'function') return false
180
200
  return await new Promise((resolve) => {
@@ -239,6 +259,7 @@ async function encodeFramesViaVideoElement ({ file, durationCfr, step, frames, c
239
259
  const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
240
260
  ve.encode(vf, { keyFrame: i === 0 })
241
261
  vf.close()
262
+ await applyVideoEncoderBackpressure(ve)
242
263
 
243
264
  if (typeof onProgress === 'function') {
244
265
  try {
@@ -285,6 +306,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
285
306
  const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
286
307
  ve.encode(vf, { keyFrame: i === 0 })
287
308
  vf.close()
309
+ await applyVideoEncoderBackpressure(ve)
288
310
 
289
311
  if (typeof onProgress === 'function') {
290
312
  try {
@@ -311,6 +333,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
311
333
  const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
312
334
  ve.encode(vf, { keyFrame: i === 0 })
313
335
  vf.close()
336
+ await applyVideoEncoderBackpressure(ve)
314
337
 
315
338
  if (typeof onProgress === 'function') {
316
339
  try {
@@ -628,12 +651,13 @@ async function encodeVideo ({ file, srcMeta, plan, onProgress }) {
628
651
  const targetWidth = Math.max(2, Number(plan?.width) || Math.round(w * scale))
629
652
  const targetHeight = Math.max(2, Number(plan?.height) || Math.round(h * scale))
630
653
 
631
- const targetFps = Math.max(1, Number(plan?.fps) || await determineTargetFps(file, { width: w, height: h }))
654
+ const fallbackPlan = plan || await determineEncodingPlan(file, { width: w, height: h })
655
+ const targetFps = Math.max(1, Number(fallbackPlan.fps))
632
656
  const step = 1 / Math.max(1, targetFps)
633
657
  const frames = Math.max(1, Math.floor(durationCfr / step))
634
658
 
635
659
  const output = new Output({ format: new Mp4OutputFormat({ fastStart: 'in-memory' }), target: new BufferTarget() })
636
- const { codecId, config: usedCfg } = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps: targetFps })
660
+ const { codecId, config: usedCfg } = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps: targetFps, bitrate: fallbackPlan.bitrate })
637
661
  const videoTrack = new EncodedVideoPacketSource(codecId)
638
662
  output.addVideoTrack(videoTrack, { frameRate: targetFps })
639
663
 
data/index.js CHANGED
@@ -13,6 +13,7 @@ const TARGET_VIDEO_BITRATE = 12_000_000
13
13
  const TARGET_AUDIO_BITRATE = 96_000
14
14
  const TARGET_AUDIO_SR = 48_000
15
15
  const TARGET_AUDIO_CHANNELS = 2
16
+ const MAX_VIDEO_ENCODER_QUEUE_SIZE = 4
16
17
 
17
18
  // ----- Video metadata probe -----
18
19
  async function probeVideo (file) {
@@ -33,34 +34,47 @@ async function probeVideo (file) {
33
34
  })
34
35
  }
35
36
 
36
- async function estimateSourceVideoFps (file) {
37
+ async function estimateSourceVideoStats (file) {
37
38
  try {
38
39
  const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS })
39
40
  const tracks = await input.getTracks()
40
41
  const video = tracks.find(t => typeof t.isVideoTrack === 'function' && t.isVideoTrack())
41
- if (!video) return 0
42
+ if (!video) return { fps: 0, bitrate: 0 }
42
43
  const sink = new EncodedPacketSink(video)
43
44
  const durations = []
45
+ let firstTimestamp = Infinity
46
+ let lastTimestamp = -Infinity
47
+ let totalBytes = 0
44
48
  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)
49
+ const duration = Number(packet?.duration)
50
+ if (packet.timestamp >= 0 && Number.isFinite(duration) && duration > 0) {
51
+ durations.push(duration)
52
+ firstTimestamp = Math.min(firstTimestamp, packet.timestamp)
53
+ lastTimestamp = Math.max(lastTimestamp, packet.timestamp + duration)
54
+ totalBytes += packet.byteLength
55
+ }
47
56
  if (durations.length >= 120) break
48
57
  }
49
- if (!durations.length) return 0
58
+ if (!durations.length) return { fps: 0, bitrate: 0 }
50
59
  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
60
+ const duration = durations[Math.floor(durations.length / 2)]
61
+ const sampledDuration = lastTimestamp - firstTimestamp
62
+ return {
63
+ fps: Number.isFinite(duration) && duration > 0 ? (1 / duration) : 0,
64
+ bitrate: sampledDuration > 0 ? (totalBytes * 8 / sampledDuration) : 0
65
+ }
53
66
  } catch (_) {
54
- return 0
67
+ return { fps: 0, bitrate: 0 }
55
68
  }
56
69
  }
57
70
 
58
- async function determineTargetFps (file, { width, height }) {
71
+ async function determineEncodingPlan (file, { width, height }) {
59
72
  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
73
+ const source = await estimateSourceVideoStats(file)
74
+ return {
75
+ fps: maxFps === 30 ? 30 : (source.fps >= 45 ? 60 : 30),
76
+ bitrate: source.bitrate > 0 ? Math.min(TARGET_VIDEO_BITRATE, Math.round(source.bitrate)) : TARGET_VIDEO_BITRATE
77
+ }
64
78
  }
65
79
 
66
80
  // ----- Audio helpers -----
@@ -124,8 +138,8 @@ async function canOptimizeVideo (file) {
124
138
  const scale = Math.min(1, MAX_LONG_SIDE / Math.max(2, long))
125
139
  const targetWidth = Math.max(2, Math.round(width * scale))
126
140
  const targetHeight = Math.max(2, Math.round(height * scale))
127
- const fps = await determineTargetFps(file, { width, height })
128
- const sup = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps }).then(() => true).catch(() => false)
141
+ const plan = await determineEncodingPlan(file, { width, height })
142
+ const sup = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, ...plan }).then(() => true).catch(() => false)
129
143
  if (!sup) return { ok: false, reason: 'unsupported-video-config', message: 'No supported encoder configuration for this resolution on this device.' }
130
144
 
131
145
  // Header sniffing when file.type is empty/incorrect
@@ -141,7 +155,7 @@ async function canOptimizeVideo (file) {
141
155
  const hasEbml = buf.length >= 4 && buf[0] === 0x1A && buf[1] === 0x45 && buf[2] === 0xDF && buf[3] === 0xA3
142
156
  if (!(hasFtyp || hasEbml)) return { ok: false, reason: 'unknown-container', message: 'Unrecognized container; expected MP4/MOV or WebM.' }
143
157
  }
144
- return { ok: true, reason: 'ok', message: 'ok', plan: { width: targetWidth, height: targetHeight, fps } }
158
+ return { ok: true, reason: 'ok', message: 'ok', plan: { width: targetWidth, height: targetHeight, ...plan } }
145
159
  } catch (e) {
146
160
  return { ok: false, reason: 'probe-failed', message: String(e?.message || e) }
147
161
  }
@@ -160,12 +174,12 @@ async function optimizeVideo (file, { onProgress } = {}) {
160
174
  return { changed: true, file: newFile }
161
175
  }
162
176
 
163
- async function selectVideoEncoderConfig ({ width, height, fps }) {
164
- const hevc = { codec: 'hvc1.1.4.L123.B0', width, height, framerate: fps, bitrate: TARGET_VIDEO_BITRATE, hardwareAcceleration: 'prefer-hardware', hevc: { format: 'hevc' } }
177
+ async function selectVideoEncoderConfig ({ width, height, fps, bitrate = TARGET_VIDEO_BITRATE }) {
178
+ const hevc = { codec: 'hvc1.1.4.L123.B0', width, height, framerate: fps, bitrate, hardwareAcceleration: 'prefer-hardware', hevc: { format: 'hevc' } }
165
179
  const supH = await VideoEncoder.isConfigSupported(hevc).catch(() => ({ supported: false }))
166
180
  if (supH.supported) return { codecId: 'hevc', config: supH.config }
167
181
 
168
- const avc = { codec: 'avc1.64002A', width, height, framerate: fps, bitrate: TARGET_VIDEO_BITRATE, hardwareAcceleration: 'prefer-hardware', avc: { format: 'avc' } }
182
+ const avc = { codec: 'avc1.64002A', width, height, framerate: fps, bitrate, hardwareAcceleration: 'prefer-hardware', avc: { format: 'avc' } }
169
183
  const supA = await VideoEncoder.isConfigSupported(avc)
170
184
  return { codecId: 'avc', config: supA.config }
171
185
  }
@@ -174,6 +188,12 @@ function shouldDecodeViaVideoElement () {
174
188
  return (navigator?.vendor || '').includes('Apple')
175
189
  }
176
190
 
191
+ async function applyVideoEncoderBackpressure (encoder) {
192
+ while (encoder.encodeQueueSize > MAX_VIDEO_ENCODER_QUEUE_SIZE) {
193
+ await new Promise(resolve => setTimeout(resolve, 0))
194
+ }
195
+ }
196
+
177
197
  async function waitForFrameReady (video, budgetMs) {
178
198
  if (typeof video.requestVideoFrameCallback !== 'function') return false
179
199
  return await new Promise((resolve) => {
@@ -238,6 +258,7 @@ async function encodeFramesViaVideoElement ({ file, durationCfr, step, frames, c
238
258
  const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
239
259
  ve.encode(vf, { keyFrame: i === 0 })
240
260
  vf.close()
261
+ await applyVideoEncoderBackpressure(ve)
241
262
 
242
263
  if (typeof onProgress === 'function') {
243
264
  try {
@@ -284,6 +305,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
284
305
  const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
285
306
  ve.encode(vf, { keyFrame: i === 0 })
286
307
  vf.close()
308
+ await applyVideoEncoderBackpressure(ve)
287
309
 
288
310
  if (typeof onProgress === 'function') {
289
311
  try {
@@ -310,6 +332,7 @@ async function encodeFramesViaVideoSampleSink ({ file, durationCfr, step, frames
310
332
  const vf = new VideoFrame(canvas, { timestamp: Math.round(t * 1e6), duration: Math.round(step * 1e6) })
311
333
  ve.encode(vf, { keyFrame: i === 0 })
312
334
  vf.close()
335
+ await applyVideoEncoderBackpressure(ve)
313
336
 
314
337
  if (typeof onProgress === 'function') {
315
338
  try {
@@ -627,12 +650,13 @@ async function encodeVideo ({ file, srcMeta, plan, onProgress }) {
627
650
  const targetWidth = Math.max(2, Number(plan?.width) || Math.round(w * scale))
628
651
  const targetHeight = Math.max(2, Number(plan?.height) || Math.round(h * scale))
629
652
 
630
- const targetFps = Math.max(1, Number(plan?.fps) || await determineTargetFps(file, { width: w, height: h }))
653
+ const fallbackPlan = plan || await determineEncodingPlan(file, { width: w, height: h })
654
+ const targetFps = Math.max(1, Number(fallbackPlan.fps))
631
655
  const step = 1 / Math.max(1, targetFps)
632
656
  const frames = Math.max(1, Math.floor(durationCfr / step))
633
657
 
634
658
  const output = new Output({ format: new Mp4OutputFormat({ fastStart: 'in-memory' }), target: new BufferTarget() })
635
- const { codecId, config: usedCfg } = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps: targetFps })
659
+ const { codecId, config: usedCfg } = await selectVideoEncoderConfig({ width: targetWidth, height: targetHeight, fps: targetFps, bitrate: fallbackPlan.bitrate })
636
660
  const videoTrack = new EncodedVideoPacketSource(codecId)
637
661
  output.addVideoTrack(videoTrack, { frameRate: targetFps })
638
662
 
@@ -1,3 +1,3 @@
1
1
  module StraightToVideo
2
- VERSION = "0.0.11"
2
+ VERSION = "0.0.12"
3
3
  end
data/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "straight-to-video",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "straight-to-video",
9
- "version": "0.0.11",
9
+ "version": "0.0.12",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "mediabunny": "^1.27.3"
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "straight-to-video",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "description": "Browser-based, hardware-accelerated video upload optimization",
5
5
  "type": "module",
6
6
  "exports": {
@@ -26,7 +26,7 @@
26
26
  ],
27
27
  "repository": {
28
28
  "type": "git",
29
- "url": "https://github.com/searlsco/straight-to-video.git"
29
+ "url": "git+https://github.com/searlsco/straight-to-video.git"
30
30
  },
31
31
  "license": "MIT",
32
32
  "files": [
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: straight_to_video
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.11
4
+ version: 0.0.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Searls