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.
- continuityguard/__init__.py +43 -0
- continuityguard/cli.py +152 -0
- continuityguard/ingest/__init__.py +1 -0
- continuityguard/ingest/ffmpeg.py +295 -0
- continuityguard/py.typed +0 -0
- continuityguard/report/__init__.py +4 -0
- continuityguard/report/json_report.py +52 -0
- continuityguard/report/terminal.py +85 -0
- continuityguard/report/types.py +64 -0
- continuityguard/scan.py +147 -0
- continuityguard/score/__init__.py +2 -0
- continuityguard/score/consistency.py +226 -0
- continuityguard/score/models/NOTICE.md +42 -0
- continuityguard/score/models/mobilenetv2-7.onnx +0 -0
- continuityguard/score/physics.py +170 -0
- continuityguard_cli-0.1.0.dist-info/METADATA +296 -0
- continuityguard_cli-0.1.0.dist-info/RECORD +20 -0
- continuityguard_cli-0.1.0.dist-info/WHEEL +4 -0
- continuityguard_cli-0.1.0.dist-info/entry_points.txt +2 -0
- continuityguard_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -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}")
|
continuityguard/py.typed
ADDED
|
File without changes
|
|
@@ -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)
|