Skip to content

Commit db18e59

Browse files
committed
feat: OpenAPI spec, integration tests, Premiere state caching (Phase 3)
OpenAPI Spec (opencut/openapi.py): - generate_openapi_spec(app) introspects Flask url_map + view docstrings - Maps endpoints to dataclass schemas from schemas.py for typed responses - GET /openapi.json serves the live spec - 155 lines, zero external dependencies Integration Tests (tests/test_integration.py): - 25 Flask test client tests across 10 test classes - Covers: /health, /system/update-check, /system/dependencies, /openapi.json, CSRF protection (403 without/with bogus token), /search/footage validation, /deliverables validation, /nlp/command validation, /settings/llm masking, /timeline/batch-rename + smart-bins validation - conftest.py with app/client/csrf_token fixtures - Total test count: 128 (103 unit + 25 integration) Premiere State Caching: - CEP: _pproCache with 8s TTL for sequence info + project items, invalidated on tab switch, avoids redundant evalScript() calls - UXP: PProBridge.getSequenceInfo() cached with 8s TTL, invalidateCache() called on tab switch
1 parent 6cb7094 commit db18e59

6 files changed

Lines changed: 649 additions & 2 deletions

File tree

extension/com.opencut.panel/client/main.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@
5252
var repeatCutsData = null; // repeat-detect cuts data
5353
var chaptersData = null; // generated chapters
5454

55+
// ---- Premiere Pro state cache (reduces evalScript round-trips) ----
56+
var _pproCache = { seq: null, clips: null, bins: null, ts: 0, ttl: 8000 };
57+
5558
// ============================================================
5659
// CUSTOM DROPDOWN SYSTEM - Inline Panel Dropdowns
5760
// ============================================================
@@ -1379,6 +1382,10 @@
13791382
if (el.contentTitle) {
13801383
el.contentTitle.textContent = this.getAttribute("title") || target;
13811384
}
1385+
// Invalidate Premiere state cache on tab switch
1386+
_pproCache.seq = null;
1387+
_pproCache.clips = null;
1388+
_pproCache.bins = null;
13821389
// Check sub-tab overflow after tab switch
13831390
checkSubTabOverflow();
13841391
// Load settings info on first visit
@@ -5859,10 +5866,22 @@
58595866

