vizcore 0.1.0

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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +170 -0
  4. data/docs/GETTING_STARTED.md +105 -0
  5. data/examples/assets/complex_demo_loop.wav +0 -0
  6. data/examples/basic.rb +9 -0
  7. data/examples/complex_audio_showcase.rb +261 -0
  8. data/examples/custom_shader.rb +21 -0
  9. data/examples/file_audio_demo.rb +74 -0
  10. data/examples/intro_drop.rb +38 -0
  11. data/examples/midi_scene_switch.rb +32 -0
  12. data/examples/shaders/custom_wave.frag +30 -0
  13. data/exe/vizcore +6 -0
  14. data/frontend/index.html +148 -0
  15. data/frontend/src/main.js +304 -0
  16. data/frontend/src/renderer/engine.js +135 -0
  17. data/frontend/src/renderer/layer-manager.js +456 -0
  18. data/frontend/src/renderer/shader-manager.js +69 -0
  19. data/frontend/src/shaders/builtins.js +244 -0
  20. data/frontend/src/shaders/post-effects.js +85 -0
  21. data/frontend/src/visuals/geometry.js +66 -0
  22. data/frontend/src/visuals/particle-system.js +148 -0
  23. data/frontend/src/visuals/text-renderer.js +143 -0
  24. data/frontend/src/visuals/vj-effects.js +56 -0
  25. data/frontend/src/websocket-client.js +131 -0
  26. data/lib/vizcore/analysis/band_splitter.rb +63 -0
  27. data/lib/vizcore/analysis/beat_detector.rb +70 -0
  28. data/lib/vizcore/analysis/bpm_estimator.rb +86 -0
  29. data/lib/vizcore/analysis/fft_processor.rb +224 -0
  30. data/lib/vizcore/analysis/fftw_ffi.rb +50 -0
  31. data/lib/vizcore/analysis/pipeline.rb +72 -0
  32. data/lib/vizcore/analysis/smoother.rb +74 -0
  33. data/lib/vizcore/analysis.rb +14 -0
  34. data/lib/vizcore/audio/base_input.rb +39 -0
  35. data/lib/vizcore/audio/dummy_sine_input.rb +40 -0
  36. data/lib/vizcore/audio/file_input.rb +163 -0
  37. data/lib/vizcore/audio/input_manager.rb +133 -0
  38. data/lib/vizcore/audio/mic_input.rb +121 -0
  39. data/lib/vizcore/audio/midi_input.rb +246 -0
  40. data/lib/vizcore/audio/portaudio_ffi.rb +243 -0
  41. data/lib/vizcore/audio/ring_buffer.rb +92 -0
  42. data/lib/vizcore/audio.rb +16 -0
  43. data/lib/vizcore/cli.rb +115 -0
  44. data/lib/vizcore/config.rb +46 -0
  45. data/lib/vizcore/dsl/engine.rb +229 -0
  46. data/lib/vizcore/dsl/file_watcher.rb +108 -0
  47. data/lib/vizcore/dsl/layer_builder.rb +182 -0
  48. data/lib/vizcore/dsl/mapping_resolver.rb +81 -0
  49. data/lib/vizcore/dsl/midi_map_executor.rb +188 -0
  50. data/lib/vizcore/dsl/scene_builder.rb +44 -0
  51. data/lib/vizcore/dsl/shader_source_resolver.rb +71 -0
  52. data/lib/vizcore/dsl/transition_controller.rb +166 -0
  53. data/lib/vizcore/dsl.rb +16 -0
  54. data/lib/vizcore/errors.rb +27 -0
  55. data/lib/vizcore/renderer/frame_scheduler.rb +75 -0
  56. data/lib/vizcore/renderer/scene_serializer.rb +73 -0
  57. data/lib/vizcore/renderer.rb +10 -0
  58. data/lib/vizcore/server/frame_broadcaster.rb +351 -0
  59. data/lib/vizcore/server/rack_app.rb +183 -0
  60. data/lib/vizcore/server/runner.rb +357 -0
  61. data/lib/vizcore/server/websocket_handler.rb +163 -0
  62. data/lib/vizcore/server.rb +12 -0
  63. data/lib/vizcore/templates/basic_scene.rb +10 -0
  64. data/lib/vizcore/templates/custom_shader_scene.rb +22 -0
  65. data/lib/vizcore/templates/custom_wave.frag +31 -0
  66. data/lib/vizcore/templates/intro_drop_scene.rb +40 -0
  67. data/lib/vizcore/templates/midi_control_scene.rb +33 -0
  68. data/lib/vizcore/templates/project_readme.md +35 -0
  69. data/lib/vizcore/version.rb +6 -0
  70. data/lib/vizcore.rb +37 -0
  71. metadata +186 -0
