Skip to content

feat: timeline.export() for NLE project bundles - #102

Open
videodb-kal wants to merge 5 commits into
video-db:mainfrom
videodb-kal:feat/nle-export-client
Open

feat: timeline.export() for NLE project bundles#102
videodb-kal wants to merge 5 commits into
video-db:mainfrom
videodb-kal:feat/nle-export-client

Conversation

@videodb-kal

@videodb-kal videodb-kal commented Jul 31, 2026

Copy link
Copy Markdown

timeline.export() — NLE project bundles

Adds 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 the
project": 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 and
returns a job.

job = timeline.export(format="nle", name="My cut")

job.wait()
if job.done:
    url = job.download_url()
    print(job.fidelity)

What's here

Timeline.export()

Mirrors generate_stream rather than inventing a shape: serialize to_json(),
POST it inline under editor, and fall back to uploading the JSON when it exceeds
MAX_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_stream
already solved it; export gets the solution rather than rediscovering it the first
time someone exports a feature-length timeline.

ExportJobvideodb/export.py

refresh(), wait(), download_url(), plus status, progress, done,
failed and fidelity. Its own module rather than editor.py, which is already
~1,200 lines.

An ExportJob carries timeline_id and builds its own path from it — an export is
read 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

ExportJob is exported from the package root and listed in __all__, alongside
GenerationJob, Sandbox, VoiceClone and CaptureSession. README gains an
example 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 URL
with 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.

done and failed are both False for an unrecognised status. The status
vocabulary 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() 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, 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_id raises. 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.

fidelity is surfaced deliberately. Not everything in a timeline has an
equivalent 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: url
Loading

Compatibility

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 are
available, export() raises InvalidRequestError. Nothing else in the SDK is
affected.


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, and download_url() resolving to a
downloadable bundle. That run is what surfaced the timeline-scoped read — no stub
could have.

Known follow-up

download_url() is annotated -> str but can return None when the response
carries no URL.

videodb-kal and others added 5 commits July 30, 2026 11:11
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant