#!/usr/bin/env python3
"""Извлекает кадры на реальных временных метках слов, стыках и финале."""
from __future__ import annotations
import argparse, json, subprocess
from pathlib import Path

def main():
    ap=argparse.ArgumentParser(); ap.add_argument('video'); ap.add_argument('timeline'); ap.add_argument('--out-dir',default='checkpoints')
    ap.add_argument('--first-words',type=int,default=5); args=ap.parse_args()
    t=json.loads(Path(args.timeline).read_text(encoding='utf-8')); out=Path(args.out_dir); out.mkdir(parents=True,exist_ok=True)
    points=[]
    for w in t['words'][:args.first_words]: points.append((w['start'],f"word_{w['id']:04d}_{w['word']}"))
    for i,c in enumerate(t['clips'][1:],start=1):
        at=c.get('global_start')
        if at is None: continue   # план ретайминга не даёт глобальных времён клипов
        points.append((at,f"join_source_{c.get('source_index',i)}"))
    if t['words']: points.append((t['words'][-1]['start'],f"last_word_{t['words'][-1]['word']}"))
    failed=[]
    for sec,name in points:
        safe=''.join(ch if ch.isalnum() or ch in '-_' else '_' for ch in name)[:80]
        p=out/f'{sec:09.3f}_{safe}.jpg'
        # `scale=out_range=pc,format=yuvj420p` is not decoration. A graded master
        # carries limited-range YUV, and mjpeg refuses to encode it ("Non
        # full-range YUV is non-standard") — so extracting a контрольный кадр
        # from a finished file failed on every project, and the gate reported a
        # failure with no error text because ffmpeg wrote nothing to parse.
        r=subprocess.run(['ffmpeg','-y','-v','error','-ss',f'{sec:.3f}','-i',args.video,
                          '-frames:v','1','-vf','scale=out_range=pc,format=yuvj420p',
                          '-q:v','2',str(p)],capture_output=True,text=True,errors='replace')
        if r.returncode or not p.is_file():
            failed.append({'at':round(sec,3),'name':safe,'tail':(r.stderr or '').strip()[-300:]})
    # The last frame is taken with -sseof, not by seeking to expected_duration
    # minus a nudge. That arithmetic is against the *planned* length, and the
    # rendered file is routinely a frame shorter, so the seek lands past the last
    # decodable frame and ffmpeg exits with nothing to say. -sseof rewinds from
    # the real end of the real file, and -update overwrites until the genuine
    # final frame is the one left on disk — which is also what the check is for.
    last=out/'zz_last_frame.jpg'
    r=subprocess.run(['ffmpeg','-y','-v','error','-sseof','-0.5','-i',args.video,
                      '-vf','scale=out_range=pc,format=yuvj420p','-update','1',
                      '-q:v','2',str(last)],capture_output=True,text=True,errors='replace')
    if r.returncode or not last.is_file():
        failed.append({'at':'sseof -0.5','name':'last_frame',
                       'tail':(r.stderr or '').strip()[-300:] or 'ffmpeg wrote no frame'})
    print(json.dumps({'frames':len(points)+1-len(failed),'requested':len(points)+1,
                      'failed':failed,'out_dir':str(out)},ensure_ascii=False))
    if failed: raise SystemExit(1)
if __name__=='__main__': main()
