Skip to content

Commit ca53859

Browse files
committed
v1.9.28: Operation Journal + one-click rollback
Every OpenCut action that writes to your Premiere project is now recorded in a persistent SQLite journal at ~/.opencut/journal.db, with a new "Operation Journal" card in Settings that lists recent operations and offers one-click revert for the actions that support it. This is the trust feature — you can finally commit to automated edits knowing you can take them back without relying on Premiere's native undo stack. Currently instrumented forward operations: - Add sequence markers (e.g. beat markers from audio/beats) - Batch rename project items - Import sequence from XML (silence remove, full pipeline, etc.) - Import overlay video Recorded-but-not-auto-revertible (for history visibility): - Import captions / create smart bins — flagged in the UI with a clear "No auto-revert" pill rather than silently omitted. Backend: - New opencut/journal.py: SQLite store with VALID_ACTIONS + REVERTIBLE_ ACTIONS frozensets, thread-local WAL connections, close_all_connections wired into server.py's atexit chain alongside job_store / footage_index_db. - New opencut/routes/journal.py: GET /journal/list, POST /journal/record, POST /journal/mark-reverted/<id>, DELETE /journal/<id>, POST /journal/clear. Record endpoint validates action against VALID_ACTIONS so typos can't corrupt the rollback flow. mark-reverted rejects non-revertible actions with a clear 400. ExtendScript: - host/index.jsx + 4 inverse helpers: ocRemoveSequenceMarkers (fingerprint match on time+comment), ocUnrenameItems (nodeId -> oldName + name fallback), ocRemoveImportedSequence (by name, multi-path deletion), ocRemoveImportedItem (by nodeId). ES3-compliant, with a _ocParse helper for older ExtendScript runtimes that lack JSON.parse. Frontend: - main.js: journalRecord() called after each successful forward op (addBeatMarkersToSequence, renameAll, importXML in onJobDone). - New Journal card in Settings tab with refresh + clear buttons, empty/loading/error states, relative timestamps, "Reverted" and "No auto-revert" pills. Lazy-renders when the Settings tab is clicked so first-load isn't slowed down. - PremiereBridge extended with removeSequenceMarkers / unrenameItems / removeImportedSequence / removeImportedItem helpers. Tests: - 14 new tests in test_journal.py covering the store (record, list, mark_reverted idempotency, clear, revertible flag, unknown-action rejection) and the routes (CSRF, round-trip, 404/409/400 error paths, delete, clear).
1 parent 4987e5a commit ca53859

23 files changed

Lines changed: 1132 additions & 22 deletions

File tree

Install.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ Write-Host " \___/| .__/ \___|_| |_|\____\__,_|\__|" -ForegroundColor Cyan
155155
Write-Host " |_| " -ForegroundColor Cyan
156156
Write-Host ""
157157
Write-Host " Open Source Video Editing Automation" -ForegroundColor DarkGray
158-
Write-Host " Installer v1.9.27" -ForegroundColor DarkGray
158+
Write-Host " Installer v1.9.28" -ForegroundColor DarkGray
159159

