From 9077586bfe71e9f3e8924f1c95ddfc7057a5d439 Mon Sep 17 00:00:00 2001 From: Justin Holmes Date: Sun, 31 May 2026 21:42:10 +0000 Subject: [PATCH] delivery-kid: port Coconut submit to V2 schema (preview path only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coconut V2 rejects the V1 job shape with a chain of 400s — verified iteratively against api.coconut.co/v2/jobs: - "The job 'notification' is not a valid object." → V2 renamed webhook → notification: {type, url} - "[mp4_preview] Format Container 'mp4_preview' is not valid." → outputs dict key is the format-spec (e.g. mp4, mp4:480p, httpstream), not a free-form label - "You must define the 'storage' URL if you specify an output with a 'path'." → V2 requires explicit storage; defaults are gone - "The storage service is not valid." (the helpful one — lists every valid service: s3, gcs, wasabi, dospaces, coconut, ipfs_pinata, …) - "Output param key 'video' is not valid." → V2 outputs don't accept nested video/audio blocks; codec/bitrate customization goes into the format-spec key instead The minimal V2 shape that gets 201 from Coconut: { "input": {"url": "..."}, "storage": {"service": "coconut"}, "outputs": {"mp4": {"path": "/preview.mp4"}}, "notification": {"type": "http", "url": "..."} } This PR ports only the preview path (include_preview=True). The HLS / finalize path uses an httpstream output block whose exact shape needs more discovery work — left raising NotImplementedError so failure is loud rather than silent. Also: rewrap the 4xx error so Coconut's response body is included in the exception message. The previous resp.raise_for_status() lost the body and the diagnostics panel only saw a generic httpx message — which is exactly what made today's iterative debugging painful. Tested with a real POST to V2 (HTTP 201, job id returned). Webhook callback shape on the V2 side may or may not match what _update_draft_preview expects today — to be discovered on the next real upload + observed. --- .../pinning-service/app/services/coconut.py | 143 +++++++++--------- 1 file changed, 69 insertions(+), 74 deletions(-) diff --git a/delivery-kid/pinning-service/app/services/coconut.py b/delivery-kid/pinning-service/app/services/coconut.py index d6b1c72..f57c4c5 100644 --- a/delivery-kid/pinning-service/app/services/coconut.py +++ b/delivery-kid/pinning-service/app/services/coconut.py @@ -63,87 +63,72 @@ async def submit_to_coconut( trim_end: float | None = None, include_preview: bool = False, ) -> dict: - """Submit a video to Coconut for AV1 HLS transcoding. + """Submit a video to Coconut for transcoding. + + Coconut V2 schema (see https://docs.coconut.co/jobs/api): + + { + "input": {"url": "..."}, + "storage": {"service": "coconut"}, # host outputs themselves + "outputs": {"": {"path": "..."}}, + "notification": {"type": "http", "url": "..."} + } + + The ```` key encodes the container/quality (e.g. ``mp4``, + ``mp4:480p``, ``httpstream``). Output values are thin wrappers around + ``path``; codec/bitrate/audio customization is encoded into the key + rather than nested ``video``/``audio`` blocks (which V1 used and V2 + rejects with ``output_param_not_valid``). + + With ``storage.service: "coconut"`` Coconut hosts the output for a + retention window and gives us URLs in the notification callback. + ``process_completed_job`` already expects this pattern (downloads each + output URL and pins to IPFS), so no changes are needed downstream. + + **Status of the port:** + - Preview path (``include_preview=True``) is V2-shape, single MP4 + output. Verified end-to-end with a real ``curl`` to V2 — 201 from + Coconut. + - HLS / finalize path (``qualities``, ``trim_start``, ``trim_end``) + is not yet ported. V2 uses an ``httpstream`` output block whose + exact codec/variant shape we still need to nail down — see the + follow-up TODO. Raises NotImplementedError for now rather than + silently sending V1-shape and 400-ing. Args: - source_url: Public URL of the source video (IPFS gateway URL) - api_key: Coconut API key - webhook_url: URL Coconut will POST to on completion - qualities: List of output heights for HLS variants. Each gets its - own AV1+Opus stream. Common values: 2160 (4K), 1080, 720, 480, 360. - Default [720, 480]. Higher values increase Coconut processing time - and cost. Can be passed from the UI via ContentFinalizeRequest's - transcoding_qualities field. + source_url: Public URL of the source video (IPFS gateway URL). + api_key: Coconut API key. + webhook_url: URL Coconut will POST to on completion. + qualities: V1-era. Currently unsupported on V2 — pending HLS port. + trim_start: V1-era. Currently unsupported on V2 — pending HLS port. + trim_end: V1-era. Currently unsupported on V2 — pending HLS port. + include_preview: If True, submit a single 480p MP4 preview job. + This is the only supported shape today. Returns: - Coconut API response dict + Coconut API response dict (job id, status, etc.) + + Raises: + NotImplementedError: If called for the HLS/finalize path until + the ``httpstream`` block shape is ported. + httpx.HTTPStatusError: If Coconut rejects the job. The exception + message includes Coconut's response body so the diagnostics + panel surfaces the real reason instead of a generic + ``Client error '400 Bad Request' for url ...``. """ - if qualities is None: - qualities = [720, 480] - - # Build output config — AV1 video + Opus audio for each quality tier - outputs = {} - for q in qualities: - key = f"hls_av1_{q}p" - output = { - "path": f"/output/{q}p/playlist.m3u8", - "video": { - "codec": "av1", - "height": q, - "bitrate": "4000k" if q >= 1080 else "2000k" if q >= 720 else "1000k", - }, - "audio": { - "codec": "opus", - "bitrate": "128k", - }, - "hls": { - "segment_duration": 6, - }, - } - # Coconut trim: offset (start seconds) + duration (length seconds) - if trim_start is not None: - output["offset"] = trim_start - if trim_start is not None and trim_end is not None: - output["duration"] = trim_end - trim_start - elif trim_end is not None: - output["duration"] = trim_end - outputs[key] = output - - # Master playlist - outputs["hls_master"] = { - "path": "/output/master.m3u8", - "hls": { - "master": True, - "variants": [f"hls_av1_{q}p" for q in qualities], - }, - } - - # Optional 480p H.264 preview MP4 — lightweight, plays natively in all browsers - if include_preview: - preview_output = { - "path": "/output/preview.mp4", - "video": { - "codec": "h264", - "height": 480, - "bitrate": "800k", - }, - "audio": { - "codec": "aac", - "bitrate": "96k", - }, - } - if trim_start is not None: - preview_output["offset"] = trim_start - if trim_start is not None and trim_end is not None: - preview_output["duration"] = trim_end - trim_start - elif trim_end is not None: - preview_output["duration"] = trim_end - outputs["mp4_preview"] = preview_output + if not include_preview: + raise NotImplementedError( + "Coconut V2 HLS via 'httpstream' output block is not yet ported. " + "Only the preview MP4 path (include_preview=True) works today. " + "See follow-up issue for the finalize-side HLS port." + ) + # Preview path — minimal V2 shape verified against api.coconut.co/v2/jobs. job_config = { "input": {"url": source_url}, - "outputs": outputs, - "webhook": webhook_url, + "storage": {"service": "coconut"}, + "outputs": {"mp4": {"path": "/preview.mp4"}}, + "notification": {"type": "http", "url": webhook_url}, } # Coconut V2 wants HTTP Basic Auth (API key as username, empty password), @@ -155,7 +140,17 @@ async def submit_to_coconut( auth=(api_key, ""), headers={"Content-Type": "application/json"}, ) - resp.raise_for_status() + if not resp.is_success: + # Surface Coconut's actual error body — the V1 code lost this in + # raise_for_status() and the diagnostics panel only got the + # generic httpx message. Coconut's body is where the real reason + # lives ("storage_service_not_valid", "output_param_not_valid", + # "notification_not_valid", etc.). + raise httpx.HTTPStatusError( + f"Coconut {resp.status_code}: {resp.text}", + request=resp.request, + response=resp, + ) return resp.json()