feat: export clips, protect bookmarks and clean session storage

This commit is contained in:
2026-10-08 18:56:51 +02:00
parent f6cdef5dd2
commit 7ac7e66ff1
28 changed files with 2286 additions and 35 deletions
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Validate a persisted clip before any source cleanup (ffmpeg/ffprobe required)."""
import json
import pathlib
import subprocess
import sys
saved_path = pathlib.Path(sys.argv[1])
saved = json.loads(saved_path.read_text())
clip = saved_path.with_suffix('.mp4')
session_path = saved_path.parent.parent / 'sessions' / str(saved['source_session']) / 'session.json'
session = json.loads(session_path.read_text())
seconds = lambda d: d['secs'] + d['nanos'] / 1e9
start, end = seconds(saved['start']), seconds(saved['end'])
assert end > start
pieces = [s for s in session['segments'] if s['available'] and seconds(s['start']) < end and seconds(s['end']) > start]
assert len(pieces) >= 2, 'Export must cross a segment boundary'
def probe(path):
return json.loads(subprocess.check_output(['ffprobe', '-v', 'error', '-show_streams', '-show_packets', '-of', 'json', str(path)]))
source = probe(session_path.parent / pieces[0]['path'])
output = probe(clip)
video = next(s for s in output['streams'] if s['codec_type'] == 'video')
source_video = next(s for s in source['streams'] if s['codec_type'] == 'video')
assert video['codec_name'] == source_video['codec_name']
pts = sorted(float(p['pts_time']) for p in output['packets'] if p['stream_index'] == video['index'])
fps = pieces[0]['frames_per_second']
for a, b in zip(pts, pts[1:]):
assert abs(b-a-1/fps) < 2e-6, ('Video gap/duplicate', a, b, fps)
assert abs(pts[0]) < 1/fps
assert abs(pts[-1]+1/fps-(end-start)) < 1/fps+0.002, (pts[-1], start, end)
# Metadata records independent AAC epochs, even if earlier files are cleaned up later.
runs={}
for segment in session['segments']:
if not segment['complete'] or not segment['available']:
continue
run=str(pathlib.Path(segment['path']).parent)
facts=probe(session_path.parent/segment['path'])
audios=[s for s in facts['streams'] if s['codec_type']=='audio']
totals=runs.setdefault(run,[0]*len(audios))
assert segment['audio_start_samples']==totals,(segment['path'],segment['audio_start_samples'],totals)
for i,stream in enumerate(audios):
totals[i]+=1024*len([p for p in facts['packets'] if p['stream_index']==stream['index']])
for ordinal,stream in enumerate([s for s in output['streams'] if s['codec_type']=='audio']):
if stream['codec_type'] == 'audio':
timestamps = [float(p['pts_time']) for p in output['packets'] if p['stream_index'] == stream['index']]
step = 1024 / int(stream['sample_rate'])
assert timestamps
import math
source_ordinal=ordinal if saved['separate_audio'] or len([s for s in source['streams'] if s['codec_type']=='audio'])!=3 else 2
first=pieces[0]
run_base=seconds(first['start'])-first['frame_start']/fps
phase=run_base+first['audio_start_samples'][source_ordinal]/int(stream['sample_rate'])-start
expected_first=phase+max(0,math.ceil(-phase/step))*step
assert abs(timestamps[0]-expected_first)<1/int(stream['sample_rate'])+2e-6,('A/V phase',timestamps[0],expected_first)
for a, b in zip(timestamps, timestamps[1:]):
assert abs(b-a-step) < 2e-6, ('Audio gap/duplicate', a, b)
source_audio = [s for s in source['streams'] if s['codec_type'] == 'audio']
expected = 2 if saved['separate_audio'] and len(source_audio) == 3 else 1
assert len([s for s in output['streams'] if s['codec_type'] == 'audio']) == expected
result = subprocess.run(['ffmpeg', '-v', 'error', '-xerror', '-i', str(clip), '-f', 'null', '-'], capture_output=True)
assert result.returncode == 0 and not result.stderr, result.stderr.decode()
assert saved['clip']['size_bytes'] == clip.stat().st_size
print(f'PASS clip {clip.name}: {video["codec_name"]}, {len(pts)} CFR frames, continuous audio, zero decode errors, {end-start:.3f}s')