diff --git a/README.md b/README.md index d10f4a2..95c7c74 100644 --- a/README.md +++ b/README.md @@ -495,6 +495,27 @@ 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 +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 — 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`) - `AudioAsset` - Background music, voiceovers, sound effects diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..7b25f88 --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,300 @@ +"""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.exceptions import ( + InvalidRequestError, + RequestTimeoutError, + VideodbError, +) +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", "timeline_id": "tl-9", "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. + + 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) + 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(InvalidRequestError, 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", timeline_id="tl-9", status="queued", progress=0) + job.refresh() + 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", 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", timeline_id="tl-9", 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", timeline_id="tl-9", 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", timeline_id="tl-9", 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", timeline_id="tl-9", status="queued") + with pytest.raises(RequestTimeoutError): + 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", timeline_id="tl-9", 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(InvalidRequestError, 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", 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 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" + + +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(InvalidRequestError, match="timeline_id"): + job.refresh() + + +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(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/__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", diff --git a/videodb/editor.py b/videodb/editor.py index 85f0981..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 @@ -1140,6 +1141,62 @@ 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``. 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"``) + :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` + """ + 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}/{ApiPath.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..0ab6926 --- /dev/null +++ b/videodb/export.py @@ -0,0 +1,195 @@ +"""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 +from videodb.exceptions import InvalidRequestError, RequestTimeoutError + +#: Statuses that mean the job is over. +DONE = "done" +ERROR = "error" +TERMINAL_STATUSES = (DONE, ERROR) + +#: 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; +#: 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 timeline_id: The timeline this export was made from + :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, + timeline_id: Optional[str] = None, + 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 + # 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 + 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 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 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}/{ApiPath.export}/{self.timeline_id}/{self.id}" + + @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=self._path()) 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 RequestTimeoutError: 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 RequestTimeoutError( + 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 InvalidRequestError: if the job has not finished + :return: A URL valid for a limited time + :rtype: str + """ + if not self.done: + 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 {} + 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 InvalidRequestError( + 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: + """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") or (data or {}).get("id") + if not 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})