continuityguard-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ """
2
+ ContinuityGuard: a local, zero-network library and CLI that scores
3
+ already-generated AI short-drama video clips for character-consistency
4
+ drift and physics-plausibility issues.
5
+
6
+ This is the Python port of the npm package `continuityguard-cli`
7
+ (https://github.com/RudrenduPaul/ContinuityGuard). It is a genuine,
8
+ independent port -- not a wrapper around the Node binary -- ported module
9
+ for module from the TypeScript source (src/ingest/ffmpeg.ts,
10
+ src/score/consistency.ts, src/score/physics.ts, src/report/*.ts,
11
+ src/cli.ts) and using the exact same bundled ONNX model file
12
+ (mobilenetv2-7.onnx) so both runtimes score the same input the same way.
13
+
14
+ Public API:
15
+
16
+ from continuityguard import scan, ScanReport
17
+
18
+ report = scan("./generated-clips")
19
+ print(report.character_consistency.flagged_shots)
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from .report.types import (
24
+ ConsistencyFlagReport,
25
+ PhysicsFlagReport,
26
+ CharacterConsistencyReport,
27
+ PhysicsPlausibilityReport,
28
+ ScanReport,
29
+ )
30
+ from .scan import scan, scan_async
31
+
32
+ __version__ = "0.1.0"
33
+
34
+ __all__ = [
35
+ "scan",
36
+ "scan_async",
37
+ "ScanReport",
38
+ "ConsistencyFlagReport",
39
+ "PhysicsFlagReport",
40
+ "CharacterConsistencyReport",
41
+ "PhysicsPlausibilityReport",
42
+ "__version__",
43
+ ]
continuityguard/cli.py ADDED
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CLI entry point. Wires the `scan` subcommand: CG01 ingestion -> CG02
4
+ character-consistency scoring -> CG03 physics-plausibility heuristic ->
5
+ CG04 report output (terminal by default, `--json` for machine-readable).
6
+
7
+ Console entry point: `continuityguard scan <directory> [options]`,
8
+ installed via the `continuityguard` console-script defined in
9
+ python/pyproject.toml -- the same command name as the npm CLI's `bin`
10
+ entry, so the two are drop-in equivalents on their respective toolchains.
11
+
12
+ Zero-network guarantee: nothing in this file, or anything it imports from
13
+ continuityguard.ingest or continuityguard.score, makes an outbound network
14
+ call. The only I/O is local filesystem reads/writes and spawning the local
15
+ `ffmpeg` binary via an explicit argument list (see ingest/ffmpeg.py).
16
+
17
+ Ported from src/cli.ts, which uses `commander`; this port uses the stdlib
18
+ `argparse` to avoid a CLI-framework dependency. Flags, defaults, and
19
+ messages are kept as close to the npm CLI's `--help` output and error text
20
+ as argparse's own conventions allow.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import sys
26
+ from pathlib import Path
27
+ from typing import List, Optional
28
+
29
+ from .ingest.ffmpeg import build_missing_ffmpeg_message, check_ffmpeg_available
30
+ from .report.json_report import serialize_report, write_json_report
31
+ from .report.terminal import render_terminal_report
32
+ from .scan import TOOL_VERSION, NoClipsFoundError, scan
33
+
34
+ _PROG = "continuityguard"
35
+
36
+
37
+ def build_parser() -> argparse.ArgumentParser:
38
+ parser = argparse.ArgumentParser(
39
+ prog=_PROG,
40
+ description=(
41
+ "Free, local-first CLI that scores already-generated AI short-drama clips/frames "
42
+ "for character-consistency and physics-plausibility problems. Zero network calls."
43
+ ),
44
+ )
45
+ parser.add_argument(
46
+ "-V", "--version", action="version", version=f"{_PROG} {TOOL_VERSION}"
47
+ )
48
+
49
+ subparsers = parser.add_subparsers(dest="command")
50
+
51
+ scan_parser = subparsers.add_parser(
52
+ "scan",
53
+ help=(
54
+ "scan a directory of generated clips for character-consistency and "
55
+ "physics-plausibility flags"
56
+ ),
57
+ description=(
58
+ "scan a directory of generated clips for character-consistency and "
59
+ "physics-plausibility flags"
60
+ ),
61
+ )
62
+ scan_parser.add_argument("directory", help="directory of video clips to scan")
63
+ scan_parser.add_argument(
64
+ "--json",
65
+ action="store_true",
66
+ default=False,
67
+ help="print the full machine-readable JSON report to stdout instead of a terminal summary",
68
+ )
69
+ scan_parser.add_argument(
70
+ "--fps",
71
+ type=float,
72
+ default=None,
73
+ help="frame sample rate for ingestion (default: 2.3)",
74
+ )
75
+ scan_parser.add_argument(
76
+ "--out",
77
+ default="./continuityguard-report.json",
78
+ help="path to write the JSON report file to",
79
+ )
80
+
81
+ return parser
82
+
83
+
84
+ def run_scan(directory: str, json_output: bool = False, fps: Optional[float] = None, out: str = "./continuityguard-report.json") -> int:
85
+ """
86
+ Runs one scan end to end and returns the process exit code (0 success,
87
+ 1 failure). Mirrors src/cli.ts's `runScan`: every failure path prints a
88
+ clean, actionable message to stderr and returns 1, rather than letting
89
+ a raw traceback / unhandled exception propagate.
90
+ """
91
+ ffmpeg_check = check_ffmpeg_available()
92
+ if not ffmpeg_check.available:
93
+ print(build_missing_ffmpeg_message(), file=sys.stderr)
94
+ return 1
95
+
96
+ target_dir = str(Path(directory).resolve())
97
+
98
+ try:
99
+ report = scan(directory, fps=fps)
100
+ except FileNotFoundError:
101
+ print(f"Directory not found: {target_dir}", file=sys.stderr)
102
+ return 1
103
+ except NotADirectoryError:
104
+ print(f"Not a directory: {target_dir}", file=sys.stderr)
105
+ return 1
106
+ except NoClipsFoundError as error:
107
+ # NoClipsFoundError's own message already carries the supported
108
+ # extensions list; split across two stderr lines to match the
109
+ # TS CLI's two separate console.error calls.
110
+ message = str(error)
111
+ head, _, tail = message.partition(". Supported extensions")
112
+ print(head + ".", file=sys.stderr)
113
+ if tail:
114
+ print("Supported extensions" + tail, file=sys.stderr)
115
+ return 1
116
+ except Exception as error: # noqa: BLE001 -- top-level crash guard, mirrors src/cli.ts's catch-all
117
+ print(f"ContinuityGuard scan failed: {error}", file=sys.stderr)
118
+ return 1
119
+
120
+ if json_output:
121
+ sys.stdout.write(serialize_report(report))
122
+ else:
123
+ # Pass the raw --out value (not pre-resolved) so write_json_report's
124
+ # own traversal check runs against what the caller actually typed --
125
+ # resolving here first would turn every path absolute and silently
126
+ # bypass that check.
127
+ write_json_report(out, report)
128
+ out_path = str(Path(out).resolve())
129
+ print(render_terminal_report(report, out_path))
130
+
131
+ return 0
132
+
133
+
134
+ def run_cli(argv: List[str]) -> int:
135
+ """`argv` follows the sys.argv convention: argv[0] is the program name,
136
+ real arguments start at argv[1]. Returns the process exit code."""
137
+ parser = build_parser()
138
+ args = parser.parse_args(argv[1:])
139
+
140
+ if args.command != "scan":
141
+ parser.print_help()
142
+ return 0
143
+
144
+ return run_scan(args.directory, json_output=args.json, fps=args.fps, out=args.out)
145
+
146
+
147
+ def main() -> None:
148
+ sys.exit(run_cli(sys.argv))
149
+
150
+
151
+ if __name__ == "__main__":
152
+ main()
@@ -0,0 +1 @@
1
+ """CG01 -- clip/frame ingestion. Ported from src/ingest/ffmpeg.ts."""
@@ -0,0 +1,295 @@
1
+ """
2
+ CG01 -- clip/frame ingestion.
3
+
4
+ ContinuityGuard depends on a system `ffmpeg` install rather than bundling a
5
+ static per-platform binary. A static ffmpeg build is materially larger than
6
+ this package's own dependency footprint (commonly 50-80MB+ per platform
7
+ depending on included codecs) and ffmpeg's licensing terms shift between
8
+ LGPL and GPL depending on which codecs/filters are compiled in -- a real
9
+ legal-review cost this project is not taking on. ffmpeg is a near-ubiquitous
10
+ developer tool already (commonly pre-installed, or one `brew install
11
+ ffmpeg` / `apt install ffmpeg` away), so this keeps the package small and
12
+ its own licensing surface simple: ContinuityGuard's code never
13
+ redistributes ffmpeg binaries or inherits their licensing obligations.
14
+
15
+ Security note: every ffmpeg invocation in this module passes an explicit
16
+ argument list to `subprocess` (never `shell=True`, never string
17
+ concatenation into a shell command). Clip paths and directory paths --
18
+ values that can originate from untrusted caller input -- are passed as
19
+ individual argv elements, so shell metacharacters in a filename (spaces,
20
+ `;`, `$()`, backticks, etc.) are never interpreted by a shell; they reach
21
+ ffmpeg as literal argument bytes, exactly as Python's subprocess module
22
+ guarantees for a list-form command with no shell.
23
+
24
+ Ported from src/ingest/ffmpeg.ts.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import shutil
29
+ import subprocess
30
+ import tempfile
31
+ from dataclasses import dataclass, field
32
+ from pathlib import Path
33
+ from typing import List, Optional
34
+
35
+ # Frame sample rate used for CG01 decode -- 2.3 fps, matching the plan's
36
+ # "roughly 2-3 fps sample rate is fine" guidance for shot-level QA sampling.
37
+ DEFAULT_SAMPLE_FPS = 2.3
38
+
39
+ # Frames are decoded to headerless raw RGB24 at a fixed square size rather
40
+ # than PNG. This is a deliberate simplification: it lets both scoring
41
+ # modules (consistency.py, physics.py) read a frame as a fixed-length byte
42
+ # buffer with zero image-decoding dependency, instead of pulling in a PNG
43
+ # parser just to get back to raw pixels a moment later. The fixed 224x224
44
+ # size also matches the embedding model's expected input dimensions (see
45
+ # continuityguard/score/consistency.py), so one decode pass serves both
46
+ # scoring paths.
47
+ FRAME_SIZE = 224
48
+ FRAME_BYTES = FRAME_SIZE * FRAME_SIZE * 3
49
+
50
+ _SUPPORTED_CLIP_EXTENSIONS = {".mp4", ".mov", ".mkv", ".webm", ".avi"}
51
+
52
+ # A crafted/malformed clip must never hang a scan indefinitely (this is the
53
+ # only ffmpeg call in the whole codebase with no bound on wall-clock time --
54
+ # check_ffmpeg_available's `-version` probe already has its own short timeout).
55
+ _DEFAULT_FFMPEG_TIMEOUT_SECONDS = 120.0
56
+
57
+ # Pre-flight cap before handing a file to ffmpeg at all: an oversized clip
58
+ # can drive unbounded temp-disk usage (one frame_%04d.rgb per sampled
59
+ # frame) and CPU time regardless of the decode timeout above.
60
+ _MAX_CLIP_BYTES = 2 * 1024 * 1024 * 1024 # 2 GiB
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class FfmpegCheckResult:
65
+ available: bool
66
+ version: Optional[str] = None
67
+
68
+
69
+ def check_ffmpeg_available() -> FfmpegCheckResult:
70
+ """Checks whether `ffmpeg` is reachable on PATH. Never raises -- callers
71
+ use the boolean result to decide whether to continue or print an
72
+ install command and exit."""
73
+ ffmpeg_path = shutil.which("ffmpeg")
74
+ if ffmpeg_path is None:
75
+ return FfmpegCheckResult(available=False)
76
+ try:
77
+ result = subprocess.run(
78
+ ["ffmpeg", "-version"],
79
+ capture_output=True,
80
+ text=True,
81
+ timeout=10,
82
+ )
83
+ except (OSError, subprocess.TimeoutExpired):
84
+ return FfmpegCheckResult(available=False)
85
+ if result.returncode != 0:
86
+ return FfmpegCheckResult(available=False)
87
+ first_line = result.stdout.split("\n", 1)[0] if result.stdout else ""
88
+ version = None
89
+ prefix = "ffmpeg version "
90
+ if first_line.startswith(prefix):
91
+ version = first_line[len(prefix) :].split(" ", 1)[0]
92
+ return FfmpegCheckResult(available=True, version=version)
93
+
94
+
95
+ def detect_os(platform: Optional[str] = None) -> str:
96
+ import sys
97
+
98
+ plat = platform or sys.platform
99
+ if plat == "darwin":
100
+ return "macos"
101
+ if plat == "win32":
102
+ return "windows"
103
+ if plat.startswith("linux"):
104
+ if shutil.which("apt-get"):
105
+ return "debian"
106
+ if shutil.which("dnf") or shutil.which("yum"):
107
+ return "redhat"
108
+ return "debian"
109
+ return "unknown"
110
+
111
+
112
+ def describe_ffmpeg_install_command(os_name: Optional[str] = None) -> str:
113
+ """Returns the exact, copy-pasteable install command for the detected OS."""
114
+ os_name = os_name or detect_os()
115
+ if os_name == "macos":
116
+ return "brew install ffmpeg"
117
+ if os_name == "debian":
118
+ return "sudo apt update && sudo apt install -y ffmpeg"
119
+ if os_name == "redhat":
120
+ return "sudo dnf install -y ffmpeg"
121
+ if os_name == "windows":
122
+ return "winget install ffmpeg (or: choco install ffmpeg)"
123
+ return "install ffmpeg via your OS package manager, or see https://ffmpeg.org/download.html"
124
+
125
+
126
+ def build_missing_ffmpeg_message(os_name: Optional[str] = None) -> str:
127
+ return "\n".join(
128
+ [
129
+ "ContinuityGuard requires ffmpeg, and it was not found on PATH.",
130
+ f"Install it with: {describe_ffmpeg_install_command(os_name)}",
131
+ "Then re-run your scan. ContinuityGuard never bundles ffmpeg itself --",
132
+ 'see README "Requirements" for why.',
133
+ ]
134
+ )
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class ClipInfo:
139
+ path: str
140
+ name: str
141
+
142
+
143
+ def list_clips(directory: str) -> List[ClipInfo]:
144
+ """Lists clip files (by extension) directly inside a directory, sorted
145
+ for deterministic scan ordering. Does not recurse into subdirectories."""
146
+ directory_path = Path(directory)
147
+ if not directory_path.exists():
148
+ raise FileNotFoundError(f"[Errno 2] No such file or directory: '{directory}'")
149
+ if not directory_path.is_dir():
150
+ raise NotADirectoryError(f"[Errno 20] Not a directory: '{directory}'")
151
+ entries = [
152
+ entry
153
+ for entry in directory_path.iterdir()
154
+ if entry.is_file() and entry.suffix.lower() in _SUPPORTED_CLIP_EXTENSIONS
155
+ ]
156
+ entries.sort(key=lambda entry: entry.name)
157
+ return [ClipInfo(path=str(entry), name=entry.name) for entry in entries]
158
+
159
+
160
+ @dataclass
161
+ class ExtractedFrames:
162
+ clip: ClipInfo
163
+ frame_dir: str
164
+ # Paths to headerless raw RGB24 frame files, FRAME_SIZE x FRAME_SIZE, in
165
+ # temporal order. Read with `read_raw_frame`.
166
+ frame_paths: List[str] = field(default_factory=list)
167
+
168
+
169
+ def extract_frames(
170
+ clip: ClipInfo, fps: Optional[float] = None, work_dir: Optional[str] = None
171
+ ) -> ExtractedFrames:
172
+ """
173
+ Decodes a single clip into sampled raw RGB24 frames via ffmpeg, at
174
+ DEFAULT_SAMPLE_FPS unless overridden. Frames are written to a fresh
175
+ temp directory the caller is responsible for cleaning up (or use
176
+ `extract_frames_from_directory`, which cleans up automatically).
177
+
178
+ If ffmpeg fails (a corrupt file, an unsupported codec, a zero-byte
179
+ file, etc.) and this call created its own temp directory (no
180
+ `work_dir` override was passed in), that now-orphaned temp directory
181
+ is removed before the error is re-raised, rather than left behind on
182
+ disk.
183
+ """
184
+ fps = DEFAULT_SAMPLE_FPS if fps is None else fps
185
+ owns_frame_dir = work_dir is None
186
+ frame_dir = work_dir or tempfile.mkdtemp(prefix="continuityguard-frames-")
187
+
188
+ clip_size = Path(clip.path).stat().st_size
189
+ if clip_size > _MAX_CLIP_BYTES:
190
+ if owns_frame_dir:
191
+ shutil.rmtree(frame_dir, ignore_errors=True)
192
+ raise RuntimeError(
193
+ f'"{clip.name}" is {clip_size / (1024 * 1024):.0f}MB, over the '
194
+ f"{_MAX_CLIP_BYTES / (1024 * 1024 * 1024):.0f}GB per-clip limit -- skipped to "
195
+ "avoid unbounded temp-disk/CPU usage during decode"
196
+ )
197
+
198
+ try:
199
+ _run_ffmpeg(
200
+ [
201
+ "-y",
202
+ "-i",
203
+ clip.path,
204
+ "-vf",
205
+ f"fps={fps},scale={FRAME_SIZE}:{FRAME_SIZE}",
206
+ "-c:v",
207
+ "rawvideo",
208
+ "-pix_fmt",
209
+ "rgb24",
210
+ "-f",
211
+ "image2",
212
+ "-loglevel",
213
+ "error",
214
+ str(Path(frame_dir) / "frame_%04d.rgb"),
215
+ ]
216
+ )
217
+
218
+ frame_paths = sorted(
219
+ str(entry)
220
+ for entry in Path(frame_dir).iterdir()
221
+ if entry.name.startswith("frame_") and entry.name.endswith(".rgb")
222
+ )
223
+ return ExtractedFrames(clip=clip, frame_dir=frame_dir, frame_paths=frame_paths)
224
+ except Exception:
225
+ if owns_frame_dir:
226
+ shutil.rmtree(frame_dir, ignore_errors=True)
227
+ raise
228
+
229
+
230
+ def extract_frames_from_directory(
231
+ directory: str, fps: Optional[float] = None
232
+ ) -> List[ExtractedFrames]:
233
+ """
234
+ Decodes every clip in a directory into sampled frames. Returns one
235
+ ExtractedFrames entry per clip. Callers should call `cleanup_frames`
236
+ when done to remove the temp directories.
237
+
238
+ If ffmpeg fails to decode any single clip (a corrupt file, an
239
+ unsupported codec, a zero-byte file, etc.), this raises a clear error
240
+ naming the offending clip, and first cleans up the temp frame
241
+ directories already created for clips that decoded successfully
242
+ earlier in the loop, so one bad clip in a batch never leaks temp
243
+ storage.
244
+ """
245
+ clips = list_clips(directory)
246
+ results: List[ExtractedFrames] = []
247
+ for clip in clips:
248
+ try:
249
+ results.append(extract_frames(clip, fps=fps))
250
+ except Exception as error:
251
+ cleanup_frames(results)
252
+ raise RuntimeError(f'Failed to decode "{clip.name}" with ffmpeg: {error}') from error
253
+ return results
254
+
255
+
256
+ def read_raw_frame(frame_path: str) -> bytes:
257
+ """Reads a headerless raw RGB24 frame file back into a bytes object of
258
+ length FRAME_BYTES (FRAME_SIZE * FRAME_SIZE * 3, row-major, RGB)."""
259
+ data = Path(frame_path).read_bytes()
260
+ if len(data) != FRAME_BYTES:
261
+ raise ValueError(
262
+ f"Unexpected frame size for {frame_path}: got {len(data)} bytes, "
263
+ f"expected {FRAME_BYTES}"
264
+ )
265
+ return data
266
+
267
+
268
+ def cleanup_frames(extracted: List[ExtractedFrames]) -> None:
269
+ for entry in extracted:
270
+ shutil.rmtree(entry.frame_dir, ignore_errors=True)
271
+
272
+
273
+ def _run_ffmpeg(args: List[str]) -> None:
274
+ """Runs ffmpeg with an explicit argument list -- never a shell string --
275
+ so no filename or path, however untrusted, is ever interpreted as shell
276
+ syntax. Raises RuntimeError with ffmpeg's stderr on a non-zero exit, or
277
+ if the process exceeds the per-clip decode timeout (subprocess.run kills
278
+ and waits for the child automatically in that case)."""
279
+ try:
280
+ result = subprocess.run(
281
+ ["ffmpeg", *args],
282
+ stdin=subprocess.DEVNULL,
283
+ stdout=subprocess.DEVNULL,
284
+ stderr=subprocess.PIPE,
285
+ text=True,
286
+ timeout=_DEFAULT_FFMPEG_TIMEOUT_SECONDS,
287
+ )
288
+ except subprocess.TimeoutExpired as error:
289
+ raise RuntimeError(
290
+ f"ffmpeg did not finish within {_DEFAULT_FFMPEG_TIMEOUT_SECONDS:.0f}s "
291
+ "(likely a malformed/oversized input) and was killed"
292
+ ) from error
293
+ if result.returncode != 0:
294
+ stderr = (result.stderr or "").strip()
295
+ raise RuntimeError(f"ffmpeg exited with code {result.returncode}: {stderr}")
File without changes
@@ -0,0 +1,4 @@
1
+ """CG04 -- report output. Shared report shape (types.py) consumed by both
2
+ the JSON writer (json_report.py) and the terminal writer (terminal.py).
3
+ Ported from src/report/*.ts.
4
+ """
@@ -0,0 +1,52 @@
1
+ """
2
+ CG04 -- machine-readable JSON report output (`--json` mode). Built so a
3
+ QA agent or CI pipeline can parse scan results programmatically, with the
4
+ same "no bare pass/fail" guarantee as the terminal report: every flagged
5
+ shot carries a reason string alongside its numeric score.
6
+
7
+ Ported from src/report/json.ts. Named json_report.py (not json.py) so it
8
+ never shadows the stdlib `json` module it imports.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from dataclasses import asdict
15
+ from pathlib import Path
16
+ from typing import Union
17
+
18
+ from .types import ScanReport
19
+
20
+
21
+ class UnsafeOutputPathError(ValueError):
22
+ pass
23
+
24
+
25
+ # --out is a plain CLI flag today, but this CLI is also meant to be invoked
26
+ # programmatically by agents that may derive the value from less-trusted
27
+ # input. A relative path containing ".." segments can escape the intended
28
+ # output location entirely (--out ../../../etc/cron.d/x) -- reject any
29
+ # --out value that resolves outside the current working directory. An
30
+ # explicit absolute path is still allowed: that's a value the caller
31
+ # typed/passed directly, not one that silently escaped via traversal.
32
+ def _assert_safe_output_path(file_path: Union[str, Path]) -> None:
33
+ file_path = str(file_path)
34
+ if os.path.isabs(file_path):
35
+ return
36
+ cwd = os.path.realpath(os.getcwd())
37
+ resolved = os.path.realpath(os.path.join(cwd, file_path))
38
+ if resolved != cwd and not resolved.startswith(cwd + os.sep):
39
+ raise UnsafeOutputPathError(
40
+ f'--out "{file_path}" resolves outside the current working directory '
41
+ f"({resolved}). Pass an absolute path if you intend to write outside "
42
+ "the working directory."
43
+ )
44
+
45
+
46
+ def serialize_report(report: ScanReport) -> str:
47
+ return json.dumps(asdict(report), indent=2) + "\n"
48
+
49
+
50
+ def write_json_report(path: Union[str, Path], report: ScanReport) -> None:
51
+ _assert_safe_output_path(path)
52
+ Path(path).write_text(serialize_report(report), encoding="utf-8")
@@ -0,0 +1,85 @@
1
+ """
2
+ CG04 -- human-readable terminal report output. Every flagged shot
3
+ includes a timestamp/frame reference, a numeric score, and a
4
+ plain-language reason -- built for a human studio QA reviewer to read
5
+ directly.
6
+
7
+ Ported line-for-line from src/report/terminal.ts so the two CLIs print
8
+ matching output.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import List, Optional
13
+
14
+ from .types import ScanReport
15
+
16
+
17
+ def render_terminal_report(report: ScanReport, json_path: Optional[str] = None) -> str:
18
+ lines: List[str] = []
19
+ lines.append("ContinuityGuard v0.1 -- Local Character-Consistency & Physics-QA Scoring")
20
+ lines.append("")
21
+ lines.append(
22
+ f"Scanning: {report.scanned_directory} ({report.clips_scanned} clips, ffmpeg decode)"
23
+ )
24
+ lines.append("")
25
+
26
+ lines.append("[SCORED] CG01 Clip Ingestion")
27
+ lines.append(
28
+ f" {report.clips_scanned} clips decoded, {report.frames_extracted} frames extracted"
29
+ )
30
+ lines.append("")
31
+
32
+ lines.append("[SCORED] CG02 Character-Consistency Scoring")
33
+ lines.append(
34
+ f" {report.character_consistency.characters_tracked} named characters tracked "
35
+ f"across {report.clips_scanned} clips"
36
+ )
37
+ consistency_flags = report.character_consistency.flagged_shots
38
+ if not consistency_flags:
39
+ lines.append(
40
+ f" 0 shots flagged (below "
41
+ f"{report.character_consistency.similarity_threshold:.2f} cosine threshold)"
42
+ )
43
+ else:
44
+ lines.append(
45
+ f" {len(consistency_flags)} shot(s) flagged: low cross-shot similarity "
46
+ f"(below {report.character_consistency.similarity_threshold:.2f} cosine threshold)"
47
+ )
48
+ for flag in consistency_flags:
49
+ lines.append(
50
+ f' {flag.clip} -- "{flag.character}" similarity '
51
+ f"{flag.similarity_score:.2f} vs. reference ({flag.reference_clip})"
52
+ )
53
+ lines.append(" NOTE: consistency scoring is best-validated on photorealistic content.")
54
+ lines.append(" Accuracy on stylized/anime-adjacent character designs is unverified --")
55
+ lines.append(" treat flags on stylized content as a prompt for human review, not a")
56
+ lines.append(' confirmed defect. See README "Known limitations."')
57
+ lines.append("")
58
+
59
+ lines.append("[SCORED] CG03 Physics-Plausibility Heuristic")
60
+ physics_flags = report.physics_plausibility.flagged_shots
61
+ if not physics_flags:
62
+ lines.append(" 0 shots flagged: no frame-to-frame motion discontinuity above threshold")
63
+ else:
64
+ lines.append(
65
+ f" {len(physics_flags)} shot(s) flagged: frame-to-frame motion discontinuity "
66
+ f"above threshold"
67
+ )
68
+ for flag in physics_flags:
69
+ lines.append(
70
+ f" {flag.clip} @ frame {flag.frame_index_a}-{flag.frame_index_b} -- "
71
+ f"discontinuity {flag.discontinuity_ratio}x local baseline"
72
+ )
73
+ lines.append(" This is a heuristic proxy, not a physics simulator. It flags shots for")
74
+ lines.append(' human review. It does not "detect" a physics violation.')
75
+ lines.append("")
76
+
77
+ if json_path:
78
+ lines.append(f"Report written to {json_path}")
79
+ lines.append("Human-readable summary above. Use --json for the full structured report.")
80
+ lines.append(
81
+ f"Scan time: {report.scan_duration_seconds:.1f}s. Nothing left this machine. "
82
+ f"No network calls were made."
83
+ )
84
+
85
+ return "\n".join(lines)