Reconstruct repo from Claude Code + codex transcripts
Working tree (including .git) was lost to an rm. Rebuilt by replaying Write/Edit/ Read/attachment events from 25 Claude sessions and 22 successful codex apply_patch blocks into one timestamp-ordered timeline. Verified against ground truth recorded in the transcripts: wc -l on 10 files and ls -l on 5 files at 2026-07-18T13:13:44Z both match exactly; 18 files are byte-identical to their newest ~/.claude/file-history blob. See HANDOFF.md for sources, gaps, and how to rebuild .venv. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# Turn a video into timestamped contact sheets and scene-change frames for visual analysis.
|
||||
# Optional --window START:DURATION arguments create dense 10-fps sheets around transitions.
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/analyze_video_frames.sh VIDEO [OUTPUT_DIR] [--window START:DURATION ...]
|
||||
|
||||
Examples:
|
||||
scripts/analyze_video_frames.sh chapter.mp4
|
||||
scripts/analyze_video_frames.sh chapter.mp4 /tmp/chapter-analysis \
|
||||
--window 5.8:2.2 --window 15.8:2.5
|
||||
|
||||
Outputs:
|
||||
overview-001.jpg ... 2-fps timestamped contact sheets
|
||||
scenes/scene-001.jpg ... frames selected by scene-change score
|
||||
scene-timestamps.txt scene-change timestamps from FFmpeg showinfo
|
||||
window-START.jpg 10-fps contact sheet for each requested window
|
||||
|
||||
Environment overrides:
|
||||
OVERVIEW_FPS=2 SCENE_THRESHOLD=0.18 WINDOW_FPS=10
|
||||
EOF
|
||||
}
|
||||
|
||||
if [ "$#" -lt 1 ] || [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
|
||||
usage
|
||||
exit $([ "$#" -lt 1 ] && echo 1 || echo 0)
|
||||
fi
|
||||
|
||||
video=$1
|
||||
shift
|
||||
if [ ! -f "$video" ]; then
|
||||
echo "video not found: $video" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$#" -gt 0 ] && [[ $1 != --* ]]; then
|
||||
output_dir=$1
|
||||
shift
|
||||
else
|
||||
base=$(basename "$video")
|
||||
output_dir="/tmp/${base%.*}-frames"
|
||||
fi
|
||||
|
||||
overview_fps=${OVERVIEW_FPS:-2}
|
||||
scene_threshold=${SCENE_THRESHOLD:-0.18}
|
||||
window_fps=${WINDOW_FPS:-10}
|
||||
windows=()
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--window)
|
||||
[ "$#" -ge 2 ] || { echo "--window needs START:DURATION" >&2; exit 2; }
|
||||
windows+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$output_dir/scenes"
|
||||
|
||||
# Six-by-six sheets keep individual frames readable while allowing videos of arbitrary length:
|
||||
# tile emits additional overview-NNN images after every 36 sampled frames.
|
||||
ffmpeg -hide_banner -loglevel error -y -i "$video" \
|
||||
-vf "fps=${overview_fps},scale=320:-1,drawtext=text='%{pts\\:hms}':x=8:y=8:fontsize=20:fontcolor=yellow:borderw=2,tile=6x6:padding=4:margin=4" \
|
||||
-fps_mode vfr "$output_dir/overview-%03d.jpg"
|
||||
|
||||
# Keep stderr because showinfo reports the original timestamps there.
|
||||
ffmpeg -hide_banner -y -i "$video" \
|
||||
-vf "select='gt(scene,${scene_threshold})',showinfo" -fps_mode vfr \
|
||||
"$output_dir/scenes/scene-%03d.jpg" 2>"$output_dir/scene-showinfo.log" || true
|
||||
sed -n 's/.*pts_time:\([^ ]*\).*/\1/p' "$output_dir/scene-showinfo.log" \
|
||||
>"$output_dir/scene-timestamps.txt"
|
||||
|
||||
for window in "${windows[@]}"; do
|
||||
start=${window%%:*}
|
||||
duration=${window#*:}
|
||||
if [ "$start" = "$window" ] || [ -z "$start" ] || [ -z "$duration" ]; then
|
||||
echo "invalid window '$window'; expected START:DURATION" >&2
|
||||
exit 2
|
||||
fi
|
||||
safe_start=${start//./_}
|
||||
# A 5x5 sheet covers 2.5 seconds at the default 10 fps. Longer windows naturally emit more sheets.
|
||||
ffmpeg -hide_banner -loglevel error -y -ss "$start" -t "$duration" -i "$video" \
|
||||
-vf "fps=${window_fps},scale=320:-1,drawtext=text='%{pts\\:hms}':x=6:y=6:fontsize=18:fontcolor=yellow:borderw=2,tile=5x5:padding=3:margin=3" \
|
||||
-fps_mode vfr "$output_dir/window-${safe_start}-%03d.jpg"
|
||||
done
|
||||
|
||||
echo "video analysis frames: $output_dir"
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Interactively generate and choose a dots.tts narrator reference."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
DEFAULT_TEXT = "The story continues as our hero steps forward into the unknown."
|
||||
DEFAULT_MODEL = "rednote-hilab/dots.tts-base"
|
||||
DEFAULT_VOICE_DIR = Path("~/.cache/manga-tts").expanduser()
|
||||
|
||||
|
||||
def load_model(model_name: str):
|
||||
# Match worker_tts.py's workaround for the tokenizer bundled with dots.tts.
|
||||
import transformers
|
||||
|
||||
original = transformers.AutoTokenizer.from_pretrained.__func__
|
||||
transformers.AutoTokenizer.from_pretrained = classmethod(
|
||||
lambda cls, *args, **kwargs: original(
|
||||
cls, *args, **{"fix_mistral_regex": True, **kwargs}
|
||||
)
|
||||
)
|
||||
from dots_tts.runtime import DotsTtsRuntime
|
||||
|
||||
return DotsTtsRuntime.from_pretrained(model_name, precision="bfloat16")
|
||||
|
||||
|
||||
def write_wav(result, path: Path) -> None:
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
audio = result["audio"]
|
||||
if hasattr(audio, "detach"):
|
||||
audio = audio.detach().cpu().numpy()
|
||||
sf.write(path, np.asarray(audio, dtype="float32").squeeze(),
|
||||
result["sample_rate"], subtype="PCM_16")
|
||||
|
||||
|
||||
def play(path: Path) -> None:
|
||||
players = (
|
||||
("ffplay", "-nodisp", "-autoexit", "-loglevel", "error"),
|
||||
("aplay", "-q"),
|
||||
("paplay",),
|
||||
)
|
||||
for command in players:
|
||||
if shutil.which(command[0]):
|
||||
subprocess.run([*command, str(path)], check=False)
|
||||
return
|
||||
print(f"No ffplay, aplay, or paplay found; listen manually: {path}")
|
||||
|
||||
|
||||
def choice() -> str:
|
||||
while True:
|
||||
answer = input("[p]ick / p[a]rk / [s]kip: ").strip().lower()
|
||||
aliases = {"p": "pick", "pick": "pick", "a": "park", "park": "park",
|
||||
"s": "skip", "skip": "skip"}
|
||||
if answer in aliases:
|
||||
return aliases[answer]
|
||||
print("Enter p, a, or s.")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--text", default=os.environ.get("VOICE_REF_TEXT", DEFAULT_TEXT),
|
||||
help="sample text to speak")
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL)
|
||||
parser.add_argument("--voice-dir", type=Path, default=DEFAULT_VOICE_DIR)
|
||||
args = parser.parse_args()
|
||||
|
||||
voice_dir = args.voice_dir.expanduser()
|
||||
parked_dir = voice_dir / "parked"
|
||||
voice_dir.mkdir(parents=True, exist_ok=True)
|
||||
parked_dir.mkdir(parents=True, exist_ok=True)
|
||||
candidate = voice_dir / "voice_candidate.wav"
|
||||
selected = voice_dir / "narrator_ref.wav"
|
||||
|
||||
print(f"Loading {args.model} ...")
|
||||
model = load_model(args.model)
|
||||
print(f"Reference text: {args.text!r}")
|
||||
|
||||
try:
|
||||
while True:
|
||||
print("\nGenerating a new voice ...")
|
||||
write_wav(model.generate(text=args.text), candidate)
|
||||
play(candidate)
|
||||
|
||||
action = choice()
|
||||
if action == "pick":
|
||||
os.replace(candidate, selected)
|
||||
(voice_dir / "narrator_ref.txt").write_text(args.text + "\n")
|
||||
print(f"Selected: {selected}")
|
||||
return 0
|
||||
if action == "park":
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
parked = parked_dir / f"voice-{stamp}.wav"
|
||||
suffix = 2
|
||||
while parked.exists():
|
||||
parked = parked_dir / f"voice-{stamp}-{suffix}.wav"
|
||||
suffix += 1
|
||||
os.replace(candidate, parked)
|
||||
parked.with_suffix(".txt").write_text(args.text + "\n")
|
||||
print(f"Parked: {parked}")
|
||||
else:
|
||||
candidate.unlink(missing_ok=True)
|
||||
print("Skipped.")
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
candidate.unlink(missing_ok=True)
|
||||
print("\nStopped without changing the selected voice.")
|
||||
return 130
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user