feat: timeline.export() for NLE project bundles - #102
Open
videodb-kal wants to merge 5 commits into
Open
Conversation
An export produces an editable Premiere project — FCP7 XML, OTIO, EDL, captions and the media the sequence references — rather than a rendered video. The work is minutes of downloads and encoding, so export() submits and returns an ExportJob immediately; the caller polls or calls wait(). The shape is not invented. It mirrors Timeline.generate_stream, which is how this SDK already asks the platform to do something with a timeline: serialize to_json(), POST it inline under `editor`, and fall back to uploading the JSON when it exceeds MAX_PAYLOAD_SIZE. Export is the same question as render — here is a timeline, produce an artifact — so it is the same shape. Mirroring rather than inventing is what makes the payload-size fallback come along for free. These requests cross a gateway with a hard body cap, so a long timeline posted inline fails at the edge with nothing useful in the response. Had this been designed from scratch it would have been found the first time somebody exported a feature-length timeline. Three decisions worth stating, each with a test: - download_url() is a method, not a property. What it returns is a signed URL with a short life; a property invites caching, and a cached signed URL works in testing and 403s a day later. Minted per call, never held on the job. - done and failed are both False for a status this client does not recognise. The platform's vocabulary can grow, and reporting an unknown status as done would have a caller fetch an artifact that is not there. Waiting on a status we cannot interpret is the recoverable mistake. - wait() raises TimeoutError rather than returning a still-running job. A caller handed an unfinished job by a method named wait will treat it as finished. Optional fields are omitted rather than sent as null: absent means "fall back to the timeline id", null means "there is no name", and those are different answers. A submit response with no job_id raises instead of yielding a job that cannot be refreshed, waited on or downloaded. ExportJob lives in videodb/export.py rather than editor.py, which is already 1,200 lines — and it keeps this off the lines the in-flight quality branch touches. 17 tests against a stub connection: no network, no platform, no credentials. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CwxN3uj28RUVbPVYVnYztw
refresh() and download_url() addressed a job by id alone. The platform scopes an export read by timeline, so every one of them 404'd against a real server — which is what the first live call found, and what no stub could have. ExportJob now carries timeline_id and builds its own path from it. That is not cosmetic: scoping the read by timeline is what lets the platform answer 404 for another user's job id instead of leaking it. A job built from a response with no timeline_id raises a sentence saying exactly that, rather than composing a malformed path and reporting whatever 404 comes back. Verified live: submit, refresh, and wait() polling a real export through to done. 20 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CwxN3uj28RUVbPVYVnYztw
Two gaps in the public surface, both found by asking what a user of this package actually sees. ExportJob was importable only as `videodb.export.ExportJob`. Every other job-like class — GenerationJob, Sandbox, VoiceClone, CaptureSession — is exported from the package root and listed in __all__. GenerationJob is the direct precedent, and a job class that needs a submodule path when its siblings do not is the kind of inconsistency people work around rather than report. README documented Timeline and generate_stream but not export(), which is new public API. The example sits next to the timeline it belongs to and shows the whole shape: submit, wait, download, and read the fidelity summary — including why that last one matters, since an export that succeeds while dropping the user's colour grades is not a plain success and a client that cannot see it will report it as one. The download URL is called out as not-to-be-cached in the example itself. It is signed and short-lived, and a cached one works in testing and 403s a day later. 24 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CwxN3uj28RUVbPVYVnYztw
This is a public repository, and three comments described backend internals a published SDK has no business referencing: the terminal-status list said it "mirrors the export service's vocabulary", the wait timeout said it "matches the export service's own per-job budget", and both editor.py and a test named the API gateway and its body cap as the reason for the upload fallback. None of it is wrong, and none of it belongs here. A reader of this package cannot see those systems, cannot depend on them, and should not learn their shape from a docstring. Each is now stated in terms of what the SDK does and why a caller should care: half an hour is longer than an export is expected to take, and a long timeline is uploaded because it can exceed the request body limit. No behaviour change; comments and docstrings only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CwxN3uj28RUVbPVYVnYztw
The method is annotated -> str and could return None when the response carried no URL. A caller reasonably treats the result as a string, so None surfaces wherever it is next handed — an opener, an HTTP call, a log line reading "None" — by which point nothing points back at the export that had no bundle. It now raises, naming the job and the likely cause. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CwxN3uj28RUVbPVYVnYztw
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
timeline.export()— NLE project bundlesA timeline can already be rendered to a video. This adds the other thing you might want from it: an editable project.
The zip holds an FCP7 XML project, an OTIO copy, an EDL, an SRT and the media the sequence references — importable with media already relinked, rather than a flat video.
+521 lines, 0 deletions. One new module, one method, one export from the package root.
The shape
sequenceDiagram participant U as Your code participant T as Timeline participant J as ExportJob participant A as VideoDB U->>T: timeline.export() T->>A: POST (the same payload generate_stream sends) A-->>T: job id + status T-->>U: ExportJob U->>J: wait() loop until terminal J->>A: GET status end U->>J: download_url() J->>A: GET artifact A-->>J: a short-lived URLSubmit returns straight away because the work is minutes of downloading and encoding. Nothing is held open.
Timeline.export()formatnameclient_reftimeline_idThe payload is the same shape
generate_streamsends, including the fallback that uploads the timeline JSON when it exceedsMAX_PAYLOAD_SIZE— a long timeline posted inline can exceed the request body limit.ExportJob—videodb/export.pyrefresh()wait(timeout=..., poll_interval=...)done/failed/terminaldownload_url()fidelityExportJobis exported at the package root, sofrom videodb import ExportJobworks for type annotations without reaching into a submodule.Design decisions, each with a test
A job is read back under the timeline that produced it. The read path is scoped by timeline, not global — a bare job id is not a lookup key.
download_url()returnsstr, and now actually does. It was annotatedstrwhile returning the response dict on one branch.Absent and null are different.
name,client_refandtimeline_idare omitted from the payload when unset rather than sent asnull— absent means "fall back to the timeline id", null means "there is no name", and those are not the same request.The docstrings describe the SDK's behaviour, not the service behind it. An earlier draft explained infrastructure this package has no business knowing about.
Compatibility
Purely additive: one new module, one new method, one new package-level export. No existing signature, return type or behaviour changes. Nothing is deprecated.
Requires
A VideoDB deployment where the export feature is enabled. Against one where it is not,
timeline.export()raises the same error any unavailable endpoint does.This PR is not a prerequisite for the feature working — it is a typed convenience over an endpoint that can also be called directly. It can merge before, after, or independently of the rest.
Verification
tests/test_export.py— 255 lines, no network. Covers the payload shape including the large-timeline upload fallback, the omitted-versus-null distinction, terminal-state handling,wait()timeout behaviour, anddownload_url()'s return type.Known follow-up
fidelityis exposed onExportJobbut the status response does not currently populate it, so it reads as empty even for an export that dropped content. The field is right; the plumbing behind it is not finished.🤖 Generated with Claude Code