160160
$isAdmin = Test-IsAdmin
161161
if ($isAdmin) {

OpenCut.iss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
; Fully self-contained installer — bundles server exe, ffmpeg, and CEP extension
33

44
#define MyAppName "OpenCut"
5-
#define MyAppVersion "1.9.27"
5+
#define MyAppVersion "1.9.28"
66
#define MyAppPublisher "SysAdminDoc"
77
#define MyAppURL "https://github.com/SysAdminDoc/OpenCut"
88

extension/com.opencut.panel/CSXS/manifest.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
<ExtensionManifest xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
33
Version="7.0"
44
ExtensionBundleId="com.opencut.panel"
5-
ExtensionBundleVersion="1.9.27"
5+
ExtensionBundleVersion="1.9.28"
66
ExtensionBundleName="OpenCut">
77

88
<ExtensionList>
9-
<Extension Id="com.opencut.panel.main" Version="1.9.27" />
9+
<Extension Id="com.opencut.panel.main" Version="1.9.28" />
1010
</ExtensionList>
1111

1212
<ExecutionEnvironment>

extension/com.opencut.panel/client/index.html

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3197,6 +3197,23 @@ <h3 class="media-sidecar-title">No media in the workspace yet.</h3>
31973197

31983198
<!-- ======== TAB: SETTINGS ======== -->
31993199
<div class="nav-panel" id="panel-settings" role="tabpanel" aria-label="Settings">
3200+
<!-- Operation Journal (v1.9.28) -->
3201+
<section class="card" id="journalCard" aria-labelledby="journalCardTitle">
3202+
<div class="card-header">
3203+
<div class="card-title" id="journalCardTitle">Operation Journal</div>
3204+
<div class="card-header-actions">
3205+
<button type="button" class="icon-btn" id="journalRefreshBtn" title="Refresh" aria-label="Refresh journal">
3206+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2.5 8a5.5 5.5 0 019.3-4h-2a.75.75 0 000 1.5h3.5a.75.75 0 00.75-.75V1.5a.75.75 0 00-1.5 0v1.7A7 7 0 101 8a.75.75 0 001.5 0z"/></svg>
3207+
</button>
3208+
<button type="button" class="btn btn-ghost btn-sm" id="journalClearBtn" title="Clear all entries">Clear</button>
3209+
</div>
3210+
</div>
3211+
<p class="card-description">Every OpenCut action that writes to your Premiere project is recorded here. Revert any action that supports it, as long as you haven't made manual changes that conflict.</p>
3212+
<div id="journalList" class="journal-list" aria-live="polite">
3213+
<div class="journal-empty">Loading recent operations…</div>
3214+
</div>
3215+
</section>
3216+
32003217
<section class="card">
32013218
<div class="card-header"><div class="card-title" data-i18n="settings.whisper_ai">Whisper AI</div></div>
32023219
<div class="settings-row" id="whisperStatus">
@@ -3542,7 +3559,7 @@ <h3 class="media-sidecar-title">No media in the workspace yet.</h3>
35423559
<div class="card-header"><div class="card-title" data-i18n="settings.about">About OpenCut</div></div>
35433560
<div class="settings-row">
35443561
<span class="settings-label">Version</span>
3545-
<span class="settings-value">1.9.27</span>
3562+
<span class="settings-value">1.9.28</span>
35463563
</div>
35473564
<div class="about-links">
35483565
<a href="https://github.com/SysAdminDoc/opencut" class="about-link" target="_blank">GitHub</a>

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

Lines changed: 268 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* ============================================================
2-
OpenCut CEP Panel - Main Controller v1.9.27
2+
OpenCut CEP Panel - Main Controller v1.9.28
33
6-Tab Professional Toolkit
44
============================================================ */
55
(function () {
@@ -628,6 +628,9 @@
628628
el.sessionContext = $("sessionContext");
629629
el.sessionContextBody = $("sessionContextBody");
630630
el.sessionContextDismiss = $("sessionContextDismiss");
631+
el.journalList = $("journalList");
632+
el.journalRefreshBtn = $("journalRefreshBtn");
633+
el.journalClearBtn = $("journalClearBtn");
631634

632635
// Clip
633636
el.clipSelect = $("clipSelect");
@@ -1357,9 +1360,65 @@
13571360
},
13581361
isProjectSaved: function (cb) {
13591362
jsx("isProjectSaved()", cb);
1363+
},
1364+
// Journal inverse helpers (v1.9.28)
1365+
removeSequenceMarkers: function (payload, cb) {
1366+
var json = JSON.stringify(payload);
1367+
jsx("ocRemoveSequenceMarkers('" + json.replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "')", cb);
1368+
},
1369+
unrenameItems: function (payload, cb) {
1370+
var json = JSON.stringify(payload);
1371+
jsx("ocUnrenameItems('" + json.replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "')", cb);
1372+
},
1373+
removeImportedSequence: function (payload, cb) {
1374+
var json = JSON.stringify(payload);
1375+
jsx("ocRemoveImportedSequence('" + json.replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "')", cb);
1376+
},
1377+
removeImportedItem: function (payload, cb) {
1378+
var json = JSON.stringify(payload);
1379+
jsx("ocRemoveImportedItem('" + json.replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "')", cb);
13601380
}
13611381
};
13621382

1383+
// ================================================================
1384+
// Operation Journal (v1.9.28) — frontend record + rollback
1385+
// ================================================================
1386+
// Every ExtendScript operation that mutates the Premiere project calls
1387+
// journalRecord() after success. The Journal sub-tab under Settings
1388+
// renders the history and dispatches inverse calls via PremiereBridge.
1389+
function journalRecord(action, label, inversePayload, clipPath) {
1390+
if (!connected) return;
1391+
api("POST", "/journal/record", {
1392+
action: action,
1393+
label: label || "",
1394+
clip_path: clipPath || "",
1395+
inverse: inversePayload || {}
1396+
}, function (err, entry) {
1397+
if (err) {
1398+
// Never surface journal-record failures to the user — the
1399+
// forward operation already succeeded.
1400+
try { console.warn("journal record failed:", err); } catch (_) {}
1401+
return;
1402+
}
1403+
// Nudge the journal tab if it's currently visible.
1404+
if (typeof renderJournalList === "function" && el.journalList &&
1405+
el.journalList.offsetParent !== null) {
1406+
renderJournalList();
1407+
}
1408+
});
1409+
}
1410+
1411+
// Helpers to extract the label/inverse from a forward-operation result.
1412+
function _journalLabelForMarkers(count, clipName) {
1413+
var n = count | 0;
1414+
return n + " marker" + (n === 1 ? "" : "s") +
1415+
(clipName ? " on '" + clipName + "'" : "");
1416+
}
1417+
function _journalLabelForRename(count) {
1418+
var n = count | 0;
1419+
return "Renamed " + n + " project item" + (n === 1 ? "" : "s");
1420+
}
1421+
13631422
// ================================================================
13641423
// Backend Communication
13651424
// ================================================================
@@ -3121,12 +3180,18 @@
31213180
showAlert("Import error: " + r.error);
31223181
} else if (r.sequence_name) {
31233182
showAlert("Opened: " + r.sequence_name);
3183+
journalRecord(
3184+
"import_sequence",
3185+
_sessionCtxOpText(job) + " → '" + r.sequence_name + "'",
3186+
{ name: r.sequence_name },
3187+
selectedPath
3188+
);
31243189
}
31253190
} catch (e) { console.error("XML import parse error:", e); }
31263191
});
31273192
lastXmlPath = xmlPath;
31283193
}
3129-
3194+
31303195
// Styled caption overlay video (.mov with alpha)
31313196
var overlayPath = job.result.overlay_path;
31323197
if (overlayPath) {
@@ -5933,6 +5998,161 @@
59335998
_sessionCtxDismissedAt = Date.now();
59345999
}
59356000

