Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 23 additions & 20 deletions Sources/Cutmark/TimelineEditorWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)" }
}
Expand Down Expand Up @@ -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)" }
}
Expand All @@ -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) {
Expand Down
139 changes: 139 additions & 0 deletions Sources/Cutmark/TimelineRender.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
94 changes: 94 additions & 0 deletions Tests/CutmarkTests/TimelineRenderTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
34 changes: 34 additions & 0 deletions mcp/cutmark_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")}
Expand Down Expand Up @@ -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"]:
Expand Down
Loading
Loading