Skip to content

Commit 402de86

Browse files
committed
fix: batch 29 — remaining audit fixes for routes, MCP, settings
Route crashes/logic errors: - video.py auto-zoom: probe.get() on non-dict → get_video_info(), import subprocess as _sp2 → use module-level _sp, keyframe result dict unpacking (generate_zoom_keyframes returns dict not list) - audio.py loudness-match: batch_loudness_match returns list not dict, result.get("outputs") always returned empty — handle list directly - settings.py: 6 POST routes used get_json(silent=True) which silently converted malformed JSON to {} — changed to force=True - captions.py chapters: missing LLM provider allowlist, segments type+size validation (max 50000) - video.py multicam: segments type+size validation, diarization file 50 MB size cap before json.load Security: - MCP server: validate files[] array items for path traversal, reject UNC paths (\, //) in filepath validation
1 parent 33d6f70 commit 402de86

6 files changed

Lines changed: 80 additions & 23 deletions

File tree

CLAUDE.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@
120120
- Lint: `ruff check opencut/` — codebase is fully clean, pre-commit enforces on every commit
121121

122122
## Version
123-
- Current: **v1.5.0**
123+
- Current: **v1.5.1**
124124
- All version strings: `pyproject.toml`, `__init__.py`, `CSXS/manifest.xml` (ExtensionBundleVersion + Version), `com.opencut.uxp/manifest.json`, `com.opencut.uxp/main.js` (VERSION const), `index.html` version display, README badge
125125
- Use `python scripts/sync_version.py --set X.Y.Z` to update all at once (also manually update UXP manifest.json and UXP main.js — not yet covered by sync script)
126126

@@ -708,3 +708,28 @@ enhance = ["resemble-enhance>=0.0.1"]
708708

709709
### Keep As-Is (Already Best-in-Class)
710710
- faster-whisper (transcription engine), WhisperX (alignment), Real-ESRGAN (upscaling), InsightFace (face swap), auto-editor (auto-editing), pedalboard (audio effects), pyannote.audio (diarization — update to v4.0.4)
711+
712+
## v1.5.1 Batch 28 Bug Fixes
713+
- **UXP panel 5 P0 bugs** — entire panel was non-functional: CSRF header X-CSRF-Token→X-OpenCut-Token, fetchCsrf /csrf→/health csrf_token field, job poll /jobs/{id}→/status/{id}, cancel DELETE→POST /cancel/{id}, LLM settings BackendClient.fetch()→.get()
714+
- **chapter_gen JSON regex** — non-greedy `\[.*?\]` truncated multi-element LLM arrays; changed to greedy `\[\s*\{[\s\S]*\}\s*\]` (same class as highlights batch 5)
715+
- **nlp_command** — greedy JSON regex for nested params, route allowlist on LLM output, word-boundary keyword matching (`\b` regex prevents "um" matching "volume"), float coercion safety on confidence, removed unused `string` import
716+
- **color_match** — VideoWriter.isOpened() check, on_progress(100) at completion, use consolidated run_ffmpeg helper, fix YCrCb channel labels (OpenCV order is Y,Cr,Cb not Y,Cb,Cr)
717+
- **auto_zoom** — VideoCapture try/finally, easing no-op fix (_ease(1.0,mode) always=1.0 → proper fractional blending), negative fps guard
718+
- **loudness_match** — use consolidated helper, pass 1 returncode check, validate measured values as floats before FFmpeg filter interpolation, clamp target_lufs [-70,0] and true_peak [-10,0]
719+
- **footage_search** — Windows LK_NBLCK→LK_LOCK (blocking), write 1 byte before lock, explicit msvcrt.LK_UNLCK before close
720+
- **multicam** — float coercion on start/end via .get(), min_cut_duration clamp
721+
- **deliverables** — int(fps)→int(round(fps)) for 29.97fps timecodes
722+
- **checks.py** — all check functions return bool consistently (new ones returned Tuple, making `if check_X():` always truthy)
723+
- **user_data.py** — removed lock eviction that could delete in-use locks (re-introduced bug from batch 11/25)
724+
- **CLI** — chapters uses dict.get() not attribute access, repeat-detect unpacks dict result, search index/query import from footage_search (was non-existent .core.search)
725+
- **nlp route** — guard parse_command() returning None, LLM provider allowlist
726+
- **timeline route** — safe_float instead of float() on SRT segments
727+
- **ExtendScript** — smart bins "video" type matches AV files (hasVid, not hasVid&&!hasAud), temp SRT cleanup after caption import
728+
729+
## v1.5.1 Batch 29 Bug Fixes
730+
- **video.py auto-zoom crash**`probe.get("width")` on non-dict probe object; replaced with `get_video_info()` from helpers.py. `import subprocess as _sp2` → use module-level `_sp`. Keyframe result dict unpacking fixed (`keyframes.get("keyframes", [])` not raw list check).
731+
- **audio.py loudness-match**`batch_loudness_match()` returns a list, not dict; `result.get("outputs")` always returned empty. Fixed to handle list directly.
732+
- **settings.py 6 POST routes**`request.get_json(silent=True)``force=True` (malformed JSON silently became {}, returning success without changes)
733+
- **captions.py chapters** — missing LLM provider allowlist, missing segments list type+size validation (max 50000)
734+
- **video.py multicam** — missing segments list type+size validation, diarization file 50 MB size cap before JSON.load
735+
- **MCP server**`files` array items validated for path traversal, UNC path (`\\` and `//`) rejection in filepath validation

opencut/mcp_server.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,8 @@ def _validate_mcp_filepath(args, key="filepath"):
354354
return False
355355
if ".." in path or "\x00" in path:
356356
return False
357+
if path.startswith("\\\\") or path.startswith("//"):
358+
return False # Reject UNC paths
357359
return True
358360

359361

@@ -362,11 +364,18 @@ def handle_tool_call(tool_name, arguments):
362364
if tool_name not in _TOOL_ROUTES:
363365
return {"error": f"Unknown tool: {tool_name}"}
364366

365-
# Validate filepath arguments at MCP layer
367+
# Validate filepath arguments at MCP layer (scalar keys)
366368
for key in ("filepath", "style_image", "voice_ref", "file", "source", "reference"):
367369
if key in arguments and not _validate_mcp_filepath(arguments, key):
368370
return {"error": f"Invalid {key}: path traversal or null bytes detected"}
369371

372+
# Validate filepath arrays (e.g. "files" in index_footage / loudness_match)
373+
for key in ("files",):
374+
if key in arguments and isinstance(arguments[key], list):
375+
for i, item in enumerate(arguments[key]):
376+
if not isinstance(item, str) or ".." in item or "\x00" in item or item.startswith("\\\\") or item.startswith("//"):
377+
return {"error": f"Invalid path in {key}[{i}]: path traversal or UNC path detected"}
378+
370379
method, path = _TOOL_ROUTES[tool_name]
371380

372381
# Handle special routing

opencut/routes/audio.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2157,10 +2157,12 @@ def _on_progress(pct, msg):
21572157
on_progress=_on_progress,
21582158
)
21592159