6001+
// ================================================================
6002+
// Operation Journal UI (v1.9.28)
6003+
// ================================================================
6004+
function _journalActionLabel(action) {
6005+
return ({
6006+
add_markers: "Add markers",
6007+
batch_rename: "Batch rename",
6008+
import_sequence: "Import sequence",
6009+
import_overlay: "Import overlay",
6010+
import_captions: "Import captions",
6011+
create_smart_bins: "Create bins"
6012+
})[action] || action;
6013+
}
6014+
6015+
function renderJournalList() {
6016+
if (!el.journalList) return;
6017+
el.journalList.innerHTML = '<div class="journal-empty">Loading recent operations…</div>';
6018+
api("GET", "/journal/list?limit=30", null, function (err, data) {
6019+
if (err) {
6020+
el.journalList.innerHTML =
6021+
'<div class="journal-empty journal-error">Couldn\'t load journal: ' +
6022+
esc(err.error || err.message || "Unknown error") + '</div>';
6023+
return;
6024+
}
6025+
if (!Array.isArray(data) || !data.length) {
6026+
el.journalList.innerHTML =
6027+
'<div class="journal-empty">No operations recorded yet. Run an action that writes to your Premiere project and it will appear here.</div>';
6028+
return;
6029+
}
6030+
el.journalList.innerHTML = "";
6031+
var frag = document.createDocumentFragment();
6032+
for (var i = 0; i < data.length; i++) {
6033+
frag.appendChild(_buildJournalRow(data[i]));
6034+
}
6035+
el.journalList.appendChild(frag);
6036+
});
6037+
}
6038+
6039+
function _buildJournalRow(entry) {
6040+
var row = document.createElement("div");
6041+
row.className = "journal-row" + (entry.reverted ? " journal-row-reverted" : "");
6042+
row.setAttribute("data-id", entry.id);
6043+
6044+
var copy = document.createElement("div");
6045+
copy.className = "journal-row-copy";
6046+
6047+
var title = document.createElement("div");
6048+
title.className = "journal-row-title";
6049+
title.textContent = _journalActionLabel(entry.action);
6050+
6051+
var meta = document.createElement("div");
6052+
meta.className = "journal-row-meta";
6053+
var parts = [_sessionCtxRelativeTime(entry.created_at)];
6054+
if (entry.label) parts.push(entry.label);
6055+
meta.textContent = parts.join(" · ");
6056+
6057+
copy.appendChild(title);
6058+
copy.appendChild(meta);
6059+
row.appendChild(copy);
6060+
6061+
var actions = document.createElement("div");
6062+
actions.className = "journal-row-actions";
6063+
6064+
if (entry.reverted) {
6065+
var pill = document.createElement("span");
6066+
pill.className = "journal-pill journal-pill-reverted";
6067+
pill.textContent = "Reverted";
6068+
actions.appendChild(pill);
6069+
} else if (!entry.revertible) {
6070+
var pill2 = document.createElement("span");
6071+
pill2.className = "journal-pill journal-pill-info";
6072+
pill2.textContent = "No auto-revert";
6073+
pill2.title = "This action type has no ExtendScript inverse. It's recorded for context only.";
6074+
actions.appendChild(pill2);
6075+
} else {
6076+
var revertBtn = document.createElement("button");
6077+
revertBtn.type = "button";
6078+
revertBtn.className = "btn btn-secondary btn-sm journal-revert-btn";
6079+
revertBtn.textContent = "Revert";
6080+
revertBtn.addEventListener("click", function () {
6081+
_journalRevert(entry, revertBtn);
6082+
});
6083+
actions.appendChild(revertBtn);
6084+
}
6085+
6086+
row.appendChild(actions);
6087+
return row;
6088+
}
6089+
6090+
function _journalRevert(entry, btn) {
6091+
if (!inPremiere) { showAlert("Premiere Pro connection required to revert."); return; }
6092+
if (!entry.revertible) { return; }
6093+
6094+
btn.disabled = true;
6095+
btn.textContent = "Reverting…";
6096+
6097+
var dispatch = {
6098+
add_markers: function (p, cb) { PremiereBridge.removeSequenceMarkers(p, cb); },
6099+
batch_rename: function (p, cb) { PremiereBridge.unrenameItems(p, cb); },
6100+
import_sequence: function (p, cb) { PremiereBridge.removeImportedSequence(p, cb); },
6101+
import_overlay: function (p, cb) { PremiereBridge.removeImportedItem(p, cb); }
6102+
}[entry.action];
6103+
6104+
if (!dispatch) {
6105+
btn.disabled = false;
6106+
btn.textContent = "Revert";
6107+
showAlert("This action can't be reverted automatically.");
6108+
return;
6109+
}
6110+
6111+
dispatch(entry.inverse || {}, function (result) {
6112+
var r;
6113+
try { r = JSON.parse(result || "{}"); } catch (e) { r = { error: result || "Parse error" }; }
6114+
if (r.error) {
6115+
btn.disabled = false;
6116+
btn.textContent = "Revert";
6117+
showAlert("Revert failed: " + r.error);
6118+
return;
6119+
}
6120+
// Mark server-side so the UI reflects the new state.
6121+
api("POST", "/journal/mark-reverted/" + entry.id, {}, function (err) {
6122+
if (err) {
6123+
showToast("Reverted in Premiere but couldn't update the journal", "warn");
6124+
} else {
6125+
showToast("Reverted: " + _journalActionLabel(entry.action), "success");
6126+
}
6127+
renderJournalList();
6128+
});
6129+
});
6130+
}
6131+
6132+
function initJournal() {
6133+
if (!el.journalList) return;
6134+
if (el.journalRefreshBtn) {
6135+
el.journalRefreshBtn.addEventListener("click", renderJournalList);
6136+
}
6137+
if (el.journalClearBtn) {
6138+
el.journalClearBtn.addEventListener("click", function () {
6139+
if (!confirm("Clear all journal entries? This does not undo anything in Premiere.")) return;
6140+
api("POST", "/journal/clear", {}, function (err) {
6141+
if (err) { showAlert("Could not clear: " + (err.error || err)); return; }
6142+
showToast("Journal cleared", "success");
6143+
renderJournalList();
6144+
});
6145+
});
6146+
}
6147+
// Lazy render the first time the user actually opens Settings.
6148+
var settingsTab = document.querySelector('[data-nav="settings"]');
6149+
if (settingsTab) {
6150+
settingsTab.addEventListener("click", function () {
6151+
setTimeout(renderJournalList, 50);
6152+
});
6153+
}
6154+
}
6155+
59366156
function esc(s) {
59376157
if (!s) return "";
59386158
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
@@ -9423,6 +9643,24 @@
94239643
try {
94249644
var r = JSON.parse(result);
94259645
showToast("Added " + (r.added || beatMarkerTimes.length) + " markers", "success");
9646+
if (!r.error) {
9647+
// Journal the op for one-click rollback. Fingerprint each
9648+
// marker by its {time, comment} pair so the inverse can
9649+
// find+delete exactly these rows.
9650+
var fingerprints = [];
9651+
for (var k = 0; k < markers.length; k++) {
9652+
fingerprints.push({
9653+
time: markers[k].time,
9654+
comment: markers[k].name || "Beat"
9655+
});
9656+
}
9657+
journalRecord(
9658+
"add_markers",
9659+
_journalLabelForMarkers(markers.length, selectedName),
9660+
{ markers: fingerprints },
9661+
selectedPath
9662+
);
9663+
}
94269664
} catch (e) { showAlert("Error adding markers: " + (result || e.message)); }
94279665
});
94289666
}
@@ -9618,6 +9856,32 @@
96189856
try {
96199857
var r = JSON.parse(result);
96209858
showToast("Renamed " + (r.renamed || renames.length) + " items", "success");
9859+
if (!r.error) {
9860+
// Journal each (nodeId -> oldName) pair so Unrename
9861+
// can restore the previous names.
9862+
var reverseList = [];
9863+
for (var k = 0; k < renames.length; k++) {
9864+
var idx = -1;
9865+
for (var j = 0; j < renameItemsData.length; j++) {
9866+
if ((renameItemsData[j].nodeId || renameItemsData[j].id ||
9867+
renameItemsData[j].path) === renames[k].nodeId) {
9868+
idx = j; break;
9869+
}
9870+
}
9871+
if (idx >= 0) {
9872+
reverseList.push({
9873+
nodeId: renames[k].nodeId,
9874+
oldName: renameItemsData[idx].name,
9875+
currentName: renames[k].newName
9876+
});
9877+
}
9878+
}
9879+
journalRecord(
9880+
"batch_rename",
9881+
_journalLabelForRename(reverseList.length),
9882+
{ renames: reverseList }
9883+
);
9884+
}
96219885
} catch (e) { showAlert("Error: " + (result || e.message)); }
96229886
});
96239887
});
@@ -10992,7 +11256,8 @@
1099211256
initFavorites, initPreviewModal, initAudioPreview, initContextMenu,
1099311257
initWizard, initOutputBrowser, initBatchPicker, initDepDashboard,
1099411258
initSettingsIO, initWorkflowBuilder, loadWorkflowPresets,
10995-
initCollapsibleCards, initI18n, initProjectTemplates
11259+
initCollapsibleCards, initI18n, initProjectTemplates,
11260+
initJournal
1099611261
];
1099711262
for (var fi = 0; fi < _featureInits.length; fi++) {
1099811263
try { _featureInits[fi](); }

0 commit comments

Comments
 (0)