Files
splatter-tennis/tools/trailer/qa.py
T
wuwiandClaude Opus 5.5 5fba837ead Tighten the trailer to 1:30 and fix its two audio faults
Cut 3 of LIFETIME MEMBERS: the intro music no longer runs into Reg's first
line, Ambrose finishes "...still waiting for a result" (line ends are now
measured off each take instead of the QA transcript), every shot is
tighter, and the black title cards are set larger. Cut 2 stays on disk
beside it as the reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:56:50 +02:00

57 lines
3.1 KiB
Python

#!/usr/bin/env python3
"""Look at and listen to a take: a contact sheet of N frames, plus a transcript and a description of
the soundtrack (an audio model hears it so the cut does not rest on guesswork).
uv run --with requests python tools/trailer/qa.py <clip.mp4> [frames=6]
uv run --with requests python tools/trailer/qa.py --ask <media> "<question>" # any file, any question
"""
import base64, json, os, subprocess, sys, tempfile
import requests
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from gen import API, H, OUT
def ask(media, question, model=os.environ.get('QA_LISTENER', 'google/gemini-2.5-pro')):
wav = tempfile.mktemp(suffix='.wav')
subprocess.run(['ffmpeg', '-v', 'error', '-y', '-i', media, '-ac', '1', '-ar', '16000', wav], check=True)
r = requests.post(f'{API}/chat/completions', headers=H(), json={'model': model, 'messages': [{'role': 'user', 'content': [
{'type': 'text', 'text': question},
{'type': 'input_audio', 'input_audio': {'data': base64.b64encode(open(wav, 'rb').read()).decode(), 'format': 'wav'}}]}]},
timeout=600)
return r.json()['choices'][0]['message']['content'] if r.ok else r.text[:500]
if sys.argv[1] == '--ask':
sys.exit(print(ask(sys.argv[2], sys.argv[3])))
clip = sys.argv[1]
n = int(sys.argv[2]) if len(sys.argv) > 2 else 6
name = os.path.splitext(os.path.basename(clip))[0]
dur = float(subprocess.check_output(['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
'-of', 'csv=p=0', clip]).decode())
info = subprocess.check_output(['ffprobe', '-v', 'error', '-show_entries', 'stream=codec_type,width,height,r_frame_rate',
'-of', 'compact', clip]).decode().strip()
print(f'{name}: {dur:.2f}s {info}')
sheet = os.path.join(OUT, 'qa', name + '.jpg')
os.makedirs(os.path.dirname(sheet), exist_ok=True)
tmp = tempfile.mkdtemp()
for i in range(n):
t = dur * (i + 0.5) / n
subprocess.run(['ffmpeg', '-v', 'error', '-y', '-ss', f'{t:.2f}', '-i', clip, '-frames:v', '1',
'-vf', 'scale=-2:640', f'{tmp}/{i:02d}.jpg'], check=True)
subprocess.run(['magick', *sorted(f'{tmp}/{f}' for f in os.listdir(tmp)), '+append', sheet], check=True)
print('sheet', sheet)
if 'audio' not in info:
sys.exit('no audio stream')
wav = f'{tmp}/a.wav'
subprocess.run(['ffmpeg', '-v', 'error', '-y', '-i', clip, '-ac', '1', '-ar', '16000', wav], check=True)
r = requests.post(f'{API}/chat/completions', headers=H(), json={
'model': os.environ.get('QA_MODEL', 'google/gemini-2.5-flash'),
'messages': [{'role': 'user', 'content': [
{'type': 'text', 'text': 'Transcribe every spoken word in this clip verbatim with rough timestamps. '
'Then, in two short lines: describe the voice (accent, age, texture) and list every non-speech '
'sound you hear (music? ambience? effects?).'},
{'type': 'input_audio', 'input_audio': {'data': base64.b64encode(open(wav, 'rb').read()).decode(),
'format': 'wav'}}]}]}, timeout=300)
print(r.json()['choices'][0]['message']['content'] if r.ok else r.text[:500])