Skip to content
Closed
9 changes: 9 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Force LF on markdown so a Windows editor can't re-flip these to CRLF
# and produce unreviewable whole-file diffs (see SKILL.md CRLF incident).
*.md text eol=lf

# Executable source: CRLF in a shell script breaks execution on Linux/Codex,
# which the README markets support for. Keep these LF everywhere.
*.py text eol=lf
*.sh text eol=lf
*.toml text eol=lf
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ ENV/

# Editors / IDEs
.vscode/
.claude/
.idea/
*.swp
*.swo
Expand Down
1 change: 1 addition & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
name: video-use
risk: cleared
description: Edit any video by conversation. Transcribe, cut, color grade, generate overlay animations, burn subtitles — for talking heads, montages, tutorials, travel, interviews. No presets, no menus. Ask questions, confirm the plan, execute, iterate, persist. Production-correctness rules are hard; everything else is artistic freedom.
---

Expand Down
35 changes: 35 additions & 0 deletions decisions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Decisions — video-use

## 2026-04-24 — video-use installed; client-content restriction pending John's ADV review

**Context:** Installed browser-use/video-use at `C:\Users\pceci\Claude\Projects\video-use` with symlink registered at `%USERPROFILE%\.claude\skills\video-use`. Skill is globally available from any directory via `claude` + an inventory/edit prompt. Outputs land in `<source-folder>/edit/`, never in the repo.

**Data flow:** ElevenLabs Scribe API handles transcription. Audio leaves the machine → ElevenLabs servers → word-level transcript returned. Video itself is never uploaded; only audio is sent for transcription. ffmpeg runs locally for rendering.

**Constraint (compliance):** No client meeting recordings, prospect calls, RIA-identifiable audio, or any content containing client PII runs through this skill until John reviews the ElevenLabs data flow as part of the ADV Part 2A AI disclosure work. This is the same gating constraint that applies to other cloud AI processing of client data.

**Permitted use today:**
- Personal video
- Youth sports (Jr. Trevians, JTL/Lax Lab, LaxVerse promo)
- Marketing/launch videos (SnapShooter, ShelfIQ, DirectorOps)
- NSIA-related non-confidential content (not board executive session recordings)

**Open items:**
- Add ElevenLabs to the RIA vendor/data-flow inventory for John's ADV review
- Once cleared, document permitted client-content scenarios and any required disclosures
- Revisit whether self-hosted Whisper (local transcription, no cloud) is a better fit for any client-adjacent use case even post-clearance

## 2026-06-19 — ElevenLabs data flow cleared for client content (supersedes the 2026-04-24 restriction)

**Decision:** John approved the ElevenLabs Scribe data flow for **full client content**. The 2026-04-24 restriction — barring client meeting recordings, prospect calls, RIA-identifiable audio, and any client-PII audio from this skill — is **lifted**. All such content may now be transcribed and edited through video-use.

**Scope:** Full. No client-content carve-outs remain.

**Conditions:** None. Approval was unconditional.

**Documentation basis:** Verbal approval, per Pete, 2026-06-19. No written ADV-file record captured yet (see open item).

**Open items:**
- **Documentation hygiene (not a usage blocker):** capture a one-line written confirmation of John's approval into the ADV Part 2A AI-disclosure file / vendor inventory. Verbal-only sign-off for a third-party cloud vendor that receives client PII is thin for an exam trail — a dated email or memo closes that gap.
- The `risk: caution` tag on SKILL.md can remain (audio still leaves the machine to a third party, so "caution" stays factually accurate even when use is permitted) or be revisited — Pete's call.
- Self-hosted Whisper remains worth evaluating as a no-cloud alternative for the most sensitive material, even though cloud use is now permitted.
33 changes: 31 additions & 2 deletions helpers/grade.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@
import tempfile
from pathlib import Path

# Windows consoles default to cp1252, which raises UnicodeEncodeError on the
# Unicode arrows/em-dashes in the status prints below. Force UTF-8 on stdout/
# stderr so this helper behaves identically on Windows, macOS, and Linux.
# No-op where the stream is already UTF-8 or can't be reconfigured.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8")
except (AttributeError, ValueError):
pass