2160-
outputs = result.get("outputs", []) if isinstance(result, dict) else []
2160+
# batch_loudness_match returns a list, not a dict
2161+
outputs = result if isinstance(result, list) else result.get("outputs", []) if isinstance(result, dict) else []
2162+
ok_count = sum(1 for r in outputs if isinstance(r, dict) and r.get("job_ok")) if outputs else 0
21612163
_update_job(
21622164
job_id, status="complete", progress=100,
2163-
message=f"Loudness-matched {len(outputs)} files to {target_lufs:.1f} LUFS",
2165+
message=f"Loudness-matched {ok_count}/{len(outputs)} files to {target_lufs:.1f} LUFS",
21642166
result={"outputs": outputs},
21652167
)
21662168
except Exception as e:

opencut/routes/captions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,11 +1396,20 @@ def captions_chapters():
13961396
filepath = data.get("file", "").strip()
13971397
segments = data.get("segments", None)
13981398
llm_provider = data.get("llm_provider", "ollama")
1399+
if llm_provider not in ("ollama", "openai", "anthropic"):
1400+
llm_provider = "ollama"
13991401
llm_model = data.get("llm_model", "llama3")
14001402
api_key = data.get("api_key", "")
14011403
max_chapters = safe_int(data.get("max_chapters", 15), 15, min_val=1, max_val=100)
14021404
transcribe_model = data.get("model", "base")
14031405

