From 784eeb5d7bdfef79c7bb5227de52d115bec2ca19 Mon Sep 17 00:00:00 2001 From: Fernando Abishai Date: Thu, 22 Oct 2026 20:26:17 -0700 Subject: [PATCH] Timeline: render exact draft through checked local job --- Sources/Cutmark/TimelineEditorWindow.swift | 43 +++--- Sources/Cutmark/TimelineRender.swift | 139 +++++++++++++++++++ Tests/CutmarkTests/TimelineRenderTests.swift | 94 +++++++++++++ mcp/cutmark_mcp.py | 34 +++++ mcp/tests/test_timeline_editing.py | 72 ++++++++++ 5 files changed, 362 insertions(+), 20 deletions(-) create mode 100644 Sources/Cutmark/TimelineRender.swift create mode 100644 Tests/CutmarkTests/TimelineRenderTests.swift diff --git a/Sources/Cutmark/TimelineEditorWindow.swift b/Sources/Cutmark/TimelineEditorWindow.swift index bbe6b5a..c1b1e09 100644 --- a/Sources/Cutmark/TimelineEditorWindow.swift +++ b/Sources/Cutmark/TimelineEditorWindow.swift @@ -211,6 +211,8 @@ private struct TimelineEditorWorkspace: View { @State private var timelineViewportWidth: CGFloat = 900 @State private var zoomToFit = true @State private var problem: String? + @State private var renderJob: String? + @State private var rendering = false @State private var playheadDragStart: Double? @FocusState private var keyboardFocus: Bool @@ -320,8 +322,8 @@ private struct TimelineEditorWorkspace: View { } Button("Reset") { reset() }.disabled(draft?.actions.isEmpty ?? true) .buttonStyle(AccentButtonStyle(kind: .quiet)) - Button("Render edit") { render() } - .disabled(draft == nil || (!((draft?.isRawTake) ?? false) && (draft?.actions.isEmpty ?? true))) + Button(rendering ? "Starting render…" : "Render edit") { render() } + .disabled(rendering || draft == nil || (!((draft?.isRawTake) ?? false) && (draft?.actions.isEmpty ?? true))) .buttonStyle(AccentButtonStyle(kind: .solid)) } .padding(.horizontal, 14).frame(height: 50) @@ -413,6 +415,10 @@ private struct TimelineEditorWorkspace: View { if let problem { Text(problem).font(Theme.sans(10.5)).foregroundStyle(Theme.danger).lineLimit(1) .help(problem) + } else if let renderJob { + Label("Render started · \(renderJob) · Review in Jobs", systemImage: "film.stack") + .font(Theme.sans(10.5)).foregroundStyle(Theme.muted).lineLimit(1) + .help("The render is unpublished. Review it from the session's Jobs row.") } } .padding(.horizontal, 14) @@ -715,6 +721,7 @@ private struct TimelineEditorWorkspace: View { draft = next selected = min(selected, next.segments.count - 1) problem = nil + renderJob = nil revision += 1 } catch { problem = "Could not save the draft: \(error.localizedDescription)" } } @@ -785,6 +792,7 @@ private struct TimelineEditorWorkspace: View { draft = previous selected = min(selected, previous.segments.count - 1) problem = nil + renderJob = nil revision += 1 } catch { problem = "Could not save undo: \(error.localizedDescription)" } } @@ -798,28 +806,23 @@ private struct TimelineEditorWorkspace: View { selected = 0 playback.seek(0, duration: fresh.duration) problem = nil + renderJob = nil revision += 1 } private func render() { - guard let draft else { return } - let request: String - if draft.isRawTake { - guard let recipe = draft.initialRecipe(), - let data = try? JSONSerialization.data(withJSONObject: recipe, options: [.sortedKeys]), - let json = String(data: data, encoding: .utf8) else { return } - request = "Render a new non-destructive Cutmark edit using render_edit with this exact recipe: \(json). " - + "Inspect the output. Do not publish_edit until I review the result. Do not modify original takes." - } else { - guard let data = try? JSONEncoder().encode(draft.actions), - let actions = String(data: data, encoding: .utf8) else { return } - request = "For the published edit \(draft.base), call plan_timeline_edit with base=\(draft.base) " - + "and these ordered actions: \(actions). Then render_edit using returned arguments, inspect checks, " - + "and do not publish_edit until I review. Original edits and takes must remain unchanged." - } - app.chats.chat(doc.url).send(request, title: doc.meta.title, onStage: video, - shown: "Render visual timeline for review") - app.chats.open = true + guard let draft, !rendering else { return } + rendering = true + renderJob = nil + problem = nil + Task { + defer { rendering = false } + do { + renderJob = try await TimelineRender.start(draft: draft, session: doc.url) + } catch { + problem = error.localizedDescription + } + } } private func suggestZooms(_ draft: EditTimelineDraft) { diff --git a/Sources/Cutmark/TimelineRender.swift b/Sources/Cutmark/TimelineRender.swift new file mode 100644 index 0000000..08eb188 --- /dev/null +++ b/Sources/Cutmark/TimelineRender.swift @@ -0,0 +1,139 @@ +import Darwin +import Foundation + +/// Runs the same checked render planner as the MCP tool, without handing exact timeline edits +/// to an AI conversation. The detached renderer continues in Cutmark's existing Jobs board. +enum TimelineRender { + enum Failure: LocalizedError { + case invalidDraft + case missingServer + case unavailable(String) + + var errorDescription: String? { + switch self { + case .invalidDraft: "The timeline changed unexpectedly. Reopen the editor and try again." + case .missingServer: "The Cutmark render helper is missing from this app." + case .unavailable(let detail): detail + } + } + } + + /// Only the canonical raw recipe or the ordered operations on a published base cross this + /// boundary. Replaying prevents a stale or malformed draft from rendering a different cut. + static func request(draft: EditTimelineDraft, session: URL) throws -> Data { + guard !draft.segments.isEmpty else { throw Failure.invalidDraft } + var replay = EditTimelineDraft(base: draft.base, fps: draft.fps, + source: draft.source, segments: draft.source) + for action in draft.actions { + guard replay.apply(action) else { throw Failure.invalidDraft } + } + guard replay.segments == draft.segments else { throw Failure.invalidDraft } + var payload: [String: Any] = ["session": session.standardizedFileURL.path] + if draft.isRawTake { + guard let recipe = draft.initialRecipe() else { throw Failure.invalidDraft } + payload["recipe"] = recipe + } else { + guard !draft.actions.isEmpty, draft.base.hasPrefix("edits/"), + let data = try? JSONEncoder().encode(draft.actions), + let actions = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { throw Failure.invalidDraft } + payload["base"] = draft.base + payload["actions"] = actions + } + return try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + } + + /// Returns a job id as soon as the backend starts its detached render. Original footage and + /// published recipe versions remain intact; the user reviews the result before publishing. + static func start(draft: EditTimelineDraft, session: URL) async throws -> String { + let payload = try request(draft: draft, session: session) + guard let script = Bundle.main.url(forResource: "cutmark_mcp", withExtension: "py") else { + throw Failure.missingServer + } + return try await run(request: payload, script: script) + } + + /// Separate for testing broken pipes, nonzero exits and stalled helpers without a render. + static func run(request payload: Data, script: URL, timeoutSeconds: Double = 30) async throws -> String { + return try await Task.detached(priority: .userInitiated) { + // Passing a read-only file as stdin avoids SIGPIPE if Python exits before consuming + // the request. A private temporary folder prevents other users reading the recipe. + let scratch = FileManager.default.temporaryDirectory.appending( + path: "cutmark-timeline-render-\(UUID().uuidString)", directoryHint: .isDirectory) + let file = scratch.appending(path: "request.json") + do { + try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700]) + try payload.write(to: file, options: .atomic) + } catch { + try? FileManager.default.removeItem(at: scratch) + throw Failure.unavailable("Could not prepare the render request: \(error.localizedDescription)") + } + defer { try? FileManager.default.removeItem(at: scratch) } + let input: FileHandle + do { input = try FileHandle(forReadingFrom: file) } + catch { throw Failure.unavailable("Could not open the render request: \(error.localizedDescription)") } + defer { try? input.close() } + // A regular file for the response avoids waiting for EOF from a detached descendant + // that inadvertently inherits stdout; the native helper itself has a hard deadline. + let responseFile = scratch.appending(path: "response.json") + guard FileManager.default.createFile(atPath: responseFile.path, contents: Data(), + attributes: [.posixPermissions: 0o600]) else { + throw Failure.unavailable("Could not prepare the render response.") + } + let output: FileHandle + do { output = try FileHandle(forWritingTo: responseFile) } + catch { throw Failure.unavailable("Could not open the render response: \(error.localizedDescription)") } + defer { try? output.close() } + let process = Process() + process.executableURL = URL(filePath: "/usr/bin/python3") + process.arguments = [script.path, "--timeline-render"] + var env = ProcessInfo.processInfo.environment + env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:\(Setup.localBin):" + + (env["PATH"] ?? "/usr/bin:/bin") + if let root = UserDefaults.standard.string(forKey: "root") { + env["CUTMARK_ROOT"] = root + } + process.environment = env + process.standardInput = input + process.standardOutput = output + process.standardError = FileHandle.nullDevice + do { try process.run() } + catch { throw Failure.unavailable("Could not launch Cutmark's render helper: \(error.localizedDescription)") } + let pid = process.processIdentifier + let limit = max(0.1, timeoutSeconds) + let deadline = DispatchWorkItem { + if process.isRunning { _ = Darwin.kill(pid, SIGTERM) } + } + let hardStop = DispatchWorkItem { + if process.isRunning { _ = Darwin.kill(pid, SIGKILL) } + } + DispatchQueue.global(qos: .utility).asyncAfter( + deadline: .now() + limit, execute: deadline) + DispatchQueue.global(qos: .utility).asyncAfter( + deadline: .now() + limit + 2, execute: hardStop) + defer { deadline.cancel(); hardStop.cancel() } + process.waitUntilExit() + if process.terminationReason == .uncaughtSignal && + (process.terminationStatus == SIGTERM || process.terminationStatus == SIGKILL) { + throw Failure.unavailable("The render helper did not respond. Try again.") + } + let size = (try? FileManager.default.attributesOfItem(atPath: responseFile.path)[.size]) as? NSNumber + guard (size?.intValue ?? Int.max) <= 1024 * 1024, + let data = try? Data(contentsOf: responseFile) else { + throw Failure.unavailable("The render helper returned an invalid response.") + } + guard let response = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw Failure.unavailable("The render helper did not return a valid result.") + } + if let error = response["error"] as? String { + throw Failure.unavailable(error) + } + guard process.terminationStatus == 0, + let job = response["job"] as? String, !job.isEmpty else { + throw Failure.unavailable("Cutmark could not start the render.") + } + return job + }.value + } +} diff --git a/Tests/CutmarkTests/TimelineRenderTests.swift b/Tests/CutmarkTests/TimelineRenderTests.swift new file mode 100644 index 0000000..a3d85b2 --- /dev/null +++ b/Tests/CutmarkTests/TimelineRenderTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing +@testable import Cutmark + +struct TimelineRenderTests { + private let session = URL(filePath: "/tmp/cutmark-timeline-test/session", directoryHint: .isDirectory) + + @Test func rawTakeRendersCanonicalRecipeWithoutAgentOrPublish() throws { + let take = Take(number: 7, kind: .screen, file: "take-07-screen.mov", + startedAt: .distantPast, duration: 8) + var draft = try #require(EditTimelineDraft.fromTake(take, duration: 8, fps: 30)) + let didSplit = draft.apply(.init(op: "split", index: 0, at: 4)) + let didMove = draft.apply(.init(op: "move", index: 1, to: 0)) + #expect(didSplit && didMove) + let data = try TimelineRender.request(draft: draft, session: session) + let request = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(request["session"] as? String == session.path) + #expect(request["base"] == nil) + #expect(request["actions"] == nil) + #expect(request["publish"] == nil) + let recipe = try #require(request["recipe"] as? [String: Any]) + #expect(recipe["name"] as? String == "clip-take-07-screen") + let cuts = try #require(recipe["cut"] as? [[String: Any]]) + #expect(cuts.count == 2) + #expect(cuts[0]["in"] as? Double == 4) + #expect(cuts[1]["out"] as? Double == 4) + } + + @Test func publishedEditSendsOrderedOperationsWithoutRewritingEffects() throws { + let source = [EditTimelineDraft.Segment(take: 3, kind: "camera", start: 0, end: 8)] + var draft = EditTimelineDraft(base: "edits/demo-v2.mp4", fps: 30, + source: source, segments: source) + let didSplit = draft.apply(.init(op: "split", index: 0, at: 3)) + let didMove = draft.apply(.init(op: "move", index: 1, to: 0)) + #expect(didSplit && didMove) + let data = try TimelineRender.request(draft: draft, session: session) + let request = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(request["recipe"] == nil) + #expect(request["base"] as? String == "edits/demo-v2.mp4") + #expect(request["publish"] == nil) + let actions = try #require(request["actions"] as? [[String: Any]]) + #expect(actions.count == 2) + #expect(actions.map { $0["op"] as? String } == ["split", "move"]) + #expect(actions[0]["at"] as? Double == 3) + #expect(actions[1]["to"] as? Int == 0) + } + + @Test func cannotRenderStaleOrTamperedDraft() throws { + let source = [EditTimelineDraft.Segment(take: 1, kind: "screen", start: 0, end: 5)] + let noEdits = EditTimelineDraft(base: "edits/demo-v1.mp4", fps: 30, + source: source, segments: source) + #expect(throws: TimelineRender.Failure.self) { + try TimelineRender.request(draft: noEdits, session: session) + } + var edited = noEdits + let didSplit = edited.apply(.init(op: "split", index: 0, at: 2)) + #expect(didSplit) + let tampered = EditTimelineDraft(base: edited.base, fps: edited.fps, + source: edited.source, segments: edited.source, + actions: edited.actions) + #expect(throws: TimelineRender.Failure.self) { + try TimelineRender.request(draft: tampered, session: session) + } + let malformed = EditTimelineDraft(base: edited.base, fps: 30, + source: source, segments: edited.segments, + actions: [.init(op: "remove", index: 6)]) + #expect(throws: TimelineRender.Failure.self) { + try TimelineRender.request(draft: malformed, session: session) + } + } + + @Test func helperFailureReturnsErrorInsteadOfCrashing() async throws { + let root = FileManager.default.temporaryDirectory.appending(path: "cutmark-render-helper-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let script = root.appending(path: "failure.py") + try Data("import sys\nsys.exit(1)\n".utf8).write(to: script) + await #expect(throws: TimelineRender.Failure.self) { + try await TimelineRender.run(request: Data(repeating: 88, count: 200_000), script: script) + } + } + + @Test func stalledHelperHasBoundedLifetime() async throws { + let root = FileManager.default.temporaryDirectory.appending(path: "cutmark-render-stall-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let script = root.appending(path: "stall.py") + // SIGTERM alone is insufficient: the helper can trap it while blocked. + try Data("import signal, sys, time\nsignal.signal(signal.SIGTERM, signal.SIG_IGN)\nsys.stdin.read()\ntime.sleep(90)\n".utf8).write(to: script) + await #expect(throws: TimelineRender.Failure.self) { + try await TimelineRender.run(request: Data("{}".utf8), script: script, timeoutSeconds: 0.5) + } + } +} diff --git a/mcp/cutmark_mcp.py b/mcp/cutmark_mcp.py index a631cf0..6cfbd2d 100644 --- a/mcp/cutmark_mcp.py +++ b/mcp/cutmark_mcp.py @@ -13495,6 +13495,30 @@ def t_plan_timeline_edit(a): "next": "Review this timeline first. Then call render_edit with render_edit arguments; check its output before publish_edit. No files were changed."} +def t_render_timeline_request(a): + """The native timeline's exact render operation: plan, validate and start one unpublished job. + + Unlike the agent-facing planner, the native caller supplies an immutable edit recipe OR + ordered actions from its draft. No publish or recipe mutation is accepted from this entrypoint. + """ + if not isinstance(a, dict) or not isinstance(a.get("session"), str) or not a["session"]: + raise ValueError("A Cutmark session is required.") + raw = "recipe" in a + if raw: + if set(a) != {"session", "recipe"} or not isinstance(a["recipe"], dict): + raise ValueError("Give only session and a complete raw-take recipe.") + render = {"session": a["session"], "recipe": a["recipe"], "wait_s": 0} + else: + if (set(a) != {"session", "base", "actions"} or + not isinstance(a.get("base"), str) or not a["base"].startswith("edits/") or + not isinstance(a.get("actions"), list) or not a["actions"]): + raise ValueError("Give a published edit base and its ordered timeline actions.") + plan = t_plan_timeline_edit({"session": a["session"], "base": a["base"], + "actions": a["actions"]}) + render = dict(plan["render_edit"], wait_s=0) + return t_render_edit(render) + + def recipe_short(r): """get_session's form: only the fields the agent sets, the cut as a count.""" out = {k: r.get(k) for k in ("name", "edit", "note", "frame", "over", "music", "sfx", "steps", "cover", "loudness")} @@ -15541,6 +15565,16 @@ def main(): except (ValueError, KeyError) as e: res = {"error": str(e)} print(json.dumps(res)) + elif sys.argv[1:2] == ["--timeline-render"]: # Native editor: one checked, unpublished job. + try: + request = sys.stdin.read(1024 * 1024 + 1) + if len(request) > 1024 * 1024: + raise ValueError("The timeline render request is too large.") + res = t_render_timeline_request(json.loads(request)) + print(json.dumps(res, allow_nan=False)) + except (ValueError, TypeError, KeyError, OSError) as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) elif sys.argv[1:2] == ["--sketch-run"]: sketch_run(sys.argv[2], retry="--retry" in sys.argv[3:]) elif sys.argv[1:2] == ["--detach"]: diff --git a/mcp/tests/test_timeline_editing.py b/mcp/tests/test_timeline_editing.py index d148214..e392414 100644 --- a/mcp/tests/test_timeline_editing.py +++ b/mcp/tests/test_timeline_editing.py @@ -2,6 +2,8 @@ import os import sys import unittest +import json +import subprocess from unittest.mock import patch sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) @@ -129,8 +131,78 @@ def test_public_plan_accepts_extend_and_returns_only_canonical_cut_fields(self): op_schema = tool[2]["actions"]["items"]["properties"]["op"] self.assertIn("extend", op_schema["enum"]) + def test_native_raw_render_passes_recipe_only_and_never_publishes(self): + recipe = {"name": "clip-take-01-screen", "frame": {"shape": "source"}, + "cut": [{"take": 1, "kind": "screen", "in": 0, "out": 5}]} + with patch.object(t, "t_render_edit", return_value={"job": "job-123", "status": "running"}) as render, \ + patch.object(t, "t_publish_edit") as publish, \ + patch.object(t, "t_plan_timeline_edit") as plan: + result = t.t_render_timeline_request({"session": "/scratch/session", "recipe": recipe}) + self.assertEqual(result["job"], "job-123") + render.assert_called_once_with({"session": "/scratch/session", "recipe": recipe, "wait_s": 0}) + publish.assert_not_called() + plan.assert_not_called() + + def test_native_published_render_uses_exact_validated_plan(self): + actions = [{"op": "split", "index": 0, "at": 1.5}, + {"op": "move", "index": 1, "to": 0}] + validated = {"session": "/scratch/session", "base": "edits/demo-v1.mp4", + "changes": {"cut": [{"take": 1, "in": 1.5, "out": 3.0}]}} + with patch.object(t, "t_plan_timeline_edit", + return_value={"render_edit": validated}) as plan, \ + patch.object(t, "t_render_edit", return_value={"job": "job-789"}) as render, \ + patch.object(t, "t_publish_edit") as publish: + out = t.t_render_timeline_request({"session": "/scratch/session", + "base": "edits/demo-v1.mp4", "actions": actions}) + self.assertEqual(out["job"], "job-789") + plan.assert_called_once_with({"session": "/scratch/session", "base": "edits/demo-v1.mp4", + "actions": actions}) + render.assert_called_once_with(dict(validated, wait_s=0)) + publish.assert_not_called() + + def test_native_render_rejects_ambiguous_or_unauthorized_payloads(self): + invalid = [ + {}, [], {"session": "/scratch", "base": "edits/demo-v1.mp4", "actions": []}, + {"session": "/scratch", "base": "edits/demo-v1.mp4", "actions": [], + "publish": True}, + {"session": "/scratch", "base": "../evil", "actions": [{"op": "remove", "index": 0}]}, + {"session": "/scratch", "recipe": {}, "base": "edits/demo-v1.mp4"}, + {"session": "/scratch", "recipe": {}, "wait_s": 999}, + ] + with patch.object(t, "t_render_edit") as render, patch.object(t, "t_plan_timeline_edit") as plan: + for request in invalid: + with self.subTest(request=request), self.assertRaises(ValueError): + t.t_render_timeline_request(request) + render.assert_not_called() + plan.assert_not_called() + + def test_native_cli_invalid_request_returns_structured_error_and_does_not_start_job(self): + script = os.path.join(os.path.dirname(__file__), "..", "cutmark_mcp.py") + process = subprocess.run([sys.executable, script, "--timeline-render"], + input=json.dumps({"session": "/scratch", "publish": True}), + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + self.assertEqual(process.returncode, 1) + self.assertIn("error", json.loads(process.stdout)) + class TimelineRenderIntegration(Base): + def test_native_raw_render_job_keeps_take_and_session_unchanged(self): + file = self.add_take(1, secs=3, kind="screen") + original_meta = t.read_meta(self.s) + output = t.t_render_timeline_request({ + "session": self.s, + "recipe": {"name": "clip-native-screen", "cut": [ + {"take": 1, "kind": "screen", "in": 0.25, "out": 2.25}], + "frame": {"shape": "source"}}, + }) + self.assertIn(output.get("status"), ("running", "done"), output) + job = t.t_get_job({"session": self.s, "id": output["job"], "wait_s": 120}) + self.assertEqual(job["status"], "done", job) + self.assertTrue(os.path.isfile(os.path.join(self.s, output["render"]))) + self.assertTrue(os.path.isfile(os.path.join(self.s, file))) + self.assertEqual(t.read_meta(self.s), original_meta) + self.assertFalse(os.path.isdir(os.path.join(self.s, "edits"))) + def test_plan_changes_generate_checked_unpublished_render_without_overwriting_base(self): self.add_take(1, secs=6) first = self.render(recipe={"name": "demo", "cut": [