PRESETS: dict[str, str] = {
# Subtle baseline — barely perceptible cleanup. No color shift.
Expand Down Expand Up @@ -75,6 +85,20 @@ def get_preset(name: str) -> str:
# -------- Auto grade (data-driven, per-clip) --------------------------------


def _metadata_filter_target(path: str) -> tuple[str, str]:
r"""Return (cwd, filter_value) for writing an ffmpeg metadata file safely.

ffmpeg filtergraphs treat ':' and '\' as delimiters, so embedding a Windows
path like C:\Users\me\tmp.txt in 'metadata=print:file=...' breaks parsing
and ffmpeg exits with an error. Instead we pass the bare filename and run
ffmpeg with cwd set to the file's directory, so no special character ever
reaches the filtergraph parser. Cross-platform: on POSIX the filename is
still bare and the parent dir is a normal cwd.
"""
p = Path(path)
return str(p.parent), p.name


def _sample_frame_stats(
video: Path,
start: float,
Expand All @@ -100,16 +124,21 @@ def _sample_frame_stats(
with tempfile.NamedTemporaryFile(mode="w+", suffix=".txt", delete=False) as f:
metadata_path = f.name

# Pass a bare filename + run ffmpeg from the file's directory so no Windows
# path (with ':' and '\') ever reaches the filtergraph parser. See
# _metadata_filter_target.
metadata_dir, metadata_name = _metadata_filter_target(metadata_path)

try:
cmd = [
"ffmpeg", "-y", "-hide_banner", "-nostats",
"-ss", f"{start:.3f}",
"-i", str(video),
"-t", f"{duration:.3f}",
"-vf", f"fps={fps:.2f},signalstats,metadata=print:file={metadata_path}",
"-vf", f"fps={fps:.2f},signalstats,metadata=print:file={metadata_name}",
"-f", "null", "-",
]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(cmd, check=True, cwd=metadata_dir, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

# Parse signalstats metadata. Signalstats reports values in the NATIVE
# bit depth of the decoded frame (8-bit → 0-255, 10-bit → 0-1023). We
Expand Down
10 changes: 10 additions & 0 deletions helpers/pack_transcripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@
import sys
from pathlib import Path

# Windows consoles default to cp1252, which raises UnicodeEncodeError on the
# Unicode arrows/em-dashes in the status prints below. Force UTF-8 on stdout/
# stderr so this helper behaves identically on Windows, macOS, and Linux.
# No-op where the stream is already UTF-8 or can't be reconfigured.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8")
except (AttributeError, ValueError):
pass


def format_time(seconds: float) -> str:
"""Format a time in seconds as "NNN.NN" with fixed 6-char width for alignment."""
Expand Down
10 changes: 10 additions & 0 deletions helpers/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@
import sys
from pathlib import Path

# Windows consoles default to cp1252, which raises UnicodeEncodeError on the
# Unicode arrows/em-dashes in the status prints below. Force UTF-8 on stdout/
# stderr so this helper behaves identically on Windows, macOS, and Linux.
# No-op where the stream is already UTF-8 or can't be reconfigured.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8")
except (AttributeError, ValueError):
pass

try:
from grade import get_preset, auto_grade_for_clip # same directory
except Exception:
Expand Down
21 changes: 21 additions & 0 deletions helpers/timeline_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import argparse
import json
import os
import subprocess
import sys
import tempfile
Expand All @@ -31,6 +32,17 @@
from PIL import Image, ImageDraw, ImageFont


# Windows consoles default to cp1252, which raises UnicodeEncodeError when the
# status prints below include a non-ASCII path (accents, emoji, CJK). Force
# UTF-8 on stdout/stderr so this helper behaves identically on Windows, macOS,
# and Linux. No-op where the stream is already UTF-8 or can't be reconfigured.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8")
except (AttributeError, ValueError):
pass


# -------- Frame extraction ---------------------------------------------------


Expand Down Expand Up @@ -151,12 +163,21 @@ def find_silences(words: list[dict], start: float, end: float, threshold: float
# -------- Font loading -------------------------------------------------------


# Windows has no fixed font directory on C:, so derive it from %WINDIR%
# (falls back to the default install path). Consolas mirrors the monospace
# intent of Menlo/DejaVuSansMono; Arial is the sans fallback. Without these,
# every candidate below misses on Windows and load_font drops to PIL's tiny
# default bitmap font, rendering the filmstrip labels nearly illegibly.
_WIN_FONTS = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"

FONT_CANDIDATES = [
"/System/Library/Fonts/Menlo.ttc",
"/System/Library/Fonts/Helvetica.ttc",
"/System/Library/Fonts/SFNSMono.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
str(_WIN_FONTS / "consola.ttf"), # Consolas — Windows monospace
str(_WIN_FONTS / "arial.ttf"), # Arial — Windows sans fallback
]


Expand Down
11 changes: 11 additions & 0 deletions helpers/transcribe.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@
import requests


# Windows consoles default to cp1252, which raises UnicodeEncodeError when the
# status prints below include a non-ASCII video filename (accents, emoji, CJK).
# Force UTF-8 on stdout/stderr so this helper behaves identically on Windows,
# macOS, and Linux. No-op where the stream is already UTF-8 or can't reconfigure.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8")
except (AttributeError, ValueError):
pass


SCRIBE_URL = "https://api.elevenlabs.io/v1/speech-to-text"


Expand Down
10 changes: 10 additions & 0 deletions helpers/transcribe_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@

from transcribe import load_api_key, transcribe_one

# Windows consoles default to cp1252, which raises UnicodeEncodeError on the
# Unicode arrows/em-dashes in the status prints below. Force UTF-8 on stdout/
# stderr so this helper behaves identically on Windows, macOS, and Linux.
# No-op where the stream is already UTF-8 or can't be reconfigured.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8")
except (AttributeError, ValueError):
pass


VIDEO_EXTS = {".mp4", ".MP4", ".mov", ".MOV", ".mkv", ".MKV", ".avi", ".AVI", ".m4v"}

Expand Down
43 changes: 43 additions & 0 deletions tests/test_grade_path.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
r"""Regression test for cross-platform ffmpeg metadata path handling in grade.py.

Bug: `grade.py --analyze` crashed on Windows because the signalstats metadata
filter embedded a temp path (e.g. C:\Users\me\Temp\stats.txt), and the ':' and
'\' in that path collide with ffmpeg filtergraph delimiters, so ffmpeg exited
with an error. Fix: _metadata_filter_target() returns the file's directory (to
use as ffmpeg's cwd) and the bare filename (to put in the filter), so no path
separator or drive colon ever reaches the filtergraph parser. These tests pin
that invariant.
"""
import sys
import unittest
from pathlib import Path

# Import the helper module directly from helpers/.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "helpers"))
import grade # noqa: E402


class MetadataFilterTargetTest(unittest.TestCase):
def test_filter_value_has_no_delimiters(self):
# The bare filename must contain no ':' or path separator — those are
# exactly the chars that break an ffmpeg filtergraph.
_, name = grade._metadata_filter_target(r"C:\Users\me\AppData\Local\Temp\stats.txt")
self.assertEqual(name, "stats.txt")
self.assertNotIn(":", name)
self.assertNotIn("/", name)
self.assertNotIn("\\", name)

def test_cwd_plus_name_roundtrips(self):
# cwd joined with the bare filename must point back at the original file.
original = str(Path("/tmp/sub/stats.txt"))
cwd, name = grade._metadata_filter_target(original)
self.assertEqual(str(Path(cwd) / name), original)

def test_posix_filename_is_bare(self):
cwd, name = grade._metadata_filter_target("/var/folders/xy/stats.txt")
self.assertEqual(name, "stats.txt")
self.assertEqual(cwd, str(Path("/var/folders/xy")))


if __name__ == "__main__":
unittest.main()
49 changes: 49 additions & 0 deletions tests/test_timeline_fonts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
r"""Regression test for cross-platform font selection in timeline_view.py.

Bug: FONT_CANDIDATES listed only macOS and Linux font paths. On Windows every
candidate failed Path.exists(), so load_font fell through to PIL's default
bitmap font (which ignores the requested size), rendering the filmstrip labels
nearly illegibly. Fix: add Windows candidates derived from %WINDIR%\Fonts
(Consolas + Arial). These tests pin the candidate list and the env-derived
font dir, and confirm load_font always returns a usable font — on any host.
"""
import os
import sys
import unittest
from pathlib import Path

HELPERS_DIR = Path(__file__).resolve().parent.parent / "helpers"
sys.path.insert(0, str(HELPERS_DIR))
import timeline_view # noqa: E402


class FontCandidatesTest(unittest.TestCase):
def test_windows_fonts_present(self):
# Both Windows fallbacks must be in the candidate list, under a Fonts dir.
names = [Path(c).name.lower() for c in timeline_view.FONT_CANDIDATES]
self.assertIn("consola.ttf", names)
self.assertIn("arial.ttf", names)
for c in timeline_view.FONT_CANDIDATES:
if Path(c).name.lower() in ("consola.ttf", "arial.ttf"):
self.assertEqual(Path(c).parent.name, "Fonts")

def test_macos_and_linux_fonts_still_present(self):
# The fix must not drop the original POSIX candidates.
joined = " ".join(timeline_view.FONT_CANDIDATES)
self.assertIn("/System/Library/Fonts/Menlo.ttc", joined)
self.assertIn("DejaVuSansMono.ttf", joined)

def test_win_fonts_dir_respects_windir(self):
# The Windows font dir is derived from %WINDIR%, not hardcoded to C:.
windir = os.environ.get("WINDIR", r"C:\Windows")
self.assertEqual(timeline_view._WIN_FONTS, Path(windir) / "Fonts")

def test_load_font_never_raises(self):
# load_font must return a usable font object on any host, falling back
# to PIL's default only when no candidate file exists.
font = timeline_view.load_font(14)
self.assertIsNotNone(font)


if __name__ == "__main__":
unittest.main()