1406+
# Validate segments if provided directly
1407+
if segments is not None:
1408+
if not isinstance(segments, list):
1409+
return jsonify({"error": "segments must be a list"}), 400
1410+
if len(segments) > 50000:
1411+
return jsonify({"error": "Too many segments (max 50000)"}), 400
1412+
14041413
if filepath:
14051414
try:
14061415
filepath = validate_filepath(filepath)

opencut/routes/settings.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ def save_llm_settings_route():
284284
from ..user_data import load_llm_settings, save_llm_settings
285285
except ImportError:
286286
from opencut.user_data import load_llm_settings, save_llm_settings
287-
data = request.get_json(silent=True) or {}
287+
data = request.get_json(force=True)
288288
current = load_llm_settings()
289289
# Don't overwrite key if masked value sent back
290290
if data.get("api_key", "").startswith("***"):
@@ -314,7 +314,7 @@ def save_footage_index_config_route():
314314
from ..user_data import load_footage_index_config, save_footage_index_config
315315
except ImportError:
316316
from opencut.user_data import load_footage_index_config, save_footage_index_config
317-
data = request.get_json(silent=True) or {}
317+
data = request.get_json(force=True)
318318
config = load_footage_index_config()
319319
config.update({k: v for k, v in data.items() if k in config})
320320
save_footage_index_config(config)
@@ -341,7 +341,7 @@ def save_loudness_target_route():
341341
from ..user_data import load_loudness_target, save_loudness_target
342342
except ImportError:
343343
from opencut.user_data import load_loudness_target, save_loudness_target
344-
data = request.get_json(silent=True) or {}
344+
data = request.get_json(force=True)
345345
settings = load_loudness_target()
346346
settings.update({k: v for k, v in data.items() if k in settings})
347347
save_loudness_target(settings)
@@ -368,7 +368,7 @@ def save_multicam_config_route():
368368
from ..user_data import load_multicam_config, save_multicam_config
369369
except ImportError:
370370
from opencut.user_data import load_multicam_config, save_multicam_config
371-
data = request.get_json(silent=True) or {}
371+
data = request.get_json(force=True)
372372
config = load_multicam_config()
373373
config.update({k: v for k, v in data.items() if k in config})
374374
save_multicam_config(config)
@@ -395,7 +395,7 @@ def save_auto_zoom_presets_route():
395395
from ..user_data import load_auto_zoom_presets, save_auto_zoom_presets
396396
except ImportError:
397397
from opencut.user_data import load_auto_zoom_presets, save_auto_zoom_presets
398-
data = request.get_json(silent=True) or {}
398+
data = request.get_json(force=True)
399399
presets = load_auto_zoom_presets()
400400
presets.update({k: v for k, v in data.items() if k in presets})
401401
save_auto_zoom_presets(presets)
@@ -422,7 +422,7 @@ def save_chapter_defaults_route():
422422
from ..user_data import load_chapter_defaults, save_chapter_defaults
423423
except ImportError:
424424
from opencut.user_data import load_chapter_defaults, save_chapter_defaults
425-
data = request.get_json(silent=True) or {}
425+
data = request.get_json(force=True)
426426
defaults = load_chapter_defaults()
427427
defaults.update({k: v for k, v in data.items() if k in defaults})
428428
save_chapter_defaults(defaults)

