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 bundlesAdds a public surface for exporting a timeline as an editable NLE project — FCP7
XML, OTIO, EDL, captions and the media the sequence references — rather than a
rendered video. Additive only.
Why
generate_stream()answers "render this timeline". This answers "give me theproject": something an editor opens in Premiere and keeps working on, from the
same timeline.
The work takes minutes, so it cannot be synchronous.
export()submits andreturns a job.
What's here
Timeline.export()Mirrors
generate_streamrather than inventing a shape: serializeto_json(),POST it inline under
editor, and fall back to uploading the JSON when it exceedsMAX_PAYLOAD_SIZE.That inheritance is the point. A long timeline posted inline can exceed the
request body limit and fail with nothing useful in the response.
generate_streamalready solved it; export gets the solution rather than rediscovering it the first
time someone exports a feature-length timeline.
ExportJob—videodb/export.pyrefresh(),wait(),download_url(), plusstatus,progress,done,failedandfidelity. Its own module rather thaneditor.py, which is already~1,200 lines.
An
ExportJobcarriestimeline_idand builds its own path from it — an export isread back under the timeline that produced it. A job built from a response without
one raises a sentence saying so, rather than composing a malformed path and
reporting whatever error comes back.
Package export and docs
ExportJobis exported from the package root and listed in__all__, alongsideGenerationJob,Sandbox,VoiceCloneandCaptureSession. README gains anexample next to the existing Timeline one.
Design decisions, each with a test
download_url()is a method, not a property. What it returns is a signed URLwith a short life. A property invites caching, and a cached signed URL works in
testing and fails a day later. Minted per call, never held on the job.
doneandfailedare bothFalsefor an unrecognised status. The statusvocabulary can grow, and reporting an unknown status as done sends a caller after
an artifact that does not exist. Waiting on a status the client cannot interpret is
the recoverable mistake.
wait()raisesTimeoutErrorrather than returning a still-running job. Acaller handed an unfinished job by a method named
waitwill treat it as finished.Optional fields are omitted, not sent as null. Absent means "fall back to the
timeline id"; null means "there is no name". Different answers.
A submit response with no
job_idraises. A job that cannot be refreshed,waited on or downloaded is not a job — failing at the call site names the problem
instead of deferring it to whichever attribute is touched first.
fidelityis surfaced deliberately. Not everything in a timeline has anequivalent in a project file. An export that succeeds while dropping colour grades
is not a plain success, and a client that cannot see that will report it as one.
Flow
sequenceDiagram participant C as Your code participant S as SDK participant A as VideoDB API C->>S: timeline.export(format="nle") alt payload > MAX_PAYLOAD_SIZE S->>A: upload timeline JSON S->>A: POST editor/export {timeline_url} else S->>A: POST editor/export {timeline} end A-->>S: 202 {job_id, timeline_id} S-->>C: ExportJob loop job.wait() C->>S: refresh() S->>A: GET editor/export/{timeline}/{job} A-->>S: status, progress end C->>S: download_url() S->>A: GET editor/export/{timeline}/{job}/download A-->>S: {download_url} — signed, short-lived S-->>C: urlCompatibility
Additive. One new module, one new method, one new package export. No existing
signature or behaviour changes. No new dependencies. Nothing to migrate.
Requires
Server-side support for
POST editor/export,GET editor/export/{timeline_id}/{job_id}and its/download. Until those areavailable,
export()raisesInvalidRequestError. Nothing else in the SDK isaffected.
Verification
20 tests, all against a stub connection — no network, no credentials.
Also exercised against a running server end to end: submit,
refresh(),wait()polling a real export through to
done, anddownload_url()resolving to adownloadable bundle. That run is what surfaced the timeline-scoped read — no stub
could have.
Known follow-up
download_url()is annotated-> strbut can returnNonewhen the responsecarries no URL.