58605867
function loadProjectItems() {
58615868
if (!inPremiere) { showAlert("Premiere Pro connection required."); return; }
5869+
// Return cached project media if still fresh
5870+
if (_pproCache.clips && (Date.now() - _pproCache.ts < _pproCache.ttl)) {
5871+
renameItemsData = _pproCache.clips;
5872+
renderRenameItems();
5873+
var applyBtn = document.getElementById("applyRenamePatternBtn");
5874+
var renameBtn = document.getElementById("renameAllBtn");
5875+
if (applyBtn) applyBtn.disabled = !renameItemsData.length;
5876+
if (renameBtn) renameBtn.disabled = !renameItemsData.length;
5877+
return;
5878+
}
58625879
cs.evalScript('getAllProjectMedia()', function (result) {
58635880
try {
58645881
var items = JSON.parse(result);
58655882
renameItemsData = items || [];
5883+
_pproCache.clips = renameItemsData;
5884+
_pproCache.ts = Date.now();
58665885
renderRenameItems();
58675886
var applyBtn = document.getElementById("applyRenamePatternBtn");
58685887
var renameBtn = document.getElementById("renameAllBtn");
@@ -6159,9 +6178,24 @@
61596178

61606179
function loadSeqInfo() {
61616180
if (!inPremiere) { showAlert("Premiere Pro connection required."); return; }
6181+
// Return cached sequence info if still fresh
6182+
if (_pproCache.seq && (Date.now() - _pproCache.ts < _pproCache.ttl)) {
6183+
var cached = _pproCache.seq;
6184+
sequenceInfo = cached;
6185+
var statusEl = document.getElementById("seqInfoStatus");
6186+
if (statusEl) {
6187+
statusEl.textContent = "Loaded: " + (cached.name || "Unknown") + " — " + (cached.clip_count || 0) + " clips (cached)";
6188+
statusEl.classList.remove("hidden");
6189+
}
6190+
var btns = ["genVfxSheetBtn","genAdrListBtn","genMusicCueBtn","genAssetListBtn"];
6191+
btns.forEach(function(id) { var b = document.getElementById(id); if (b) b.disabled = false; });
6192+
return;
6193+
}
61626194
cs.evalScript('ocGetSequenceInfo()', function (result) {
61636195
try {
61646196
sequenceInfo = JSON.parse(result);
6197+
_pproCache.seq = sequenceInfo;
6198+
_pproCache.ts = Date.now();
61656199
var statusEl = document.getElementById("seqInfoStatus");
61666200
if (statusEl) {
61676201
statusEl.textContent = "Loaded: " + (sequenceInfo.name || "Unknown") + " — " + (sequenceInfo.clip_count || 0) + " clips";

extension/com.opencut.uxp/main.js

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ let elapsedSec = 0;
4949
let lastCuts = null; // cuts array from last silence/filler run
5050
let lastMarkers = null; // marker array from last beat detection
5151

52+
// ---- Premiere Pro state cache (reduces UXP API round-trips) ----
53+
const _pproCache = { seq: null, ts: 0 };
54+
const PPRO_CACHE_TTL = 8000; // 8 seconds
55+
5256
// ─────────────────────────────────────────────────────────────
5357
// PProBridge — gracefully degrades when UXP module unavailable
5458
// ─────────────────────────────────────────────────────────────
@@ -88,13 +92,18 @@ const PProBridge = (() => {
8892

8993
/**
9094
* Returns basic info about the active sequence as a plain object.
95+
* Results are cached for PPRO_CACHE_TTL ms to reduce UXP API round-trips.
9196
*/
9297
async function getSequenceInfo() {
98+
// Return cached data if still fresh
99+
if (_pproCache.seq && (Date.now() - _pproCache.ts < PPRO_CACHE_TTL)) {
100+
return _pproCache.seq;
101+
}
93102
const seq = await getActiveSequence();
94103
if (!seq) return null;
95104
try {
96105
const settings = await seq.getSettings();
97-
return {
106+
const info = {
98107
name: await seq.getName(),
99108
duration: await seq.getEnd(),
100109
framerate: settings ? settings.videoFrameRate : "unknown",
@@ -103,12 +112,22 @@ const PProBridge = (() => {
103112
audioTracks: (await seq.getAudioTrackList())?.length ?? "unknown",
104113
videoTracks: (await seq.getVideoTrackList())?.length ?? "unknown",
105114
};
115+
_pproCache.seq = info;
116+
_pproCache.ts = Date.now();
117+
return info;
106118
} catch (e) {
107119
console.warn("[PProBridge] getSequenceInfo failed:", e.message);
108120
return null;
109121
}
110122
}
111123

124+
/**
125+
* Invalidates the sequence info cache, forcing a fresh UXP API call next time.
126+
*/
127+
function invalidateCache() {
128+
_pproCache.seq = null;
129+
}
130+
112131
/**
113132
* Adds an array of sequence markers.
114133
* @param {Array<{time: number, label: string, color: string}>} markers
@@ -168,7 +187,7 @@ const PProBridge = (() => {
168187
return map[name.toLowerCase()] ?? 1;
169188
}
170189

171-
return { init, available: () => available, getActiveSequence, getSequenceInfo, addMarkers, applyCuts };
190+
return { init, available: () => available, getActiveSequence, getSequenceInfo, addMarkers, applyCuts, invalidateCache };
172191
})();
173192

174193
// ─────────────────────────────────────────────────────────────
@@ -322,6 +341,8 @@ const JobPoller = (() => {
322341
const UIController = (() => {
323342
// ── Tab switching ──
324343
function switchTab(tabId) {
344+
// Invalidate Premiere state cache on tab switch
345+
PProBridge.invalidateCache();
325346
document.querySelectorAll(".oc-tab").forEach(btn => {
326347
const active = btn.dataset.tab === tabId;
327348
btn.classList.toggle("active", active);

opencut/openapi.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""
2+
OpenCut OpenAPI Spec Generator
3+
4+
Introspects the Flask app's url_map to produce an OpenAPI 3.0.x JSON spec.
5+
Schema classes from opencut.schemas are mapped to known endpoints for
6+
response definitions.
7+
"""
8+
9+
from dataclasses import fields as dc_fields
10+
from typing import Any, Dict, List, Optional, get_args, get_origin
11+
12+
from opencut import __version__
13+
from opencut.schemas import (
14+
AutoZoomResult,
15+
BeatMarkersResult,
16+
ChaptersResult,
17+
ColorMatchResult,
18+
DeliverableResult,
19+
ExportMarkersResult,
20+
IndexResult,
21+
JobResponse,
22+
LoudnessMatchResult,
23+
MulticamResult,
24+
RepeatDetectResult,
25+
SearchResult,
26+
SilenceResult,
27+
UpdateCheckResult,
28+
)
29+
30+
# ---------------------------------------------------------------------------
31+
# Endpoint -> schema mapping (known response schemas)
32+
# ---------------------------------------------------------------------------
33+
_ENDPOINT_SCHEMAS: Dict[str, type] = {
34+
"/health": None, # ad-hoc dict
35+
"/system/update-check": UpdateCheckResult,
36+
"/search/footage": SearchResult,
37+
"/search/index": IndexResult,
38+
"/deliverables/vfx-sheet": DeliverableResult,
39+
"/deliverables/adr-list": DeliverableResult,
40+
"/deliverables/music-cue-sheet": DeliverableResult,
41+
"/deliverables/asset-list": DeliverableResult,
42+
"/timeline/export-from-markers": ExportMarkersResult,
43+
"/silence": SilenceResult,
44+
"/audio/loudness-match": LoudnessMatchResult,
45+
"/audio/beat-markers": BeatMarkersResult,
46+
"/video/color-match": ColorMatchResult,
47+
"/video/auto-zoom": AutoZoomResult,
48+
"/video/multicam-cuts": MulticamResult,
49+
"/captions/chapters": ChaptersResult,
50+
"/captions/repeat-detect": RepeatDetectResult,
51+
}
52+
53+
# Endpoints that return a JobResponse (async job-based routes)
54+
_JOB_ENDPOINTS = {
55+
"/silence", "/search/index", "/timeline/export-from-markers",
56+
"/install-whisper", "/whisper/reinstall",
57+
"/video/color-match", "/video/auto-zoom", "/video/multicam-cuts",
58+
"/audio/loudness-match", "/audio/beat-markers",
59+
"/captions/chapters", "/captions/repeat-detect",
60+
}
61+
62+
# Methods to skip (HEAD is auto-added by Flask; OPTIONS for CORS)
63+
_SKIP_METHODS = {"HEAD", "OPTIONS"}
64+
65+
66+
def _python_type_to_json(tp) -> dict:
67+
"""Convert a Python type annotation to a JSON Schema fragment."""
68+
origin = get_origin(tp)
69+
70+
if tp is str or tp is Optional[str]:
71+
return {"type": "string"}
72+
if tp is int:
73+
return {"type": "integer"}
74+
if tp is float:
75+
return {"type": "number"}
76+
if tp is bool:
77+
return {"type": "boolean"}
78+
if tp is dict or tp is Dict or origin is dict:
79+
return {"type": "object"}
80+
if origin is list or origin is List:
81+
args = get_args(tp)
82+
if args:
83+
return {"type": "array", "items": _python_type_to_json(args[0])}
84+
return {"type": "array", "items": {}}
85+
if tp is list:
86+
return {"type": "array", "items": {}}
87+
if tp is type(None):
88+
return {"type": "string", "nullable": True}
89+
# Optional[X] -> X with nullable
90+
if origin is type(None) or str(tp).startswith("typing.Optional"):
91+
args = get_args(tp)
92+
if args:
93+
schema = _python_type_to_json(args[0])
94+
schema["nullable"] = True
95+
return schema
96+
return {"type": "string"}
97+
98+
99+
def _dataclass_to_schema(cls) -> dict:
100+
"""Convert a dataclass to an OpenAPI schema object."""
101+
props = {}
102+
for f in dc_fields(cls):
103+
props[f.name] = _python_type_to_json(f.type)
104+
return {
105+
"type": "object",
106+
"properties": props,
107+
}
108+
109+
110+
def generate_openapi_spec(app) -> dict:
111+
"""Build an OpenAPI 3.0.3 spec dict from a Flask app's url_map."""
112+
paths: Dict[str, dict] = {}
113+
114+
for rule in app.url_map.iter_rules():
115+
path = rule.rule
116+
# Skip static endpoint
117+
if path.startswith("/static"):
118+
continue
119+
120+
methods = sorted(rule.methods - _SKIP_METHODS)
121+
if not methods:
122+
continue
123+
124+
# Look up the view function for docstring extraction
125+
view_func = app.view_functions.get(rule.endpoint)
126+
docstring = (view_func.__doc__ or "").strip() if view_func else ""
127+
128+
path_item = paths.setdefault(path, {})
129+
130+
for method in methods:
131+
operation: Dict[str, Any] = {
132+
"summary": docstring.split("\n")[0] if docstring else rule.endpoint,
133+
"operationId": f"{rule.endpoint}_{method.lower()}",
134+
"tags": [rule.endpoint.split(".")[0] if "." in rule.endpoint else "default"],
135+
"responses": {},
136+
}
137+
138+
if docstring:
139+
operation["description"] = docstring
140+
141+
# Build response schema
142+
schema_cls = _ENDPOINT_SCHEMAS.get(path)
143+
if schema_cls is not None:
144+
response_schema = _dataclass_to_schema(schema_cls)
145+
elif path in _JOB_ENDPOINTS and method == "POST":
146+
response_schema = _dataclass_to_schema(JobResponse)
147+
else:
148+
response_schema = {"type": "object"}
149+
150+
operation["responses"]["200"] = {
151+
"description": "Successful response",
152+
"content": {
153+
"application/json": {"schema": response_schema}
154+
},
155+
}
156+
157+
# Add 400/403 for POST endpoints
158+
if method == "POST":
159+
operation["responses"]["400"] = {
160+
"description": "Validation error",
161+
"content": {
162+
"application/json": {
163+
"schema": {
164+
"type": "object",
165+
"properties": {"error": {"type": "string"}},
166+
}
167+
}
168+
},
169+
}
170+
operation["responses"]["403"] = {
171+
"description": "Missing or invalid CSRF token",
172+
"content": {
173+
"application/json": {
174+
"schema": {
175+
"type": "object",
176+
"properties": {"error": {"type": "string"}},
177+
}
178+
}
179+
},
180+
}
181+
182+
path_item[method.lower()] = operation
183+
184+
return {
185+
"openapi": "3.0.3",
186+
"info": {
187+
"title": "OpenCut API",
188+
"description": "Premiere Pro video editing automation backend",
189+
"version": __version__,
190+
},
191+
"servers": [{"url": "http://127.0.0.1:5679"}],
192+
"paths": paths,
193+
}

opencut/routes/system.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1234,6 +1234,15 @@ def llm_test():
12341234
_UPDATE_CACHE_TTL = 3600 # 1 hour
12351235

12361236

1237+
@system_bp.route("/openapi.json", methods=["GET"])
1238+
def openapi_spec():
1239+
"""Return the OpenAPI 3.0 specification for this server."""
1240+
from flask import current_app
1241+
1242+
from opencut.openapi import generate_openapi_spec
1243+
return jsonify(generate_openapi_spec(current_app))
1244+
1245+
12371246
@system_bp.route("/system/update-check", methods=["GET"])
12381247
def check_for_update():
12391248
"""Check GitHub for a newer release. Cached for 1 hour."""

tests/conftest.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""
2+
Shared pytest fixtures for OpenCut integration tests.
3+
"""
4+
5+
import pytest
6+
7+
8+
@pytest.fixture
9+
def app():
10+
"""Create a Flask app instance configured for testing."""
11+
from opencut.server import app as flask_app
12+
flask_app.config["TESTING"] = True
13+
return flask_app
14+
15+
16+
@pytest.fixture
17+
def client(app):
18+
"""Flask test client -- no real network, no subprocess needed."""
19+
return app.test_client()
20+
21+
22+
@pytest.fixture
23+
def csrf_token(client):
24+
"""Fetch a valid CSRF token from the /health endpoint."""
25+
resp = client.get("/health")
26+
data = resp.get_json()
27+
return data.get("csrf_token", "")
28+
29+
30+
def csrf_headers(token):
31+
"""Build headers dict with CSRF token and JSON content type."""
32+
return {
33+
"X-OpenCut-Token": token,
34+
"Content-Type": "application/json",
35+
}

0 commit comments

Comments
 (0)