opencut/routes/video.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3921,18 +3921,20 @@ def _on_progress(pct, msg):
39213921
out_path = os.path.join(output_dir, f"{base_name}_autozoom{ext}")
39223922

39233923
# Build FFmpeg zoompan filter from keyframes
3924-
if keyframes:
3925-
import subprocess as _sp2
3924+
kf_data = keyframes.get("keyframes", []) if isinstance(keyframes, dict) else keyframes if isinstance(keyframes, list) else []
3925+
if kf_data:
39263926
# zoompan: z='zoom_expr':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'
39273927
zoom_val = zoom_amount
3928-
# Get source dimensions
3928+
# Get source dimensions via probe
3929+
src_w, src_h = 1920, 1080
39293930
try:
3930-
from opencut.utils.media import probe as _probe_media
3931-
except ImportError:
3932-
_probe_media = None
3933-
probe = _probe_media(filepath) if _probe_media else None
3934-
src_w = probe.get("width", 1920) if probe else 1920
3935-
src_h = probe.get("height", 1080) if probe else 1080
3931+
from opencut.helpers import get_video_info
3932+
info = get_video_info(filepath)
3933+
if info and info.get("width"):
3934+
src_w = int(info["width"])
3935+
src_h = int(info["height"])
3936+
except Exception:
3937+
pass
39363938
zoompan_filter = (
39373939
f"zoompan=z='min(zoom+0.0015,{zoom_val})'"
39383940
f":x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
@@ -3944,13 +3946,13 @@ def _on_progress(pct, msg):
39443946
"-c:a", "copy",
39453947
out_path,
39463948
]
3947-
result = _sp2.run(cmd, capture_output=True, timeout=600)
3948-
if result.returncode == 0:
3949+
ffmpeg_result = _sp.run(cmd, capture_output=True, timeout=600)
3950+
if ffmpeg_result.returncode == 0:
39493951
output_path = out_path
39503952
else:
3951-
logger.warning("FFmpeg auto-zoom failed: %s", result.stderr.decode(errors="replace")[:200])
3953+
logger.warning("FFmpeg auto-zoom failed: %s", ffmpeg_result.stderr.decode(errors="replace")[:200])
39523954

3953-
kf_list = keyframes if isinstance(keyframes, list) else []
3955+
kf_list = keyframes.get("keyframes", []) if isinstance(keyframes, dict) else keyframes if isinstance(keyframes, list) else []
39543956
result_dict = {"keyframes": kf_list}
39553957
if output_path:
39563958
result_dict["output"] = output_path
@@ -3985,6 +3987,13 @@ def video_multicam_cuts():
39853987
speaker_map = data.get("speaker_map", None)
39863988
min_cut_duration = safe_float(data.get("min_cut_duration", 1.0), 1.0, min_val=0.1, max_val=60.0)
39873989

3990+
# Validate segments if provided directly
3991+
if segments is not None:
3992+
if not isinstance(segments, list):
3993+
return jsonify({"error": "segments must be a list"}), 400
3994+
if len(segments) > 50000:
3995+
return jsonify({"error": "Too many segments (max 50000)"}), 400
3996+
39883997
# Need either segments or a diarization file
39893998
if not segments and not diarization_file:
39903999
return jsonify({"error": "diarization_file or segments required"}), 400
@@ -3999,6 +4008,9 @@ def video_multicam_cuts():
39994008
effective_segments = segments
40004009
if effective_segments is None and diarization_file:
40014010
try:
4011+
file_size = os.path.getsize(diarization_file)
4012+
if file_size > 50_000_000:
4013+
return jsonify({"error": "Diarization file too large (max 50 MB)"}), 400
40024014
import json as _json
40034015
with open(diarization_file, encoding="utf-8") as _f:
40044016
effective_segments = _json.load(_f)

0 commit comments

Comments
 (0)