@@ -0,0 +1,351 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../audio"
4
+ require_relative "../analysis"
5
+ require_relative "../dsl"
6
+ require_relative "../errors"
7
+ require_relative "../renderer"
8
+
9
+ module Vizcore
10
+ module Server
11
+ # Produces audio-reactive frame payloads and broadcasts them over WebSocket.
12
+ class FrameBroadcaster
13
+ # Target broadcast frame rate.
14
+ FRAME_RATE = 60.0
15
+
16
+ # @param scene_name [String]
17
+ # @param scene_layers [Array<Hash>, nil]
18
+ # @param input_manager [Vizcore::Audio::InputManager, nil]
19
+ # @param analysis_pipeline [Vizcore::Analysis::Pipeline, nil]
20
+ # @param mapping_resolver [Vizcore::DSL::MappingResolver, nil]
21
+ # @param scene_serializer [Vizcore::Renderer::SceneSerializer, nil]
22
+ # @param frame_scheduler [Vizcore::Renderer::FrameScheduler, nil]
23
+ # @param scene_catalog [Array<Hash>, nil]
24
+ # @param transitions [Array<Hash>, nil]
25
+ # @param transition_controller [Vizcore::DSL::TransitionController, nil]
26
+ # @param error_reporter [#call, nil]
27
+ def initialize(
28
+ scene_name: "basic",
29
+ scene_layers: nil,
30
+ input_manager: nil,
31
+ analysis_pipeline: nil,
32
+ mapping_resolver: nil,
33
+ scene_serializer: nil,
34
+ frame_scheduler: nil,
35
+ scene_catalog: nil,
36
+ transitions: nil,
37
+ transition_controller: nil,
38
+ error_reporter: nil
39
+ )
40
+ @scene_name = scene_name
41
+ @scene_layers = Array(scene_layers)
42
+ @scene_mutex = Mutex.new
43
+ @input_manager = input_manager || Vizcore::Audio::InputManager.new(source: :mic)
44
+ fft_size = supported_fft_size(@input_manager.frame_size)
45
+ @analysis_pipeline = analysis_pipeline || Vizcore::Analysis::Pipeline.new(
46
+ sample_rate: @input_manager.sample_rate,
47
+ fft_size: fft_size
48
+ )
49
+ @mapping_resolver = mapping_resolver || Vizcore::DSL::MappingResolver.new
50
+ @scene_serializer = scene_serializer || Vizcore::Renderer::SceneSerializer.new
51
+ @transition_controller = transition_controller || Vizcore::DSL::TransitionController.new(
52
+ scenes: scene_catalog || [],
53
+ transitions: transitions || []
54
+ )
55
+ @error_reporter = error_reporter || ->(_message) {}
56
+ @last_error = nil
57
+ @frame_count = 0
58
+ @transport_playing = initial_transport_playing_state
59
+ reset_transition_trigger_counters!
60
+ @frame_scheduler = frame_scheduler || Vizcore::Renderer::FrameScheduler.new(frame_rate: FRAME_RATE) do |elapsed|
61
+ tick(elapsed)
62
+ end
63
+ end
64
+
65
+ attr_reader :last_error
66
+
67
+ # @return [void]
68
+ def start
69
+ return if running?
70
+
71
+ @input_manager.start
72
+ @frame_scheduler.start
73
+ rescue StandardError => e
74
+ report_error(e, context: "frame broadcaster start failed")
75
+ @input_manager.stop
76
+ raise
77
+ end
78
+
79
+ # @return [void]
80
+ def stop
81
+ return unless running?
82
+
83
+ @frame_scheduler.stop
84
+ @input_manager.stop
85
+ end
86
+
87
+ # @return [Boolean]
88
+ def running?
89
+ @frame_scheduler.running?
90
+ end
91
+
92
+ # @return [Hash] current scene snapshot (`name`, `layers`)
93
+ def current_scene_snapshot
94
+ current_scene
95
+ end
96
+
97
+ # Synchronize external playback transport (e.g. browser audio element) with the input source.
98
+ #
99
+ # @param playing [Boolean]
100
+ # @param position_seconds [Numeric]
101
+ # @return [void]
102
+ def sync_transport(playing:, position_seconds:)
103
+ @scene_mutex.synchronize do
104
+ @transport_playing = !!playing
105
+ reset_transition_trigger_counters! if transport_position_reset?(position_seconds)
106
+ end
107
+ return unless @input_manager.respond_to?(:sync_transport)
108
+
109
+ @input_manager.sync_transport(playing: playing, position_seconds: position_seconds)
110
+ rescue StandardError => e
111
+ report_error(e, context: "audio transport sync failed")
112
+ end
113
+
114
+ # Run one frame tick and broadcast it.
115
+ #
116
+ # @param elapsed_seconds [Float]
117
+ # @param samples [Array<Float>, nil]
118
+ # @return [Hash] serialized frame
119
+ def tick(elapsed_seconds, samples = nil)
120
+ @frame_count += 1
121
+ frame = build_frame(elapsed_seconds, samples)
122
+ WebSocketHandler.broadcast(type: "audio_frame", payload: frame)
123
+ evaluate_transition(frame[:audio], frame_count: @frame_count)
124
+ frame
125
+ end
126
+
127
+ # Replace active scene and layers.
128
+ #
129
+ # @param scene_name [String, Symbol]
130
+ # @param scene_layers [Array<Hash>]
131
+ # @return [void]
132
+ def update_scene(scene_name:, scene_layers:)
133
+ @scene_mutex.synchronize do
134
+ @scene_name = scene_name.to_s
135
+ @scene_layers = Array(scene_layers)
136
+ reset_transition_trigger_counters!
137
+ end
138
+ end
139
+
140
+ # Replace transition catalog used by automatic scene switching.
141
+ #
142
+ # @param scenes [Array<Hash>]
143
+ # @param transitions [Array<Hash>]
144
+ # @return [void]
145
+ def update_transition_definition(scenes:, transitions:)
146
+ @scene_mutex.synchronize do
147
+ @transition_controller.update(scenes: scenes, transitions: transitions)
148
+ end
149
+ end
150
+
151
+ # Build one frame payload for transport to frontend.
152
+ #
153
+ # @param _elapsed_seconds [Float]
154
+ # @param samples [Array<Float>, nil]
155
+ # @raise [Vizcore::FrameBuildError] when frame construction fails
156
+ # @return [Hash]
157
+ def build_frame(_elapsed_seconds, samples = nil)
158
+ audio_samples = samples || capture_samples
159
+ analyzed = @analysis_pipeline.call(audio_samples)
160
+ scene = current_scene
161
+ layers = build_scene_layers(scene[:layers], analyzed)
162
+
163
+ @scene_serializer.audio_frame(
164
+ timestamp: Time.now.to_f,
165
+ audio: analyzed,
166
+ scene_name: scene[:name],
167
+ scene_layers: layers,
168
+ transition: nil
169
+ )
170
+ rescue StandardError => e
171
+ report_error(e, context: "frame build failed")
172
+ raise Vizcore::FrameBuildError, Vizcore::ErrorFormatting.summarize(e, context: "Frame build failed")
173
+ end
174
+
175
+ private
176
+
177
+ def capture_samples
178
+ ingest_count =
179
+ if @input_manager.respond_to?(:realtime_capture_size)
180
+ @input_manager.realtime_capture_size(FRAME_RATE)
181
+ else
182
+ @input_manager.frame_size
183
+ end
184
+
185
+ @input_manager.capture_frame(ingest_count)
186
+ samples = Array(@input_manager.latest_samples(@input_manager.frame_size))
187
+ return samples if samples.length == @input_manager.frame_size
188
+ return Array.new(@input_manager.frame_size, 0.0) if samples.empty?
189
+
190
+ Array.new(@input_manager.frame_size - samples.length, 0.0) + samples
191
+ rescue StandardError => e
192
+ report_error(e, context: "audio capture failed")
193
+ fallback_frame_size = @input_manager.respond_to?(:frame_size) ? Integer(@input_manager.frame_size) : 1024
194
+ Array.new(fallback_frame_size, 0.0)
195
+ end
196
+
197
+ def supported_fft_size(size)
198
+ value = Integer(size)
199
+ return value if power_of_two?(value)
200
+
201
+ 1024
202
+ rescue StandardError
203
+ 1024
204
+ end
205
+
206
+ def power_of_two?(value)
207
+ value.positive? && (value & (value - 1)).zero?
208
+ end
209
+
210
+ def build_scene_layers(scene_layers, analyzed)
211
+ return default_scene_layers(analyzed) if scene_layers.empty?
212
+
213
+ @mapping_resolver.resolve_layers(scene_layers: scene_layers, audio: analyzed)
214
+ end
215
+
216
+ def default_scene_layers(analyzed)
217
+ amplitude = analyzed[:amplitude]
218
+ high = analyzed.dig(:bands, :high).to_f
219
+
220
+ [
221
+ {
222
+ name: "wireframe_cube",
223
+ type: "geometry",
224
+ params: {
225
+ rotation_speed: (0.4 + amplitude * 1.5).round(4),
226
+ color_shift: high.round(4)
227
+ }
228
+ }
229
+ ]
230
+ end
231
+
232
+ def current_scene
233
+ @scene_mutex.synchronize do
234
+ {
235
+ name: @scene_name,
236
+ layers: Array(@scene_layers)
237
+ }
238
+ end
239
+ end
240
+
241
+ def evaluate_transition(audio, frame_count:)
242
+ return if transition_evaluation_paused?
243
+
244
+ transition = @scene_mutex.synchronize do
245
+ scene = {
246
+ name: @scene_name,
247
+ layers: Array(@scene_layers)
248
+ }
249
+ trigger_frame_count, trigger_audio = transition_trigger_inputs(
250
+ scene_name: scene[:name],
251
+ audio: audio,
252
+ frame_count: frame_count
253
+ )
254
+ @transition_controller.next_transition(
255
+ scene_name: scene[:name],
256
+ audio: trigger_audio,
257
+ frame_count: trigger_frame_count
258
+ )
259
+ end
260
+ return unless transition
261
+
262
+ update_scene(scene_name: transition[:to], scene_layers: transition.dig(:scene, :layers))
263
+ WebSocketHandler.broadcast(
264
+ type: "scene_change",
265
+ payload: {
266
+ from: transition[:from].to_s,
267
+ to: transition[:to].to_s,
268
+ effect: transition[:effect]
269
+ }
270
+ )
271
+ end
272
+
273
+ def reset_transition_trigger_counters!
274
+ @transition_counter_scene_name = nil
275
+ @transition_counter_frame_base = 0
276
+ @transition_counter_beat_base = 0
277
+ end
278
+
279
+ def transition_evaluation_paused?
280
+ @scene_mutex.synchronize { file_transport_source? && !@transport_playing }
281
+ end
282
+
283
+ def initial_transport_playing_state
284
+ file_transport_source? ? false : true
285
+ end
286
+
287
+ def file_transport_source?
288
+ return false unless @input_manager.is_a?(Vizcore::Audio::InputManager)
289
+
290
+ @input_manager.source_name.to_sym == :file
291
+ rescue StandardError
292
+ false
293
+ end
294
+
295
+ def transport_position_reset?(position_seconds)
296
+ Float(position_seconds) <= 0.05
297
+ rescue StandardError
298
+ false
299
+ end
300
+
301
+ def transition_trigger_inputs(scene_name:, audio:, frame_count:)
302
+ sync_transition_trigger_counters(scene_name: scene_name, audio: audio, frame_count: frame_count)
303
+
304
+ global_frame_count = Integer(frame_count)
305
+ scene_frame_count = [global_frame_count - @transition_counter_frame_base, 0].max
306
+
307
+ audio_hash = Hash(audio)
308
+ global_beat_count = extract_beat_count(audio_hash)
309
+ scene_beat_count = [global_beat_count - @transition_counter_beat_base, 0].max
310
+
311
+ [scene_frame_count, audio_hash.merge(beat_count: scene_beat_count)]
312
+ rescue StandardError
313
+ [0, { beat_count: 0 }]
314
+ end
315
+
316
+ def sync_transition_trigger_counters(scene_name:, audio:, frame_count:)
317
+ normalized_scene_name = scene_name.to_s
318
+ return if @transition_counter_scene_name == normalized_scene_name
319
+
320
+ audio_hash = Hash(audio)
321
+ global_frame_count = Integer(frame_count)
322
+ global_beat_count = extract_beat_count(audio_hash)
323
+
324
+ @transition_counter_scene_name = normalized_scene_name
325
+ @transition_counter_frame_base = [global_frame_count - 1, 0].max
326
+ # Include the current frame's beat in the new scene-local counter when a beat is detected.
327
+ @transition_counter_beat_base = global_beat_count - (truthy_audio_beat?(audio_hash) ? 1 : 0)
328
+ rescue StandardError
329
+ reset_transition_trigger_counters!
330
+ end
331
+
332
+ def extract_beat_count(audio)
333
+ Integer(audio[:beat_count] || audio["beat_count"] || 0)
334
+ rescue StandardError
335
+ 0
336
+ end
337
+
338
+ def truthy_audio_beat?(audio)
339
+ !!(audio[:beat] || audio["beat"])
340
+ end
341
+
342
+ def report_error(error, context:)
343
+ @last_error = error
344
+ @error_reporter.call(Vizcore::ErrorFormatting.summarize(error, context: context))
345
+ rescue StandardError
346
+ nil
347
+ end
348
+
349
+ end
350
+ end
351
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+ require "rack"
6
+ require_relative "websocket_handler"
7
+
8
+ module Vizcore
9
+ module Server
10
+ # Rack app serving frontend assets, health endpoint, and WebSocket upgrade.
11
+ class RackApp
12
+ AUDIO_FILE_PATH = "/audio-file"
13
+ RUNTIME_PATH = "/runtime"
14
+
15
+ # @param frontend_root [Pathname]
16
+ # @param websocket_path [String]
17
+ # @param audio_source [Symbol, String, nil]
18
+ # @param audio_file [String, Pathname, nil]
19
+ # @param scene_names [Array<String, Symbol>, nil]
20
+ def initialize(frontend_root:, websocket_path: "/ws", audio_source: nil, audio_file: nil, scene_names: nil)
21
+ @frontend_root = frontend_root.expand_path
22
+ @websocket_path = websocket_path
23
+ @audio_source = audio_source&.to_sym
24
+ @audio_file = audio_file ? Pathname.new(audio_file).expand_path : nil
25
+ @scene_names = normalize_scene_names(scene_names)
26
+ end
27
+
28
+ # @param env [Hash]
29
+ # @return [Array(Integer, Hash, Array<String>)]
30
+ def call(env)
31
+ request = Rack::Request.new(env)
32
+
33
+ return WebSocketHandler.call(env) if request.path_info == @websocket_path
34
+ return health_response if request.path_info == "/health"
35
+ return runtime_response if request.path_info == RUNTIME_PATH
36
+ return audio_file_response(request) if request.path_info == AUDIO_FILE_PATH
37
+
38
+ serve_static(request.path_info)
39
+ end
40
+
41
+ private
42
+
43
+ def health_response
44
+ body = JSON.generate(status: "ok", websocket_clients: WebSocketHandler.connection_count)
45
+ [200, json_headers.merge("content-length" => body.bytesize.to_s), [body]]
46
+ end
47
+
48
+ def runtime_response
49
+ payload = {
50
+ status: "ok",
51
+ audio_source: (@audio_source || :unknown).to_s,
52
+ audio_file_name: nil,
53
+ audio_file_url: nil,
54
+ scene_names: @scene_names
55
+ }
56
+
57
+ if audio_file_available?
58
+ payload[:audio_file_name] = @audio_file.basename.to_s
59
+ payload[:audio_file_url] = AUDIO_FILE_PATH
60
+ end
61
+
62
+ body = JSON.generate(payload)
63
+ [200, json_headers.merge("content-length" => body.bytesize.to_s), [body]]
64
+ end
65
+
66
+ def audio_file_response(request)
67
+ return not_found_response unless audio_file_available?
68
+
69
+ file_size = @audio_file.size
70
+ range = parse_byte_range(request.get_header("HTTP_RANGE"), file_size)
71
+ return range_not_satisfiable_response(file_size) if range == :invalid
72
+
73
+ if range
74
+ byte_start, byte_end = range
75
+ length = byte_end - byte_start + 1
76
+ body = File.binread(@audio_file, length, byte_start)
77
+ return [
78
+ 206,
79
+ audio_headers(content_length: body.bytesize).merge("content-range" => "bytes #{byte_start}-#{byte_end}/#{file_size}"),
80
+ [body]
81
+ ]
82
+ end
83
+
84
+ body = File.binread(@audio_file)
85
+ [200, audio_headers(content_length: body.bytesize), [body]]
86
+ end
87
+
88
+ def serve_static(path_info)
89
+ path = path_info == "/" ? "index.html" : path_info.delete_prefix("/")
90
+ full_path = File.expand_path(path, @frontend_root.to_s)
91
+
92
+ return not_found_response unless full_path.start_with?(@frontend_root.to_s)
93
+ return not_found_response unless File.file?(full_path)
94
+
95
+ body = File.binread(full_path)
96
+ headers = {
97
+ "content-type" => Rack::Mime.mime_type(File.extname(full_path), "text/plain"),
98
+ "content-length" => body.bytesize.to_s,
99
+ "cache-control" => "no-store, max-age=0, must-revalidate"
100
+ }
101
+ [200, headers, [body]]
102
+ end
103
+
104
+ def not_found_response
105
+ [404, text_headers.merge("content-length" => "9"), ["Not Found"]]
106
+ end
107
+
108
+ def text_headers
109
+ { "content-type" => "text/plain; charset=utf-8" }
110
+ end
111
+
112
+ def json_headers
113
+ { "content-type" => "application/json; charset=utf-8" }
114
+ end
115
+
116
+ def audio_file_available?
117
+ @audio_source == :file && @audio_file && @audio_file.file?
118
+ end
119
+
120
+ def audio_headers(content_length:)
121
+ {
122
+ "content-type" => Rack::Mime.mime_type(@audio_file.extname, "application/octet-stream"),
123
+ "content-length" => content_length.to_s,
124
+ "cache-control" => "no-store, max-age=0, must-revalidate",
125
+ "accept-ranges" => "bytes"
126
+ }
127
+ end
128
+
129
+ def normalize_scene_names(values)
130
+ Array(values).filter_map do |entry|
131
+ name = entry.to_s.strip
132
+ next if name.empty?
133
+
134
+ name
135
+ end.uniq
136
+ rescue StandardError
137
+ []
138
+ end
139
+
140
+ def parse_byte_range(raw_range, file_size)
141
+ range_value = raw_range.to_s.strip
142
+ return nil if range_value.empty?
143
+ return :invalid unless range_value.start_with?("bytes=")
144
+ return :invalid if file_size <= 0
145
+
146
+ match = /\Abytes=(\d*)-(\d*)\z/.match(range_value)
147
+ return :invalid unless match
148
+
149
+ start_part = match[1]
150
+ end_part = match[2]
151
+
152
+ if start_part.empty?
153
+ return :invalid if end_part.empty?
154
+
155
+ suffix_length = Integer(end_part)
156
+ return :invalid unless suffix_length.positive?
157
+
158
+ return [0, file_size - 1] if suffix_length >= file_size
159
+
160
+ return [file_size - suffix_length, file_size - 1]
161
+ end
162
+
163
+ start_offset = Integer(start_part)
164
+ return :invalid if start_offset.negative? || start_offset >= file_size
165
+
166
+ if end_part.empty?
167
+ return [start_offset, file_size - 1]
168
+ end
169
+
170
+ end_offset = Integer(end_part)
171
+ return :invalid if end_offset < start_offset
172
+
173
+ [start_offset, [end_offset, file_size - 1].min]
174
+ rescue StandardError
175
+ :invalid
176
+ end
177
+
178
+ def range_not_satisfiable_response(file_size)
179
+ [416, text_headers.merge("content-range" => "bytes */#{file_size}", "content-length" => "0"), []]
180
+ end
181
+ end
182
+ end
183
+ end