Shoraky commited on
Commit
a071eaf
·
1 Parent(s): 8a36278

Add browser compatible video finalization

Browse files
Files changed (2) hide show
  1. Dockerfile +1 -1
  2. app.py +132 -1
Dockerfile CHANGED
@@ -8,7 +8,7 @@ RUN npm run build && npm prune --omit=dev
8
 
9
  FROM python:3.10-slim-bookworm
10
  ENV NODE_ENV=production PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 HF_HOME=/home/appuser/.cache/huggingface PORT=7860
11
- RUN apt-get update && apt-get install -y --no-install-recommends libglib2.0-0 libgl1 \
12
  && rm -rf /var/lib/apt/lists/* \
13
  && useradd --create-home --uid 1000 appuser
14
  COPY --from=node-build /usr/local/bin/node /usr/local/bin/node
 
8
 
9
  FROM python:3.10-slim-bookworm
10
  ENV NODE_ENV=production PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 HF_HOME=/home/appuser/.cache/huggingface PORT=7860
11
+ RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg libglib2.0-0 libgl1 \
12
  && rm -rf /var/lib/apt/lists/* \
13
  && useradd --create-home --uid 1000 appuser
14
  COPY --from=node-build /usr/local/bin/node /usr/local/bin/node
app.py CHANGED
@@ -7,6 +7,8 @@ import math
7
  import os
8
  import re
9
  import shutil
 
 
10
  import tempfile
11
  import time
12
  from concurrent.futures import ThreadPoolExecutor
@@ -237,6 +239,132 @@ def create_video_poster(source, destination):
237
  capture.release()
238
 
239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  def run_job(client_id, work, cameras):
241
  job = jobs[client_id]
242
  session_id = job["sessionId"]
@@ -260,7 +388,10 @@ def run_job(client_id, work, cameras):
260
  if job.get("cancel"):
261
  raise InterruptedError()
262
  (work / "results.json").write_text(json.dumps(json_value(reports), allow_nan=False), encoding="utf-8")
263
- job.update(progress=95, phase="Saving", stage="saving")
 
 
 
264
  hub().upload_folder(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
265
  folder_path=str(work), path_in_repo=f"sessions/{session_id}",
266
  allow_patterns=["results.json", "videos/*", "posters/*.webp"])
 
7
  import os
8
  import re
9
  import shutil
10
+ import subprocess
11
+ import threading
12
  import tempfile
13
  import time
14
  from concurrent.futures import ThreadPoolExecutor
 
239
  capture.release()
240
 
241
 
242
+ def probe_video(path):
243
+ result = subprocess.run(
244
+ ["ffprobe", "-v", "error", "-show_streams", "-show_format", "-of", "json", str(path)],
245
+ check=True, capture_output=True, text=True, timeout=60,
246
+ )
247
+ metadata = json.loads(result.stdout)
248
+ video = next((stream for stream in metadata.get("streams", []) if stream.get("codec_type") == "video"), None)
249
+ if not video:
250
+ raise RuntimeError(f"No video stream found in {path.name}")
251
+ def numeric(value):
252
+ try:
253
+ parsed = float(value)
254
+ return parsed if math.isfinite(parsed) else 0
255
+ except (TypeError, ValueError):
256
+ return 0
257
+
258
+ duration = numeric(video.get("duration")) or numeric(metadata.get("format", {}).get("duration"))
259
+ fps_text = video.get("avg_frame_rate", "0/1")
260
+ try:
261
+ numerator, denominator = fps_text.split("/", 1)
262
+ denominator_value = float(denominator)
263
+ fps = float(numerator) / denominator_value if denominator_value > 0 else 0
264
+ except (ValueError, ZeroDivisionError):
265
+ fps = 0
266
+ try:
267
+ total_frames = int(video.get("nb_frames"))
268
+ except (TypeError, ValueError):
269
+ total_frames = round(duration * fps)
270
+ compatible = (
271
+ Path(path).suffix.lower() == ".mp4"
272
+ and video.get("codec_name") == "h264"
273
+ and video.get("pix_fmt") == "yuv420p"
274
+ and duration > 0
275
+ )
276
+ return {"duration": duration, "frames": total_frames, "compatible": compatible}
277
+
278
+
279
+ def finalize_videos(work, cameras, job):
280
+ video_dir = work / "videos"
281
+ inputs = []
282
+ for order in cameras:
283
+ matches = [path for path in video_dir.glob(f"camera_{order}.*") if path.is_file()]
284
+ if len(matches) != 1:
285
+ raise RuntimeError(f"Expected one source video for camera {order}")
286
+ metadata = probe_video(matches[0])
287
+ inputs.append((order, matches[0], metadata))
288
+
289
+ total_duration = sum(metadata["duration"] for _, _, metadata in inputs)
290
+ if total_duration <= 0:
291
+ raise RuntimeError("Could not determine video duration for finalization")
292
+ completed_duration = 0.0
293
+
294
+ def report(order, message, local_progress, frames_done=0, frames_total=0):
295
+ weighted = (completed_duration + inputs_by_order[order]["duration"] * local_progress) / total_duration
296
+ job.update(
297
+ status="running", stage="finalizing", progress=90 + 8 * weighted,
298
+ phase=message, videoProgress=round(local_progress * 100),
299
+ videoFramesProcessed=frames_done, videoFramesTotal=frames_total,
300
+ updatedAt=int(time.time() * 1000),
301
+ )
302
+
303
+ inputs_by_order = {order: metadata for order, _, metadata in inputs}
304
+ for index, (order, source, metadata) in enumerate(inputs, start=1):
305
+ if job.get("cancel"):
306
+ raise InterruptedError()
307
+ frames_total = metadata["frames"]
308
+ if metadata["compatible"]:
309
+ report(order, f"Video {index}/{len(inputs)} already browser-ready", 1, frames_total, frames_total)
310
+ completed_duration += metadata["duration"]
311
+ continue
312
+
313
+ target = video_dir / f"camera_{order}.mp4"
314
+ temporary = video_dir / f"camera_{order}.finalizing.mp4"
315
+ temporary.unlink(missing_ok=True)
316
+ command = [
317
+ "ffmpeg", "-hide_banner", "-loglevel", "error", "-nostats", "-y", "-i", str(source),
318
+ "-map", "0:v:0", "-an", "-sn", "-dn", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
319
+ "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p",
320
+ "-movflags", "+faststart", "-progress", "pipe:1", str(temporary),
321
+ ]
322
+ process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1)
323
+ stderr_lines = []
324
+ stderr_reader = threading.Thread(target=lambda: stderr_lines.extend(process.stderr), daemon=True)
325
+ stderr_reader.start()
326
+ last_frames = 0
327
+ try:
328
+ for line in process.stdout:
329
+ if job.get("cancel"):
330
+ process.terminate()
331
+ raise InterruptedError()
332
+ key, separator, value = line.strip().partition("=")
333
+ if not separator:
334
+ continue
335
+ if key == "frame":
336
+ try:
337
+ last_frames = int(value)
338
+ except ValueError:
339
+ pass
340
+ elif key == "out_time_us":
341
+ try:
342
+ local_progress = min(0.99, max(0, int(value) / 1_000_000 / metadata["duration"]))
343
+ report(order, f"Preparing video {index}/{len(inputs)}", local_progress, last_frames, frames_total)
344
+ except ValueError:
345
+ pass
346
+ return_code = process.wait()
347
+ stderr_reader.join(timeout=5)
348
+ if return_code != 0:
349
+ raise RuntimeError("Video conversion failed: " + "".join(stderr_lines)[-1000:])
350
+ if not temporary.is_file() or temporary.stat().st_size == 0:
351
+ raise RuntimeError("Video conversion produced an empty file")
352
+ verified = probe_video(temporary)
353
+ if not verified["compatible"]:
354
+ raise RuntimeError("Converted video failed browser compatibility verification")
355
+ os.replace(temporary, target)
356
+ if source != target:
357
+ source.unlink()
358
+ except Exception:
359
+ if process.poll() is None:
360
+ process.kill()
361
+ process.wait()
362
+ temporary.unlink(missing_ok=True)
363
+ raise
364
+ report(order, f"Video {index}/{len(inputs)} verified", 1, frames_total, frames_total)
365
+ completed_duration += metadata["duration"]
366
+
367
+
368
  def run_job(client_id, work, cameras):
369
  job = jobs[client_id]
370
  session_id = job["sessionId"]
 
388
  if job.get("cancel"):
389
  raise InterruptedError()
390
  (work / "results.json").write_text(json.dumps(json_value(reports), allow_nan=False), encoding="utf-8")
391
+ finalize_videos(work, cameras, job)
392
+ if job.get("cancel"):
393
+ raise InterruptedError()
394
+ job.update(progress=98, phase="Saving results", stage="saving", videoProgress=100)
395
  hub().upload_folder(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
396
  folder_path=str(work), path_in_repo=f"sessions/{session_id}",
397
  allow_patterns=["results.json", "videos/*", "posters/*.webp"])