Ivan000 commited on
Commit
5f47c12
·
verified ·
1 Parent(s): 5b3ff82

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +157 -228
app.py CHANGED
@@ -1,16 +1,14 @@
1
  # audio_split_zip_gradio.py
2
- # UI: Gradio. Splits big audio into N-second chunks, exports with chosen bitrate,
3
- # and returns a ZIP archive with all parts.
4
- #
5
- # Notes:
6
- # - Works best with ffmpeg installed (system dependency).
7
- # - Supports most common formats (mp3, wav, m4a, flac, ogg, etc.).
8
- # - Keeps original bitrate if "Auto (same as source)" is selected and bitrate is detectable.
9
 
10
  import os
11
  import math
12
  import re
13
- import shutil
14
  import tempfile
15
  import zipfile
16
  import subprocess
@@ -20,14 +18,10 @@ import gradio as gr
20
 
21
 
22
  # -------------------------
23
- # Helpers
24
  # -------------------------
25
 
26
- AUDIO_EXTS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg", ".opus", ".wma", ".aiff", ".alac", ".mp4", ".webm"}
27
-
28
-
29
  def _run(cmd):
30
- """Run subprocess command, raise with readable stderr on error."""
31
  p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
32
  if p.returncode != 0:
33
  raise RuntimeError(f"Command failed:\n{' '.join(cmd)}\n\n{p.stderr.strip()}")
@@ -36,23 +30,19 @@ def _run(cmd):
36
 
37
  def ffprobe_info(path: str) -> dict:
38
  """
39
- Extract duration and bitrate info via ffprobe.
40
- Returns dict with:
41
- duration_sec (float or None),
42
- bitrate_bps (int or None),
43
- codec_name (str or None),
44
- format_name (str or None)
45
  """
46
- # Get duration + format bitrate (if present)
47
- cmd = [
 
 
48
  "ffprobe", "-v", "error",
49
- "-show_entries", "format=duration,bit_rate,format_name",
50
  "-of", "default=noprint_wrappers=1:nokey=0",
51
  path
52
- ]
53
- out, _ = _run(cmd)
54
- info = {"duration_sec": None, "bitrate_bps": None, "codec_name": None, "format_name": None}
55
-
56
  for line in out.splitlines():
57
  if "=" not in line:
58
  continue
@@ -68,45 +58,36 @@ def ffprobe_info(path: str) -> dict:
68
  info["bitrate_bps"] = int(v)
69
  except Exception:
70
  pass
71
- elif k == "format_name":
72
- info["format_name"] = v
73
 
74
- # Get audio stream codec (optional)
75
- cmd2 = [
76
  "ffprobe", "-v", "error",
77
  "-select_streams", "a:0",
78
- "-show_entries", "stream=codec_name,bit_rate",
79
  "-of", "default=noprint_wrappers=1:nokey=0",
80
  path
81
- ]
82
- out2, _ = _run(cmd2)
83
- # Try to parse codec and stream bitrate (stream bitrate may be more accurate)
84
- codec = None
85
- stream_br = None
86
  for line in out2.splitlines():
87
  if "=" not in line:
88
  continue
89
  k, v = line.split("=", 1)
90
- k, v = k.strip(), v.strip()
91
- if k == "codec_name":
92
- codec = v
93
- elif k == "bit_rate":
94
  try:
95
- stream_br = int(v)
96
  except Exception:
97
  pass
98
 
99
- if codec:
100
- info["codec_name"] = codec
101
- if stream_br:
102
- info["bitrate_bps"] = stream_br
103
-
104
  return info
105
 
106
 
 
 
 
 
 
 
107
  def sanitize_filename(name: str) -> str:
108
- name = re.sub(r"[^\w\-.() ]+", "_", name, flags=re.UNICODE)
109
- name = name.strip().strip(".")
110
  return name or "audio"
111
 
112
 
@@ -115,249 +96,197 @@ def format_seconds(s: float) -> str:
115
  hh = int(s // 3600)
116
  mm = int((s % 3600) // 60)
117
  ss = int(s % 60)
118
- if hh > 0:
119
- return f"{hh:02d}:{mm:02d}:{ss:02d}"
120
- return f"{mm:02d}:{ss:02d}"
121
 
122
 
123
- def to_kbps(bps: int | None) -> int | None:
124
- if not bps:
125
- return None
126
- return max(1, int(round(bps / 1000)))
127
-
128
 
129
- def choose_output_ext(input_path: str, preferred: str) -> str:
130
  """
131
- preferred:
132
- - "Same as input" -> keep extension
133
- - "mp3", "m4a (aac)", "wav", "flac", "ogg (vorbis)", "opus"
134
  """
135
- in_ext = Path(input_path).suffix.lower()
136
- if preferred == "Same as input":
137
- return in_ext if in_ext in AUDIO_EXTS else ".mp3"
138
- mapping = {
139
- "mp3": ".mp3",
140
- "m4a (aac)": ".m4a",
141
- "wav": ".wav",
142
- "flac": ".flac",
143
- "ogg (vorbis)": ".ogg",
144
- "opus": ".opus",
145
- }
146
- return mapping.get(preferred, ".mp3")
147
-
148
-
149
- def ffmpeg_codec_args_for_ext(ext: str):
150
- """
151
- Return (codec_args, container_ext).
152
- codec_args used for encoding audio.
153
- """
154
- ext = ext.lower()
155
- if ext == ".mp3":
156
- return ["-c:a", "libmp3lame"], ext
157
- if ext == ".m4a":
158
- return ["-c:a", "aac"], ext
159
- if ext == ".wav":
160
- # PCM; bitrate selection not applicable
161
- return ["-c:a", "pcm_s16le"], ext
162
- if ext == ".flac":
163
- return ["-c:a", "flac"], ext
164
- if ext == ".ogg":
165
- return ["-c:a", "libvorbis"], ext
166
- if ext == ".opus":
167
- return ["-c:a", "libopus"], ext
168
- # fallback
169
- return ["-c:a", "libmp3lame"], ".mp3"
170
-
171
-
172
- def build_bitrate_arg(ext: str, bitrate_kbps: int | None):
173
- """
174
- For lossy codecs we can apply -b:a.
175
- For wav/flac it doesn't make much sense; we'll ignore.
176
- """
177
- ext = ext.lower()
178
- if bitrate_kbps is None:
179
- return []
180
- if ext in {".wav", ".flac"}:
181
- return []
182
- # common, flexible range 8..320 (or more for some codecs, but per your request)
183
- bitrate_kbps = int(max(8, min(320, bitrate_kbps)))
184
- return ["-b:a", f"{bitrate_kbps}k"]
185
 
186
 
187
  # -------------------------
188
- # Main processing
189
  # -------------------------
190
 
191
- def split_and_zip(
192
- file_obj,
193
- chunk_seconds: int,
194
- quality_mode: str,
195
- custom_bitrate_kbps: int,
196
- output_format: str
197
- ):
198
- if file_obj is None:
199
  raise gr.Error("Загрузите аудиофайл.")
200
 
201
- in_path = file_obj if isinstance(file_obj, str) else getattr(file_obj, "name", None)
202
- if not in_path or not os.path.exists(in_path):
203
- raise gr.Error("Не удалось прочитать загруженный файл.")
204
-
205
  try:
206
  chunk_seconds = int(chunk_seconds)
207
  except Exception:
208
  raise gr.Error("Длина фрагмента должна быть целым числом (секунды).")
209
-
210
  if chunk_seconds <= 0:
211
- raise gr.Error("Длина фрагмента должна быть > 0 секунд.")
 
 
212
 
213
- info = ffprobe_info(in_path)
214
  duration = info.get("duration_sec")
215
  src_kbps = to_kbps(info.get("bitrate_bps"))
216
 
217
  if duration is None:
218
- raise gr.Error("Не удалось определить длительность файла (проверьте ffmpeg/ffprobe и формат файла).")
219
-
220
- out_ext = choose_output_ext(in_path, output_format)
221
- codec_args, out_ext = ffmpeg_codec_args_for_ext(out_ext)
222
 
223
- # Determine bitrate for output
224
- bitrate_kbps = None
225
  if quality_mode == "Auto (same as source)":
226
- bitrate_kbps = src_kbps # may be None
 
227
  else:
228
- bitrate_kbps = int(custom_bitrate_kbps)
229
-
230
- # If auto but unknown -> choose a reasonable default for lossy formats
231
- if quality_mode == "Auto (same as source)" and bitrate_kbps is None:
232
- # Conservative default
233
- bitrate_kbps = 192 if out_ext not in {".wav", ".flac"} else None
234
-
235
- bitrate_args = build_bitrate_arg(out_ext, bitrate_kbps)
236
 
237
- base = sanitize_filename(Path(in_path).stem)
238
 
239
  tmpdir = tempfile.mkdtemp(prefix="audiosplit_")
240
- outdir = os.path.join(tmpdir, "parts")
241
- os.makedirs(outdir, exist_ok=True)
242
 
243
  total_parts = int(math.ceil(duration / chunk_seconds))
244
  digits = max(3, len(str(total_parts)))
245
 
246
- # We'll re-encode chunks (most compatible and consistent length).
247
- # For some formats you could do stream copy, but then exact cut points are not guaranteed.
248
- created_files = []
 
 
249
 
250
- try:
251
- for i in range(total_parts):
252
- start = i * chunk_seconds
253
- remaining = max(0.0, duration - start)
254
- this_len = min(float(chunk_seconds), remaining)
255
-
256
- out_name = f"{base}_part_{str(i+1).zfill(digits)}_{format_seconds(start)}-{format_seconds(start+this_len)}{out_ext}"
257
- out_path = os.path.join(outdir, out_name)
258
-
259
- cmd = [
260
- "ffmpeg", "-y",
261
- "-ss", str(start),
262
- "-t", str(this_len),
263
- "-i", in_path,
264
- *codec_args,
265
- *bitrate_args,
266
- "-vn", # no video
267
- out_path
268
- ]
269
- _run(cmd)
270
- created_files.append(out_path)
271
-
272
- # Create zip
273
- zip_path = os.path.join(tmpdir, f"{base}_split_{chunk_seconds}s.zip")
274
- with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
275
- for p in created_files:
276
- zf.write(p, arcname=os.path.basename(p))
277
-
278
- # Build status text
279
- src_br_txt = f"{src_kbps} kbps" if src_kbps else "не удалось определить"
280
- out_br_txt = "как в источнике" if quality_mode == "Auto (same as source)" else f"{custom_bitrate_kbps} kbps"
281
- if out_ext in {".wav", ".flac"}:
282
- out_br_txt += " (для WAV/FLAC параметр kbps не применяется)"
283
-
284
- status = (
285
- f"Файл: {Path(in_path).name}\n"
286
- f"Длительность: {duration:.2f} сек\n"
287
- f"Чанки: {chunk_seconds} сек\n"
288
- f"Количество частей: {total_parts}\n"
289
- f"Качество источника (битрейт): {src_br_txt}\n"
290
- f"Выходной формат: {out_ext}\n"
291
- f"Выходное качество: {out_br_txt}\n"
292
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
 
294
- return zip_path, status
295
-
296
- finally:
297
- # Important: Gradio needs the returned file to still exist.
298
- # So we cannot delete tmpdir immediately here.
299
- # Gradio will copy the file for download; to avoid leaks, we can schedule cleanup
300
- # when process exits. As a simple approach: do nothing here.
301
- # If you want aggressive cleanup, implement background cleanup with a delay.
302
- pass
303
 
304
 
305
  # -------------------------
306
- # UI
307
  # -------------------------
308
 
309
- css = """
 
310
  #app { max-width: 980px; margin: 0 auto; }
311
- .small { font-size: 12px; opacity: 0.85; }
 
 
 
 
 
 
 
 
 
 
 
 
312
  """
313
 
314
- with gr.Blocks(css=css) as demo:
315
- gr.Markdown("## Audio Splitter → ZIP\nЗагрузите большой аудиофайл, задайте длину фрагмента и качество, получите ZIP-архив с частями.")
 
 
 
 
 
 
 
316
 
317
- with gr.Row():
318
  inp = gr.File(label="Аудиофайл", file_count="single", type="filepath")
319
- with gr.Row():
320
  chunk_seconds = gr.Number(label="Длина одного фрагмента (сек)", value=10, precision=0)
321
- output_format = gr.Dropdown(
322
- label="Выходной формат",
323
- value="Same as input",
324
- choices=["Same as input", "mp3", "m4a (aac)", "wav", "flac", "ogg (vorbis)", "opus"]
325
- )
326
 
327
- with gr.Row():
328
  quality_mode = gr.Radio(
329
  label="Качество (битрейт)",
330
  choices=["Auto (same as source)", "Custom (8..320 kbps)"],
331
  value="Auto (same as source)"
332
  )
333
- custom_bitrate_kbps = gr.Slider(
 
334
  label="Custom bitrate (kbps)",
335
- minimum=8, maximum=320, value=192, step=1
 
336
  )
337
 
338
- gr.Markdown(
339
- "<div class='small'>"
340
- "Подсказка: режим Auto пытается взять битрейт из источника через ffprobe. "
341
- "Если битрейт не определяется, будет выбран дефолт 192 kbps (для lossy форматов)."
342
- "</div>"
343
- )
344
 
345
- btn = gr.Button("Split & Download ZIP", variant="primary")
346
 
347
- out_zip = gr.File(label="ZIP архив (скачать)")
348
- status = gr.Textbox(label="Статус", lines=8)
349
 
350
- def _toggle_slider(mode):
351
- return gr.update(interactive=(mode != "Auto (same as source)"))
352
 
353
- quality_mode.change(_toggle_slider, inputs=quality_mode, outputs=custom_bitrate_kbps)
354
 
355
  btn.click(
356
  split_and_zip,
357
- inputs=[inp, chunk_seconds, quality_mode, custom_bitrate_kbps, output_format],
358
  outputs=[out_zip, status]
359
  )
360
 
361
  if __name__ == "__main__":
362
- # You can set share=True if you want a public link.
363
- demo.launch(server_name="0.0.0.0", server_port=7860)
 
 
 
 
 
 
 
 
1
  # audio_split_zip_gradio.py
2
+ # Gradio app: upload huge audio -> split into N-second parts -> download ZIP (mp3 parts)
3
+ # - Auto bitrate = same as source if detectable; fallback to 192 kbps
4
+ # - Custom bitrate slider appears only in Custom mode
5
+ # - Mobile-friendly UI (bigger text, simpler layout)
6
+ # - Uses ffmpeg/ffprobe (system dependency)
 
 
7
 
8
  import os
9
  import math
10
  import re
11
+ import time
12
  import tempfile
13
  import zipfile
14
  import subprocess
 
18
 
19
 
20
  # -------------------------
21
+ # Subprocess helpers
22
  # -------------------------
23
 
 
 
 
24
  def _run(cmd):
 
25
  p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
26
  if p.returncode != 0:
27
  raise RuntimeError(f"Command failed:\n{' '.join(cmd)}\n\n{p.stderr.strip()}")
 
30
 
31
  def ffprobe_info(path: str) -> dict:
32
  """
33
+ Returns:
34
+ duration_sec: float|None
35
+ bitrate_bps: int|None (tries stream bitrate, then format bitrate)
 
 
 
36
  """
37
+ info = {"duration_sec": None, "bitrate_bps": None}
38
+
39
+ # duration + format bitrate
40
+ out, _ = _run([
41
  "ffprobe", "-v", "error",
42
+ "-show_entries", "format=duration,bit_rate",
43
  "-of", "default=noprint_wrappers=1:nokey=0",
44
  path
45
+ ])
 
 
 
46
  for line in out.splitlines():
47
  if "=" not in line:
48
  continue
 
58
  info["bitrate_bps"] = int(v)
59
  except Exception:
60
  pass
 
 
61
 
62
+ # prefer audio stream bitrate if available
63
+ out2, _ = _run([
64
  "ffprobe", "-v", "error",
65
  "-select_streams", "a:0",
66
+ "-show_entries", "stream=bit_rate",
67
  "-of", "default=noprint_wrappers=1:nokey=0",
68
  path
69
+ ])
 
 
 
 
70
  for line in out2.splitlines():
71
  if "=" not in line:
72
  continue
73
  k, v = line.split("=", 1)
74
+ if k.strip() == "bit_rate":
 
 
 
75
  try:
76
+ info["bitrate_bps"] = int(v.strip())
77
  except Exception:
78
  pass
79
 
 
 
 
 
 
80
  return info
81
 
82
 
83
+ def to_kbps(bps: int | None) -> int | None:
84
+ if not bps:
85
+ return None
86
+ return max(1, int(round(bps / 1000)))
87
+
88
+
89
  def sanitize_filename(name: str) -> str:
90
+ name = re.sub(r"[^\w\-.() ]+", "_", name, flags=re.UNICODE).strip().strip(".")
 
91
  return name or "audio"
92
 
93
 
 
96
  hh = int(s // 3600)
97
  mm = int((s % 3600) // 60)
98
  ss = int(s % 60)
99
+ return f"{hh:02d}:{mm:02d}:{ss:02d}" if hh > 0 else f"{mm:02d}:{ss:02d}"
 
 
100
 
101
 
102
+ # -------------------------
103
+ # Temp cleanup
104
+ # -------------------------
 
 
105
 
106
+ def cleanup_tmpdirs(prefix="audiosplit_", older_than_seconds=6 * 3600):
107
  """
108
+ Best-effort cleanup of temp dirs created by this app.
109
+ Removes dirs in system temp older than N seconds.
 
110
  """
111
+ tmp_root = Path(tempfile.gettempdir())
112
+ now = time.time()
113
+ for p in tmp_root.glob(prefix + "*"):
114
+ try:
115
+ if not p.is_dir():
116
+ continue
117
+ age = now - p.stat().st_mtime
118
+ if age > older_than_seconds:
119
+ # avoid importing shutil globally; but it's fine:
120
+ import shutil
121
+ shutil.rmtree(p, ignore_errors=True)
122
+ except Exception:
123
+ pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
 
126
  # -------------------------
127
+ # Main logic
128
  # -------------------------
129
 
130
+ def split_and_zip(file_path: str, chunk_seconds: int, quality_mode: str, custom_kbps: int):
131
+ if not file_path or not os.path.exists(file_path):
 
 
 
 
 
 
132
  raise gr.Error("Загрузите аудиофайл.")
133
 
 
 
 
 
134
  try:
135
  chunk_seconds = int(chunk_seconds)
136
  except Exception:
137
  raise gr.Error("Длина фрагмента должна быть целым числом (секунды).")
 
138
  if chunk_seconds <= 0:
139
+ raise gr.Error("Длина фрагмента должна быть больше 0.")
140
+
141
+ cleanup_tmpdirs() # occasional cleanup
142
 
143
+ info = ffprobe_info(file_path)
144
  duration = info.get("duration_sec")
145
  src_kbps = to_kbps(info.get("bitrate_bps"))
146
 
147
  if duration is None:
148
+ raise gr.Error("Не удалось определить длительность файла. Проверьте ffmpeg/ffprobe и формат.")
 
 
 
149
 
150
+ # bitrate selection
 
151
  if quality_mode == "Auto (same as source)":
152
+ out_kbps = src_kbps if src_kbps else 192
153
+ out_quality_text = f"Auto → {out_kbps} kbps" + ("" if src_kbps else " (fallback)")
154
  else:
155
+ out_kbps = int(max(8, min(320, int(custom_kbps))))
156
+ out_quality_text = f"Custom → {out_kbps} kbps"
 
 
 
 
 
 
157
 
158
+ base = sanitize_filename(Path(file_path).stem)
159
 
160
  tmpdir = tempfile.mkdtemp(prefix="audiosplit_")
161
+ parts_dir = os.path.join(tmpdir, "parts")
162
+ os.makedirs(parts_dir, exist_ok=True)
163
 
164
  total_parts = int(math.ceil(duration / chunk_seconds))
165
  digits = max(3, len(str(total_parts)))
166
 
167
+ created = []
168
+ for i in range(total_parts):
169
+ start = i * chunk_seconds
170
+ remaining = max(0.0, duration - start)
171
+ this_len = min(float(chunk_seconds), remaining)
172
 
173
+ out_name = (
174
+ f"{base}_part_{str(i+1).zfill(digits)}_"
175
+ f"{format_seconds(start)}-{format_seconds(start+this_len)}.mp3"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  )
177
+ out_path = os.path.join(parts_dir, out_name)
178
+
179
+ # Re-encode to mp3 for consistent output and compatibility
180
+ cmd = [
181
+ "ffmpeg", "-y",
182
+ "-ss", str(start),
183
+ "-t", str(this_len),
184
+ "-i", file_path,
185
+ "-vn",
186
+ "-c:a", "libmp3lame",
187
+ "-b:a", f"{out_kbps}k",
188
+ out_path
189
+ ]
190
+ _run(cmd)
191
+ created.append(out_path)
192
+
193
+ zip_path = os.path.join(tmpdir, f"{base}_split_{chunk_seconds}s_mp3.zip")
194
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
195
+ for p in created:
196
+ zf.write(p, arcname=os.path.basename(p))
197
+
198
+ src_txt = f"{src_kbps} kbps" if src_kbps else "не удалось определить"
199
+ status = (
200
+ f"Файл: {Path(file_path).name}\n"
201
+ f"Длительность: {duration:.2f} сек\n"
202
+ f"Фрагмент: {chunk_seconds} сек\n"
203
+ f"Частей: {total_parts}\n"
204
+ f"Битрейт источника: {src_txt}\n"
205
+ f"Выход: mp3\n"
206
+ f"Качество: {out_quality_text}\n"
207
+ f"ZIP готов."
208
+ )
209
 
210
+ return zip_path, status
 
 
 
 
 
 
 
 
211
 
212
 
213
  # -------------------------
214
+ # UI (mobile-friendly)
215
  # -------------------------
216
 
217
+ CSS = """
218
+ :root { --text-lg: 18px; --text-md: 16px; }
219
  #app { max-width: 980px; margin: 0 auto; }
220
+ #title h2 { font-size: 22px; margin-bottom: 6px; }
221
+ .gradio-container { font-size: var(--text-md); }
222
+ label, .wrap, .prose { font-size: var(--text-md) !important; }
223
+ textarea, input, button { font-size: var(--text-md) !important; }
224
+ button { padding: 12px 16px !important; }
225
+ .smallnote { font-size: 13px; opacity: 0.85; }
226
+
227
+ @media (max-width: 640px) {
228
+ #app { padding: 10px; }
229
+ #title h2 { font-size: 20px; }
230
+ .gradio-container { font-size: var(--text-lg); }
231
+ label, textarea, input, button { font-size: var(--text-lg) !important; }
232
+ }
233
  """
234
 
235
+ with gr.Blocks() as demo:
236
+ with gr.Column(elem_id="app"):
237
+ gr.Markdown("## Audio Splitter → ZIP (mp3)", elem_id="title")
238
+ gr.Markdown(
239
+ "<div class='smallnote'>"
240
+ "Загрузите аудиофайл, задайте длину фрагмента в секундах. "
241
+ "На выходе будет ZIP с mp3-частями."
242
+ "</div>"
243
+ )
244
 
 
245
  inp = gr.File(label="Аудиофайл", file_count="single", type="filepath")
246
+
247
  chunk_seconds = gr.Number(label="Длина одного фрагмента (сек)", value=10, precision=0)
 
 
 
 
 
248
 
 
249
  quality_mode = gr.Radio(
250
  label="Качество (битрейт)",
251
  choices=["Auto (same as source)", "Custom (8..320 kbps)"],
252
  value="Auto (same as source)"
253
  )
254
+
255
+ custom_bitrate = gr.Slider(
256
  label="Custom bitrate (kbps)",
257
+ minimum=8, maximum=320, value=192, step=1,
258
+ visible=False, interactive=True
259
  )
260
 
261
+ gr.Markdown(
262
+ "<div class='smallnote'>"
263
+ "Auto: пытаемся взять битрейт из исходника через ffprobe. Если не получилось — 192 kbps."
264
+ "</div>"
265
+ )
 
266
 
267
+ btn = gr.Button("Split & Download ZIP", variant="primary")
268
 
269
+ out_zip = gr.File(label="ZIP архив (скачать)")
270
+ status = gr.Textbox(label="Статус", lines=8)
271
 
272
+ def toggle_custom(mode):
273
+ return gr.update(visible=(mode.startswith("Custom")))
274
 
275
+ quality_mode.change(toggle_custom, inputs=quality_mode, outputs=custom_bitrate)
276
 
277
  btn.click(
278
  split_and_zip,
279
+ inputs=[inp, chunk_seconds, quality_mode, custom_bitrate],
280
  outputs=[out_zip, status]
281
  )
282
 
283
  if __name__ == "__main__":
284
+ # Key changes:
285
+ # - css moved to launch() to avoid warning
286
+ # - ssr_mode disabled to remove SSR experimental mode
287
+ demo.launch(
288
+ server_name="0.0.0.0",
289
+ server_port=7860,
290
+ ssr_mode=False,
291
+ css=CSS
292
+ )