From 2434376534b7cb3e30eb26bbe68b9a709829f8f3 Mon Sep 17 00:00:00 2001 From: videodb-kal Date: Thu, 30 Jul 2026 11:11:00 +0530 Subject: [PATCH 1/6] feat(export): timeline.export() for NLE project bundles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01CwxN3uj28RUVbPVYVnYztw --- tests/test_export.py | 220 +++++++++++++++++++++++++++++++++++++++++++ videodb/editor.py | 59 ++++++++++++ videodb/export.py | 162 +++++++++++++++++++++++++++++++ 3 files changed, 441 insertions(+) create mode 100644 tests/test_export.py create mode 100644 videodb/export.py diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..be8770f --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,220 @@ +"""Submitting a timeline for NLE export, and following the job. + +The SDK is a pure HTTP client, so all of this is provable against a stub +connection — no network, no platform, no credentials. + +The shape mirrors ``Timeline.generate_stream``, which is the established way this +SDK asks the platform to do something with a timeline: serialize ``to_json()``, +POST it inline, 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, and the payload-size fallback +comes along for free rather than being rediscovered the first time somebody +exports a long timeline. +""" + +import pytest + +from videodb.editor import MAX_PAYLOAD_SIZE, Timeline +from videodb.export import ExportJob + + +class StubConnection: + """Records requests and replays scripted responses.""" + + def __init__(self, *responses): + self._responses = list(responses) + self.posts = [] + self.gets = [] + + def post(self, path, data=None, **kwargs): + self.posts.append({"path": path, "data": data}) + return self._next() + + def get(self, path, params=None, **kwargs): + self.gets.append({"path": path, "params": params}) + return self._next() + + def _next(self): + return self._responses.pop(0) if self._responses else {} + + +def _timeline(conn): + """A bare timeline is enough here. + + These tests are about the request export() builds, not about how tracks are + assembled — to_json() produces the same envelope either way, and a populated + track would couple them to Track's API for nothing. + """ + return Timeline(conn) + + +SUBMITTED = {"job_id": "exp_abc123def456", "status": "queued", "progress": 0} + + +# ------------------------------------------------------------------- submit + + +def test_export_posts_the_timeline_inline(): + """The same payload shape generate_stream sends, to a path under `editor`.""" + conn = StubConnection(SUBMITTED) + _timeline(conn).export() + assert conn.posts[0]["path"] == "editor/export" + assert "timeline" in conn.posts[0]["data"] + + +def test_export_returns_a_job_without_waiting(): + """An export is minutes of downloads and encoding. A call that blocked by + default would make every caller discover that the hard way.""" + conn = StubConnection(SUBMITTED) + job = _timeline(conn).export() + assert isinstance(job, ExportJob) + assert job.id == "exp_abc123def456" + assert job.status == "queued" + + +def test_the_format_is_named_rather_than_assumed(): + """So a second format is additive rather than a breaking change.""" + conn = StubConnection(SUBMITTED) + _timeline(conn).export() + assert conn.posts[0]["data"]["format"] == "nle" + + +def test_optional_fields_are_omitted_rather_than_sent_as_null(): + """A null `name` is not the same as no name — the service falls back to the + timeline id when the key is absent, and to nothing when it is null.""" + conn = StubConnection(SUBMITTED) + _timeline(conn).export() + data = conn.posts[0]["data"] + assert "name" not in data + assert "client_ref" not in data + assert "timeline_id" not in data + + +def test_supplied_fields_are_forwarded(): + conn = StubConnection(SUBMITTED) + _timeline(conn).export(name="Demo cut", client_ref="ours-1", timeline_id="tl-9") + data = conn.posts[0]["data"] + assert data["name"] == "Demo cut" + assert data["client_ref"] == "ours-1" + assert data["timeline_id"] == "tl-9" + + +def test_a_large_timeline_is_uploaded_rather_than_posted_inline(monkeypatch): + """The reason to mirror generate_stream rather than invent a shape. + + These requests cross an API gateway with a hard body cap, so a long timeline + posted inline fails at the edge with nothing useful in the response. The + render path already solved this; export inherits the solution. + """ + conn = StubConnection(SUBMITTED) + timeline = _timeline(conn) + monkeypatch.setattr( + Timeline, "_upload_timeline_data", lambda self, json_str: "https://x/timeline.json" + ) + monkeypatch.setattr("videodb.editor.MAX_PAYLOAD_SIZE", 1) + timeline.export() + data = conn.posts[0]["data"] + assert data["timeline_url"] == "https://x/timeline.json" + assert "timeline" not in data + + +def test_the_inline_threshold_is_the_one_the_render_path_uses(): + """Two different thresholds would mean a timeline that renders and does not + export, for no reason a caller could discover.""" + assert MAX_PAYLOAD_SIZE == 100 * 1024 + + +def test_a_response_without_a_job_id_is_an_error_not_a_broken_job(): + """A job object with no id cannot be polled, refreshed or downloaded. Failing + at the call site names the problem; returning one defers it to whichever + attribute is touched first.""" + conn = StubConnection({"status": "queued"}) + with pytest.raises(ValueError, match="job_id"): + _timeline(conn).export() + + +# -------------------------------------------------------------- following it + + +def test_refresh_reads_the_job_and_updates_in_place(): + conn = StubConnection({"job_id": "exp_a", "status": "rendering", "progress": 40}) + job = ExportJob(conn, job_id="exp_a", status="queued", progress=0) + job.refresh() + assert conn.gets[0]["path"] == "editor/export/exp_a" + assert job.status == "rendering" + assert job.progress == 40 + + +def test_done_and_failed_describe_the_two_terminal_states(): + conn = StubConnection() + assert ExportJob(conn, job_id="a", status="done").done is True + assert ExportJob(conn, job_id="a", status="error").failed is True + assert ExportJob(conn, job_id="a", status="rendering").done is False + assert ExportJob(conn, job_id="a", status="rendering").failed is False + + +def test_an_unknown_status_is_neither_done_nor_failed(): + """The platform's status vocabulary can grow. Reporting an unrecognised + status as done would have a caller download an artifact that is not there.""" + job = ExportJob(StubConnection(), job_id="a", status="transmogrifying") + assert job.done is False + assert job.failed is False + + +def test_wait_polls_until_terminal(): + conn = StubConnection( + {"job_id": "exp_a", "status": "rendering", "progress": 10}, + {"job_id": "exp_a", "status": "packaging", "progress": 80}, + {"job_id": "exp_a", "status": "done", "progress": 100}, + ) + job = ExportJob(conn, job_id="exp_a", status="queued") + job.wait(poll_interval=0) + assert job.status == "done" + assert len(conn.gets) == 3 + + +def test_wait_returns_on_a_failed_job_rather_than_polling_forever(): + conn = StubConnection({"job_id": "exp_a", "status": "error"}) + job = ExportJob(conn, job_id="exp_a", status="queued") + assert job.wait(poll_interval=0).failed is True + + +def test_wait_gives_up_and_says_so(): + """Silently returning a still-running job would have the caller treat an + unfinished export as finished.""" + conn = StubConnection(*[{"job_id": "exp_a", "status": "rendering"}] * 50) + job = ExportJob(conn, job_id="exp_a", status="queued") + with pytest.raises(TimeoutError): + job.wait(timeout=0, poll_interval=0) + + +def test_download_url_is_a_method_because_the_link_expires(): + """A property invites caching, and what would be cached is a signed URL with + a short life. Minted per call, never stored on the job.""" + conn = StubConnection({"download_url": "https://storage/bundle.zip?sig=1"}) + job = ExportJob(conn, job_id="exp_a", status="done") + assert job.download_url() == "https://storage/bundle.zip?sig=1" + assert not hasattr(job, "_download_url") + + +def test_download_url_refuses_before_the_job_is_done(): + """There is nothing to sign yet, and a 410 from the platform is a worse + explanation than the one available here.""" + job = ExportJob(StubConnection(), job_id="exp_a", status="rendering") + with pytest.raises(ValueError, match="not finished"): + job.download_url() + + +def test_the_fidelity_summary_reaches_the_caller(): + """An export that dropped the user's colour grades is not a plain success, + and a client that cannot see that reports it as one.""" + conn = StubConnection( + { + "job_id": "exp_a", + "status": "done", + "fidelity": {"counts": {"carried": 11, "dropped": 1}, "missing_media": []}, + } + ) + job = ExportJob(conn, job_id="exp_a", status="queued") + job.refresh() + assert job.fidelity["counts"]["dropped"] == 1 diff --git a/videodb/editor.py b/videodb/editor.py index 85f0981..91fa7b5 100644 --- a/videodb/editor.py +++ b/videodb/editor.py @@ -1140,6 +1140,65 @@ def generate_stream(self) -> str: self.player_url = stream_data.get("player_url") return stream_data.get("stream_url", None) + def export( + self, + format: str = "nle", + name: str = None, + client_ref: str = None, + timeline_id: str = None, + ) -> "ExportJob": + """Export this timeline as an editable NLE project bundle. + + Produces a zip containing FCP7 XML, OTIO, EDL, captions and the media the + sequence references — a project a human opens in Premiere, rather than a + rendered video. + + The work is minutes of downloads and encoding, so this submits and returns + immediately. Poll the returned job, or call its ``wait()``. + + The payload is the same shape :meth:`generate_stream` sends, including the + fallback that uploads the timeline JSON when it exceeds + ``MAX_PAYLOAD_SIZE`` — these requests cross a gateway with a hard body cap, + and a long timeline posted inline fails at the edge with nothing useful in + the response. + + :param str format: Bundle format. Named rather than assumed so a second + format is additive (default ``"nle"``) + :param str name: Sequence name in the NLE's project panel. Falls back to the + timeline id — an unnamed sequence is a worse import than an ugly one + :param str client_ref: Your own identifier, echoed back on the job, so you + never have to hold ours to correlate + :param str timeline_id: Your identifier for this timeline. The platform + assigns one when omitted + :return: The submitted job + :rtype: :class:`videodb.export.ExportJob` + """ + from videodb.export import job_from_response + + timeline_data = self.to_json() + json_str = json.dumps(timeline_data) + + if len(json_str.encode("utf-8")) > MAX_PAYLOAD_SIZE: + data = {"timeline_url": self._upload_timeline_data(json_str)} + else: + data = dict(timeline_data) + + data["format"] = format + # Omitted rather than sent as null: absent means "fall back to the + # timeline id", null means "there is no name", and those differ. + for key, value in ( + ("name", name), + ("client_ref", client_ref), + ("timeline_id", timeline_id), + ): + if value is not None: + data[key] = value + + return job_from_response( + self.connection, + self.connection.post(path=f"{ApiPath.editor}/export", data=data), + ) + def _upload_timeline_data(self, json_str: str) -> str: """Upload timeline JSON data as a file and return the URL. diff --git a/videodb/export.py b/videodb/export.py new file mode 100644 index 0000000..e0cd5ef --- /dev/null +++ b/videodb/export.py @@ -0,0 +1,162 @@ +"""Following an NLE export job. + +An export is minutes of multi-gigabyte downloads and encoding, so it cannot be a +synchronous call. :meth:`videodb.editor.Timeline.export` submits and returns one +of these immediately; the caller polls, or calls :meth:`ExportJob.wait`. + +Two shape decisions worth stating, because both are easy to get backwards. + +**``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 is a link +that works in testing and 403s a day later. It is minted per call and 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 does not exist. Waiting on a +status we cannot interpret is the recoverable mistake. +""" + +import time +from typing import Optional + +from videodb._constants import ApiPath + +#: Statuses that mean the job is over. Mirrors the export service's vocabulary. +DONE = "done" +ERROR = "error" +TERMINAL_STATUSES = (DONE, ERROR) + +#: Default ceiling for :meth:`ExportJob.wait`, in seconds. Matches the export +#: service's own per-job budget — waiting longer than the job can possibly run +#: only delays the disappointment. +DEFAULT_WAIT_TIMEOUT = 1800 + +#: How often :meth:`ExportJob.wait` polls, in seconds. The job takes minutes; +#: polling faster than this costs requests and buys nothing. +DEFAULT_POLL_INTERVAL = 5 + + +class ExportJob: + """A submitted export. + + :ivar str id: The platform's job id + :ivar str status: ``queued``, ``rendering``, ``converting``, ``packaging``, + ``done`` or ``error`` + :ivar int progress: 0-100 + :ivar str stage: Human-readable label for the current stage + :ivar str error: Failure message, when ``status`` is ``error`` + :ivar dict fidelity: What the bundle could and could not carry + """ + + def __init__( + self, + connection, + job_id: str, + status: Optional[str] = None, + progress: Optional[int] = None, + stage: Optional[str] = None, + error: Optional[str] = None, + fidelity: Optional[dict] = None, + **kwargs, + ) -> None: + self.connection = connection + self.id = job_id + self.status = status + self.progress = progress + self.stage = stage + self.error = error + self.fidelity = fidelity + + def __repr__(self) -> str: + return f"ExportJob(id={self.id!r}, status={self.status!r}, progress={self.progress!r})" + + @property + def done(self) -> bool: + """Whether the export finished successfully.""" + return self.status == DONE + + @property + def failed(self) -> bool: + """Whether the export ended in failure.""" + return self.status == ERROR + + @property + def terminal(self) -> bool: + """Whether the job is over, either way.""" + return self.status in TERMINAL_STATUSES + + def refresh(self) -> "ExportJob": + """Re-read the job and update this object in place. + + :return: self, so it can be chained + :rtype: :class:`ExportJob` + """ + data = self.connection.get(path=f"{ApiPath.editor}/export/{self.id}") or {} + self.status = data.get("status", self.status) + self.progress = data.get("progress", self.progress) + self.stage = data.get("stage", self.stage) + self.error = data.get("error", self.error) + if data.get("fidelity") is not None: + self.fidelity = data["fidelity"] + return self + + def wait( + self, + timeout: int = DEFAULT_WAIT_TIMEOUT, + poll_interval: int = DEFAULT_POLL_INTERVAL, + ) -> "ExportJob": + """Poll until the job is over. + + Returns on failure as well as success — ``error`` is a terminal state, and + polling a failed job forever is not more helpful than reporting it. + + :param int timeout: Seconds to wait before giving up + :param int poll_interval: Seconds between polls + :raises TimeoutError: if the job is still running when the budget runs out + :return: self + :rtype: :class:`ExportJob` + """ + deadline = time.monotonic() + timeout + while True: + self.refresh() + if self.terminal: + return self + if time.monotonic() >= deadline: + # Raised rather than returned: a caller handed a still-running job + # by a method named `wait` will treat it as finished. + raise TimeoutError( + f"export {self.id} was still {self.status!r} after {timeout}s" + ) + time.sleep(poll_interval) + + def download_url(self) -> str: + """A signed URL for the bundle, minted for this call. + + Not cached and not stored on the job — see the module docstring. + + :raises ValueError: if the job has not finished + :return: A URL valid for a limited time + :rtype: str + """ + if not self.done: + raise ValueError( + f"export {self.id} is not finished (status {self.status!r}); " + "there is no bundle to download yet" + ) + data = self.connection.get(path=f"{ApiPath.editor}/export/{self.id}/download") or {} + return data.get("download_url") + + +def job_from_response(connection, data: dict) -> ExportJob: + """Build an :class:`ExportJob` from a submit response. + + A response without a ``job_id`` is an error rather than a job: an + :class:`ExportJob` with no id cannot be refreshed, waited on or downloaded, + so failing here names the problem instead of deferring it to whichever + attribute the caller touches first. + """ + job_id = (data or {}).get("job_id") + if not job_id: + raise ValueError(f"export response carried no job_id: {data!r}") + return ExportJob(connection, **{**data, "job_id": job_id}) From d692334ecf91c26bdebef802ad56b0a4880cf567 Mon Sep 17 00:00:00 2001 From: videodb-kal Date: Thu, 30 Jul 2026 21:16:15 +0530 Subject: [PATCH 2/6] fix(export): read a job back under the timeline that produced it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01CwxN3uj28RUVbPVYVnYztw --- tests/test_export.py | 47 ++++++++++++++++++++++++++++++++------------ videodb/export.py | 19 ++++++++++++++++-- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/tests/test_export.py b/tests/test_export.py index be8770f..1fada68 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -48,7 +48,7 @@ def _timeline(conn): return Timeline(conn) -SUBMITTED = {"job_id": "exp_abc123def456", "status": "queued", "progress": 0} +SUBMITTED = {"job_id": "exp_abc123def456", "timeline_id": "tl-9", "status": "queued", "progress": 0} # ------------------------------------------------------------------- submit @@ -138,25 +138,25 @@ def test_a_response_without_a_job_id_is_an_error_not_a_broken_job(): def test_refresh_reads_the_job_and_updates_in_place(): conn = StubConnection({"job_id": "exp_a", "status": "rendering", "progress": 40}) - job = ExportJob(conn, job_id="exp_a", status="queued", progress=0) + job = ExportJob(conn, job_id="exp_a", timeline_id="tl-9", status="queued", progress=0) job.refresh() - assert conn.gets[0]["path"] == "editor/export/exp_a" + assert conn.gets[0]["path"] == "editor/export/tl-9/exp_a" assert job.status == "rendering" assert job.progress == 40 def test_done_and_failed_describe_the_two_terminal_states(): conn = StubConnection() - assert ExportJob(conn, job_id="a", status="done").done is True - assert ExportJob(conn, job_id="a", status="error").failed is True - assert ExportJob(conn, job_id="a", status="rendering").done is False - assert ExportJob(conn, job_id="a", status="rendering").failed is False + assert ExportJob(conn, job_id="a", timeline_id="tl-9", status="done").done is True + assert ExportJob(conn, job_id="a", timeline_id="tl-9", status="error").failed is True + assert ExportJob(conn, job_id="a", timeline_id="tl-9", status="rendering").done is False + assert ExportJob(conn, job_id="a", timeline_id="tl-9", status="rendering").failed is False def test_an_unknown_status_is_neither_done_nor_failed(): """The platform's status vocabulary can grow. Reporting an unrecognised status as done would have a caller download an artifact that is not there.""" - job = ExportJob(StubConnection(), job_id="a", status="transmogrifying") + job = ExportJob(StubConnection(), job_id="a", timeline_id="tl-9", status="transmogrifying") assert job.done is False assert job.failed is False @@ -167,7 +167,7 @@ def test_wait_polls_until_terminal(): {"job_id": "exp_a", "status": "packaging", "progress": 80}, {"job_id": "exp_a", "status": "done", "progress": 100}, ) - job = ExportJob(conn, job_id="exp_a", status="queued") + job = ExportJob(conn, job_id="exp_a", timeline_id="tl-9", status="queued") job.wait(poll_interval=0) assert job.status == "done" assert len(conn.gets) == 3 @@ -175,7 +175,7 @@ def test_wait_polls_until_terminal(): def test_wait_returns_on_a_failed_job_rather_than_polling_forever(): conn = StubConnection({"job_id": "exp_a", "status": "error"}) - job = ExportJob(conn, job_id="exp_a", status="queued") + job = ExportJob(conn, job_id="exp_a", timeline_id="tl-9", status="queued") assert job.wait(poll_interval=0).failed is True @@ -183,7 +183,7 @@ def test_wait_gives_up_and_says_so(): """Silently returning a still-running job would have the caller treat an unfinished export as finished.""" conn = StubConnection(*[{"job_id": "exp_a", "status": "rendering"}] * 50) - job = ExportJob(conn, job_id="exp_a", status="queued") + job = ExportJob(conn, job_id="exp_a", timeline_id="tl-9", status="queued") with pytest.raises(TimeoutError): job.wait(timeout=0, poll_interval=0) @@ -192,7 +192,7 @@ def test_download_url_is_a_method_because_the_link_expires(): """A property invites caching, and what would be cached is a signed URL with a short life. Minted per call, never stored on the job.""" conn = StubConnection({"download_url": "https://storage/bundle.zip?sig=1"}) - job = ExportJob(conn, job_id="exp_a", status="done") + job = ExportJob(conn, job_id="exp_a", timeline_id="tl-9", status="done") assert job.download_url() == "https://storage/bundle.zip?sig=1" assert not hasattr(job, "_download_url") @@ -215,6 +215,27 @@ def test_the_fidelity_summary_reaches_the_caller(): "fidelity": {"counts": {"carried": 11, "dropped": 1}, "missing_media": []}, } ) - job = ExportJob(conn, job_id="exp_a", status="queued") + job = ExportJob(conn, job_id="exp_a", timeline_id="tl-9", status="queued") job.refresh() assert job.fidelity["counts"]["dropped"] == 1 + + +def test_a_job_read_back_is_addressed_under_its_timeline(): + """The read is scoped by timeline, which is what makes another user's job id + a 404 rather than a leak on the platform side.""" + conn = StubConnection({"job_id": "exp_a", "status": "done"}) + ExportJob(conn, job_id="exp_a", timeline_id="tl-9").refresh() + assert conn.gets[0]["path"] == "editor/export/tl-9/exp_a" + + +def test_a_job_without_a_timeline_says_so_rather_than_guessing(): + """A submit response that carried no timeline_id yields a job that cannot be + read back. Failing with that sentence beats a 404 from a malformed path.""" + job = ExportJob(StubConnection(), job_id="exp_a") + with pytest.raises(ValueError, match="timeline_id"): + job.refresh() + + +def test_the_submitted_job_remembers_its_timeline(): + conn = StubConnection(SUBMITTED) + assert _timeline(conn).export().timeline_id == "tl-9" diff --git a/videodb/export.py b/videodb/export.py index e0cd5ef..c3d7dc2 100644 --- a/videodb/export.py +++ b/videodb/export.py @@ -41,6 +41,7 @@ class ExportJob: """A submitted export. :ivar str id: The platform's job id + :ivar str timeline_id: The timeline this export was made from :ivar str status: ``queued``, ``rendering``, ``converting``, ``packaging``, ``done`` or ``error`` :ivar int progress: 0-100 @@ -53,6 +54,7 @@ def __init__( self, connection, job_id: str, + timeline_id: Optional[str] = None, status: Optional[str] = None, progress: Optional[int] = None, stage: Optional[str] = None, @@ -62,6 +64,10 @@ def __init__( ) -> None: self.connection = connection self.id = job_id + # Part of the address, not decoration. An export is read back under the + # timeline that produced it, which is what scopes the read to its owner — + # a job id alone would have to be trusted on its own. + self.timeline_id = timeline_id self.status = status self.progress = progress self.stage = stage @@ -71,6 +77,15 @@ def __init__( def __repr__(self) -> str: return f"ExportJob(id={self.id!r}, status={self.status!r}, progress={self.progress!r})" + def _path(self) -> str: + """Where this job lives. The timeline scopes the read to its owner.""" + if not self.timeline_id: + raise ValueError( + f"export {self.id} has no timeline_id, so it cannot be read back; " + "it was built from a response that did not carry one" + ) + return f"{ApiPath.editor}/export/{self.timeline_id}/{self.id}" + @property def done(self) -> bool: """Whether the export finished successfully.""" @@ -92,7 +107,7 @@ def refresh(self) -> "ExportJob": :return: self, so it can be chained :rtype: :class:`ExportJob` """ - data = self.connection.get(path=f"{ApiPath.editor}/export/{self.id}") or {} + data = self.connection.get(path=self._path()) or {} self.status = data.get("status", self.status) self.progress = data.get("progress", self.progress) self.stage = data.get("stage", self.stage) @@ -144,7 +159,7 @@ def download_url(self) -> str: f"export {self.id} is not finished (status {self.status!r}); " "there is no bundle to download yet" ) - data = self.connection.get(path=f"{ApiPath.editor}/export/{self.id}/download") or {} + data = self.connection.get(path=f"{self._path()}/download") or {} return data.get("download_url") From 2d4e40cd5dbe9757e5da8ac84f317e14135917a3 Mon Sep 17 00:00:00 2001 From: videodb-kal Date: Fri, 31 Jul 2026 09:06:10 +0530 Subject: [PATCH 3/6] docs(export): export ExportJob at top level and document the surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01CwxN3uj28RUVbPVYVnYztw --- README.md | 20 ++++++++++++++++++++ videodb/__init__.py | 2 ++ 2 files changed, 22 insertions(+) diff --git a/README.md b/README.md index d10f4a2..ed9cd2d 100644 --- a/README.md +++ b/README.md @@ -495,6 +495,26 @@ timeline.add_track(audio_track) stream_url = timeline.generate_stream() ``` +**Example: Export as an editable Premiere Pro project** + +A timeline can be exported as an NLE bundle instead of a rendered video — the +cuts, text and captions as a Premiere project, plus the media the sequence +references. The work takes minutes, so `export()` returns immediately and the job +is polled. + +```python +job = timeline.export(format="nle", name="My cut") + +job.wait() # or poll job.refresh() yourself +if job.done: + url = job.download_url() # signed, minted per call — do not cache it + print(job.fidelity) # what carried over, and what could not +``` + +Not everything in a timeline has an equivalent in a project file. `job.fidelity` +reports per-disposition counts so a successful export that dropped colour grades +is not reported as a plain success. + **Asset Types:** - `VideoAsset` - Video clips with trim control (`start`, `volume`) - `AudioAsset` - Background music, voiceovers, sound effects diff --git a/videodb/__init__.py b/videodb/__init__.py index 79293de..5b17875 100644 --- a/videodb/__init__.py +++ b/videodb/__init__.py @@ -32,6 +32,7 @@ from videodb.search import AskResponse, SearchResponse, SearchResult from videodb.understanding import Understanding, UnderstandingAnalyzer from videodb.job import GenerationJob +from videodb.export import ExportJob from videodb.sandbox import Sandbox from videodb.voice_clone import VoiceClone from videodb.capture_session import CaptureSession @@ -51,6 +52,7 @@ __all__ = [ "connect", "CaptureSession", + "ExportJob", "GenerationJob", "Sandbox", "VoiceClone", From db96d39467e7be8ca5fe5440880768a83bef280a Mon Sep 17 00:00:00 2001 From: videodb-kal Date: Fri, 31 Jul 2026 21:55:07 +0530 Subject: [PATCH 4/6] docs: describe the SDK's own behaviour, not the service behind it 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) Claude-Session: https://claude.ai/code/session_01CwxN3uj28RUVbPVYVnYztw --- tests/test_export.py | 6 +++--- videodb/editor.py | 5 ++--- videodb/export.py | 8 ++++---- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/tests/test_export.py b/tests/test_export.py index 1fada68..ec851d1 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -102,9 +102,9 @@ def test_supplied_fields_are_forwarded(): def test_a_large_timeline_is_uploaded_rather_than_posted_inline(monkeypatch): """The reason to mirror generate_stream rather than invent a shape. - These requests cross an API gateway with a hard body cap, so a long timeline - posted inline fails at the edge with nothing useful in the response. The - render path already solved this; export inherits the solution. + A long timeline can exceed the request body limit, and posting it inline + then fails with nothing useful in the response. generate_stream already + solved this by uploading and referencing by URL; export inherits it. """ conn = StubConnection(SUBMITTED) timeline = _timeline(conn) diff --git a/videodb/editor.py b/videodb/editor.py index 91fa7b5..079c8ee 100644 --- a/videodb/editor.py +++ b/videodb/editor.py @@ -1158,9 +1158,8 @@ def export( The payload is the same shape :meth:`generate_stream` sends, including the fallback that uploads the timeline JSON when it exceeds - ``MAX_PAYLOAD_SIZE`` — these requests cross a gateway with a hard body cap, - and a long timeline posted inline fails at the edge with nothing useful in - the response. + ``MAX_PAYLOAD_SIZE``. A long timeline posted inline can exceed the request + body limit, so it is uploaded and referenced by URL instead. :param str format: Bundle format. Named rather than assumed so a second format is additive (default ``"nle"``) diff --git a/videodb/export.py b/videodb/export.py index c3d7dc2..0646a43 100644 --- a/videodb/export.py +++ b/videodb/export.py @@ -22,14 +22,14 @@ from videodb._constants import ApiPath -#: Statuses that mean the job is over. Mirrors the export service's vocabulary. +#: Statuses that mean the job is over. DONE = "done" ERROR = "error" TERMINAL_STATUSES = (DONE, ERROR) -#: Default ceiling for :meth:`ExportJob.wait`, in seconds. Matches the export -#: service's own per-job budget — waiting longer than the job can possibly run -#: only delays the disappointment. +#: Default ceiling for :meth:`ExportJob.wait`, in seconds. Half an hour is +#: longer than an export is expected to take, so a wait that reaches it means +#: something is wrong rather than slow. DEFAULT_WAIT_TIMEOUT = 1800 #: How often :meth:`ExportJob.wait` polls, in seconds. The job takes minutes; From 9fe7b34d8b49717c66fca72dc1634ca4ddcca8ed Mon Sep 17 00:00:00 2001 From: videodb-kal Date: Fri, 31 Jul 2026 23:05:33 +0530 Subject: [PATCH 5/6] fix(export): download_url honours its own type annotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01CwxN3uj28RUVbPVYVnYztw --- tests/test_export.py | 14 ++++++++++++++ videodb/export.py | 11 ++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/test_export.py b/tests/test_export.py index ec851d1..d10ab06 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -239,3 +239,17 @@ def test_a_job_without_a_timeline_says_so_rather_than_guessing(): def test_the_submitted_job_remembers_its_timeline(): conn = StubConnection(SUBMITTED) assert _timeline(conn).export().timeline_id == "tl-9" + + +def test_download_url_raises_rather_than_returning_none(): + """The method is annotated `-> str`, so a caller reasonably treats the + result as one. Returning None pushes the failure into whatever it is handed + to — an opener, an HTTP call, a log line reading "None" — by which point + nothing points back at the export that had no bundle. + """ + conn = StubConnection({}) # a download response carrying no URL + job = ExportJob(conn, SUBMITTED["job_id"], timeline_id=SUBMITTED["timeline_id"], + status="done") + + with pytest.raises(ValueError, match="no download URL"): + job.download_url() diff --git a/videodb/export.py b/videodb/export.py index 0646a43..9c0a599 100644 --- a/videodb/export.py +++ b/videodb/export.py @@ -160,7 +160,16 @@ def download_url(self) -> str: "there is no bundle to download yet" ) data = self.connection.get(path=f"{self._path()}/download") or {} - return data.get("download_url") + url = data.get("download_url") + if not url: + # Returning None from something annotated -> str pushes the failure + # into whatever the caller does with it — an opener, a request, a + # log line reading "None" — and by then nothing points back here. + raise ValueError( + f"export {self.id} finished but no download URL was returned; " + "the bundle may have expired" + ) + return url def job_from_response(connection, data: dict) -> ExportJob: From 3fd76d246f4191fa9f6d016afa54bc3484150fb0 Mon Sep 17 00:00:00 2001 From: videodb-kal Date: Tue, 11 Aug 2026 14:16:23 +0530 Subject: [PATCH 6/6] fix(export): raise VideodbError subclasses, resolve the return annotation, document what ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExportJob raised builtin TimeoutError/ValueError where its sibling GenerationJob raises RequestTimeoutError/InvalidRequestError; a documented `except VideodbError` catch-all handled one job type's timeout and crashed on the other's. Public surface — the types have to be right at first release, so they are now the package's own. - Timeline.export's `-> "ExportJob"` had no import in scope: typing.get_type_hints raised NameError for every annotation resolver. videodb.export imports nothing from editor, so the import is real, top-level, and the late local import is gone. - job_from_response accepts `id` as well as `job_id`, the same pair GenerationJob.from_data accepts — a submit answering the other spelling must not hard-fail after the job was created. - `.job_id` alias to match GenerationJob; the connection attribute is `_connection` like every peer. - Paths compose from ApiPath.export instead of a literal. - README: the example now has a failure branch (wait() returns on failure), and the fidelity paragraph points at the bundle's own report instead of promising a field the status response does not populate. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018hU2Vo3r5DYZVFiowNaVZK --- README.md | 9 ++++--- tests/test_export.py | 59 ++++++++++++++++++++++++++++++++++++++------ videodb/editor.py | 5 ++-- videodb/export.py | 41 ++++++++++++++++++------------ 4 files changed, 84 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index ed9cd2d..95c7c74 100644 --- a/README.md +++ b/README.md @@ -508,12 +508,13 @@ job = timeline.export(format="nle", name="My cut") job.wait() # or poll job.refresh() yourself if job.done: url = job.download_url() # signed, minted per call — do not cache it - print(job.fidelity) # what carried over, and what could not +elif job.failed: + print(job.error) # wait() returns on failure too; check which ``` -Not everything in a timeline has an equivalent in a project file. `job.fidelity` -reports per-disposition counts so a successful export that dropped colour grades -is not reported as a plain success. +Not everything in a timeline has an equivalent in a project file — the bundle +includes a fidelity report (`fidelity.md`) saying what carried over and what +could not. **Asset Types:** - `VideoAsset` - Video clips with trim control (`start`, `volume`) diff --git a/tests/test_export.py b/tests/test_export.py index d10ab06..7b25f88 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -15,6 +15,11 @@ import pytest from videodb.editor import MAX_PAYLOAD_SIZE, Timeline +from videodb.exceptions import ( + InvalidRequestError, + RequestTimeoutError, + VideodbError, +) from videodb.export import ExportJob @@ -129,7 +134,7 @@ def test_a_response_without_a_job_id_is_an_error_not_a_broken_job(): at the call site names the problem; returning one defers it to whichever attribute is touched first.""" conn = StubConnection({"status": "queued"}) - with pytest.raises(ValueError, match="job_id"): + with pytest.raises(InvalidRequestError, match="job_id"): _timeline(conn).export() @@ -184,7 +189,7 @@ def test_wait_gives_up_and_says_so(): unfinished export as finished.""" conn = StubConnection(*[{"job_id": "exp_a", "status": "rendering"}] * 50) job = ExportJob(conn, job_id="exp_a", timeline_id="tl-9", status="queued") - with pytest.raises(TimeoutError): + with pytest.raises(RequestTimeoutError): job.wait(timeout=0, poll_interval=0) @@ -201,7 +206,7 @@ def test_download_url_refuses_before_the_job_is_done(): """There is nothing to sign yet, and a 410 from the platform is a worse explanation than the one available here.""" job = ExportJob(StubConnection(), job_id="exp_a", status="rendering") - with pytest.raises(ValueError, match="not finished"): + with pytest.raises(InvalidRequestError, match="not finished"): job.download_url() @@ -221,8 +226,8 @@ def test_the_fidelity_summary_reaches_the_caller(): def test_a_job_read_back_is_addressed_under_its_timeline(): - """The read is scoped by timeline, which is what makes another user's job id - a 404 rather than a leak on the platform side.""" + """The read is addressed under the timeline that produced the job — the + path shape the API defines.""" conn = StubConnection({"job_id": "exp_a", "status": "done"}) ExportJob(conn, job_id="exp_a", timeline_id="tl-9").refresh() assert conn.gets[0]["path"] == "editor/export/tl-9/exp_a" @@ -232,7 +237,7 @@ def test_a_job_without_a_timeline_says_so_rather_than_guessing(): """A submit response that carried no timeline_id yields a job that cannot be read back. Failing with that sentence beats a 404 from a malformed path.""" job = ExportJob(StubConnection(), job_id="exp_a") - with pytest.raises(ValueError, match="timeline_id"): + with pytest.raises(InvalidRequestError, match="timeline_id"): job.refresh() @@ -251,5 +256,45 @@ def test_download_url_raises_rather_than_returning_none(): job = ExportJob(conn, SUBMITTED["job_id"], timeline_id=SUBMITTED["timeline_id"], status="done") - with pytest.raises(ValueError, match="no download URL"): + with pytest.raises(InvalidRequestError, match="no download URL"): job.download_url() + + +def test_every_export_failure_is_a_videodb_error(): + """The package promises `except VideodbError` catches SDK failures, and + GenerationJob keeps that promise — a sibling raising builtins alongside it + means the documented catch-all handles one job type and crashes on the + other. Public surface: the types must be right at first release.""" + job = ExportJob(StubConnection(), job_id="exp_a") + with pytest.raises(VideodbError): + job.refresh() # no timeline_id + slow = StubConnection(*[{"job_id": "exp_a", "status": "rendering"}] * 5) + running = ExportJob(slow, job_id="exp_a", timeline_id="tl-9") + with pytest.raises(VideodbError): + running.wait(timeout=0, poll_interval=0) + + +def test_a_submit_answering_id_instead_of_job_id_still_makes_a_job(): + """GenerationJob.from_data accepts either key, and endpoints have answered + with both shapes. A submit that hard-fails AFTER the job was created costs + the user a running export they cannot see.""" + conn = StubConnection({"id": "exp_b", "timeline_id": "tl-9", "status": "queued"}) + job = _timeline(conn).export() + assert job.id == "exp_b" + + +def test_job_id_is_an_alias_for_id(): + """GenerationJob exposes both spellings; a caller moving between the two + job types should not need to remember which one this is.""" + job = ExportJob(StubConnection(), job_id="exp_a") + assert job.job_id == "exp_a" + + +def test_the_export_annotation_resolves(): + """`-> "ExportJob"` with no import in scope breaks every annotation + resolver (typeguard, sphinx, pydantic) that calls get_type_hints on a + public method.""" + import typing + + hints = typing.get_type_hints(Timeline.export) + assert hints["return"].__name__ == "ExportJob" diff --git a/videodb/editor.py b/videodb/editor.py index 079c8ee..95ac4d0 100644 --- a/videodb/editor.py +++ b/videodb/editor.py @@ -7,6 +7,7 @@ from videodb._constants import ApiPath from videodb._upload import upload_bytes +from videodb.export import ExportJob, job_from_response from videodb._utils._video import build_iframe_embed_code from videodb.exceptions import InvalidRequestError @@ -1172,8 +1173,6 @@ def export( :return: The submitted job :rtype: :class:`videodb.export.ExportJob` """ - from videodb.export import job_from_response - timeline_data = self.to_json() json_str = json.dumps(timeline_data) @@ -1195,7 +1194,7 @@ def export( return job_from_response( self.connection, - self.connection.post(path=f"{ApiPath.editor}/export", data=data), + self.connection.post(path=f"{ApiPath.editor}/{ApiPath.export}", data=data), ) def _upload_timeline_data(self, json_str: str) -> str: diff --git a/videodb/export.py b/videodb/export.py index 9c0a599..0ab6926 100644 --- a/videodb/export.py +++ b/videodb/export.py @@ -21,6 +21,7 @@ from typing import Optional from videodb._constants import ApiPath +from videodb.exceptions import InvalidRequestError, RequestTimeoutError #: Statuses that mean the job is over. DONE = "done" @@ -62,11 +63,10 @@ def __init__( fidelity: Optional[dict] = None, **kwargs, ) -> None: - self.connection = connection + self._connection = connection self.id = job_id - # Part of the address, not decoration. An export is read back under the - # timeline that produced it, which is what scopes the read to its owner — - # a job id alone would have to be trusted on its own. + # Part of the address, not decoration: an export is read back under + # the timeline that produced it. self.timeline_id = timeline_id self.status = status self.progress = progress @@ -77,14 +77,21 @@ def __init__( def __repr__(self) -> str: return f"ExportJob(id={self.id!r}, status={self.status!r}, progress={self.progress!r})" + @property + def job_id(self) -> str: + """Alias for :attr:`id` — :class:`videodb.job.GenerationJob` exposes + both spellings, and a caller moving between the two job types should + not need to remember which one this is.""" + return self.id + def _path(self) -> str: """Where this job lives. The timeline scopes the read to its owner.""" if not self.timeline_id: - raise ValueError( + raise InvalidRequestError( f"export {self.id} has no timeline_id, so it cannot be read back; " "it was built from a response that did not carry one" ) - return f"{ApiPath.editor}/export/{self.timeline_id}/{self.id}" + return f"{ApiPath.editor}/{ApiPath.export}/{self.timeline_id}/{self.id}" @property def done(self) -> bool: @@ -107,7 +114,7 @@ def refresh(self) -> "ExportJob": :return: self, so it can be chained :rtype: :class:`ExportJob` """ - data = self.connection.get(path=self._path()) or {} + data = self._connection.get(path=self._path()) or {} self.status = data.get("status", self.status) self.progress = data.get("progress", self.progress) self.stage = data.get("stage", self.stage) @@ -128,7 +135,8 @@ def wait( :param int timeout: Seconds to wait before giving up :param int poll_interval: Seconds between polls - :raises TimeoutError: if the job is still running when the budget runs out + :raises RequestTimeoutError: if the job is still running when the + budget runs out :return: self :rtype: :class:`ExportJob` """ @@ -140,7 +148,7 @@ def wait( if time.monotonic() >= deadline: # Raised rather than returned: a caller handed a still-running job # by a method named `wait` will treat it as finished. - raise TimeoutError( + raise RequestTimeoutError( f"export {self.id} was still {self.status!r} after {timeout}s" ) time.sleep(poll_interval) @@ -150,22 +158,22 @@ def download_url(self) -> str: Not cached and not stored on the job — see the module docstring. - :raises ValueError: if the job has not finished + :raises InvalidRequestError: if the job has not finished :return: A URL valid for a limited time :rtype: str """ if not self.done: - raise ValueError( + raise InvalidRequestError( f"export {self.id} is not finished (status {self.status!r}); " "there is no bundle to download yet" ) - data = self.connection.get(path=f"{self._path()}/download") or {} + data = self._connection.get(path=f"{self._path()}/download") or {} url = data.get("download_url") if not url: # Returning None from something annotated -> str pushes the failure # into whatever the caller does with it — an opener, a request, a # log line reading "None" — and by then nothing points back here. - raise ValueError( + raise InvalidRequestError( f"export {self.id} finished but no download URL was returned; " "the bundle may have expired" ) @@ -180,7 +188,8 @@ def job_from_response(connection, data: dict) -> ExportJob: so failing here names the problem instead of deferring it to whichever attribute the caller touches first. """ - job_id = (data or {}).get("job_id") + job_id = (data or {}).get("job_id") or (data or {}).get("id") if not job_id: - raise ValueError(f"export response carried no job_id: {data!r}") - return ExportJob(connection, **{**data, "job_id": job_id}) + raise InvalidRequestError(f"export response carried no job_id: {data!r}") + kwargs = {k: v for k, v in (data or {}).items() if k != "id"} + return ExportJob(connection, **{**kwargs, "job_id": job_id})