Two services own everything the Gateway stores in S3: AssetService (src/service/asset_service.py) handles the per-drone media library — images, HLS video, and DataFlash logs — while ArchiveService (src/service/archive_service.py) packages a mission execution’s assets into a downloadable ZIP. Both are plain singletons built in the composition root (src/application/app.py:45 and :59) and injected into route blueprints; neither is a drone-type service, so they are not created through the get_service factory covered in Service Layer Overview. Both write to the same bucket, settings.ASSET_BUCKET (default skyhub-prod-assets, src/application/settings.py:128), using boto3 with SigV4 presigning (Config(signature_version="s3v4")). Only the presign lifetimes differ: assets expire after 3 hours, archives after 7 days.
AssetService.__init__ reads REGION into a local the_region variable but the boto3 client is created without region_name (src/service/asset_service.py:24-29) — the region actually comes from the standard AWS env/config chain, not from that variable. If you tighten S3 configuration, don’t assume REGION is what selects the endpoint here.

S3 key scheme

Every asset key is derived deterministically from the drone id, asset type, and file name in generate_presigned_url (src/service/asset_service.py:169). Thumbnails and archives live in their own sibling folders under the same drone prefix.
ContentS3 key patternWritten by
Image / video / log assetdrone/{drone_id}/{asset_type}/{file_name}generate_presigned_url (presigned PUT)
HLS segmentdrone/{drone_id}/video/{segment}.tsclient PUT (no DB row)
Image thumbnaildrone/{drone_id}/thumbnail/{file_name}create_thumbnail__get_thumbnail_path
Execution archivedrone/{drone_id}/archives/execution_{id}_{mission}_{ts}.zipArchiveService.generate_archive
asset_type is one of image, video, logs (the AssetType enum in src/models/asset.py:16). Note the thumbnail folder is the singular thumbnail/ and is keyed by bare file_name, not the full asset key — this matters for the reconciliation gotcha at the end of this page.

Presigned upload

Uploads are a two-call handshake so the drone (or UI) streams bytes straight to S3 and never through the Gateway. The HTTP endpoints live in src/routes/asset_routes.py and are documented in Executions, Assets & Reports API; this section is the service-layer contract.
1

request_upload → presigned PUT

request_upload(drone, file_name, data) (src/service/asset_service.py:125) resolves the MIME type (explicit, else guessed from the extension), builds the drone/{id}/{asset_type}/{file} key, and returns a presigned put_object URL. It also inserts an Asset row in pending status.The ContentType baked into the presign must match the Content-Type header the client sends on the PUT, or S3 rejects the signature (see the # NOTE: must match! at line 172).
2

HLS segments skip the DB

When mime_type == "video/mp2t" (an HLS .ts segment), request_upload returns (None, url) and creates no Asset row (src/service/asset_service.py:146). Only the .m3u8 playlist gets a DB row — it is the single canonical “video” asset, and segment files are re-derived from it on demand (see below).
3

complete_upload → thumbnail + ready

After the PUT succeeds the client calls complete_upload(asset_id) (src/service/asset_service.py:65). For image assets it generates a thumbnail; then it flips status to ready and commits. Videos and logs get no thumbnail.
request_upload defaults asset_type to "video" when the caller omits it, so always pass the real type. There is no server-side size or file-type enforcement yet (see the TODO comments at src/service/asset_service.py:127-129).

Thumbnails

create_thumbnail(asset_id, size=(192, 108)) (src/service/asset_service.py:80) pulls the original image from S3, downscales it with Pillow (Image.thumbnail preserves aspect ratio inside the 192×108 box), re-encodes JPEG, and PUTs it to drone/{drone_id}/thumbnail/{file_name}. The whole method is wrapped in a broad try/except that only logs — a thumbnail failure never blocks complete_upload. Read paths that hand thumbnail URLs to the UI (get_asset_by_vehicle, src/service/asset_service.py:202) presign the same __get_thumbnail_path, so listings served by AssetService are internally consistent.

HLS video: playback rewriting

Recorded flight video is stored as an HLS playlist (.m3u8) plus a set of .ts segments — the on-drone GStreamer pipeline uploads them (see On-Drone Video Streaming). The stored .m3u8 references its segments with file:// paths, which a browser cannot fetch. get_video_asset_by_asset_id_and_vehicle_id (src/service/asset_service.py:241) rewrites the playlist on the fly for each request:
  • Every line starting with file:// is matched by /([^/]+\.ts)$ to pull the bare segment name.
  • That name is turned into a fresh 3-hour presigned GET for drone/{drone_id}/video/{segment}.ts.
  • All other lines (#EXTM3U, #EXTINF, …) pass through untouched.
The result is a browser-playable playlist of short-lived signed segment URLs. The object in S3 is never mutated — rewriting happens per-request in memory, so the same stored playlist serves both live streaming and offline archiving.
Because segments carry no DB rows, both playback and deletion re-parse the .m3u8 to discover them. If you change how segments are named or where they live, update get_video_asset_by_asset_id_and_vehicle_id, __get_m3u8_segment_filenames (src/service/asset_service.py:396), and ArchiveService._get_m3u8_segment_filenames together.

Atomic two-phase delete

delete_asset(user_id, vehicle_id, asset_ids) (src/service/asset_service.py:273) is deliberately ordered S3 first, DB second to avoid orphaned rows pointing at deleted objects: Video deletion fetches the .m3u8, extracts segment names via __get_m3u8_segment_filenames, and issues a single delete_objects bulk call; a non-empty Errors array raises and aborts before Phase 2. The failure modes are asymmetric and worth preserving:
If S3 deletion fails partway, some objects are already gone but no DB rows are removed — you get orphaned S3 objects, never orphaned DB rows. If Phase 2’s commit fails after Phase 1 succeeded, the transaction is rolled back and the (already-deleted) S3 objects are logged as orphaned. This is the intended trade-off: the DB is the source of truth and must never dangle.
delete_all_drone_assets (src/service/asset_service.py:375) wraps delete_asset for drone teardown and swallows exceptions so a failed S3 cleanup can’t block drone deletion. Content-Disposition download headers everywhere use encode_filename_rfc5987 (src/service/asset_service.py:38) to prevent CRLF/quote header injection.

Execution archives (ZIP)

ArchiveService bundles one execution’s assets into a single ZIP for export. It is invoked from the background report pipeline (ReportService.enqueue_archive_and_report, see Executions, Log Analysis & Reports) and on-demand from the executions API. generate_archive(execution_id) (src/service/archive_service.py:35):
  1. Loads the MissionExecution and all its assets filtered to status == ready. No ready assets → clears any stale archive reference and returns None.
  2. Builds a filename execution_{id}_{safe_mission_name}_{timestamp}.zip (mission name sanitized with re.sub(r"[^\w\-]", "_", …)[:50]) and the key drone/{drone_id}/archives/{filename}.
  3. Streams each asset into a ZIP_DEFLATED temp file, then upload_files it with ContentType: application/zip.
  4. Commits archive_s3_key + archive_generated_at on the execution, then deferred-deletes the previous archive only after the new upload+commit succeed.
Assets are foldered inside the ZIP by type — photos/, videos/, logs/, or other/ (_add_asset_to_zip, src/service/archive_service.py:125). Every filename is passed through Path(...).name to defuse path-traversal (../) attacks.

HLS videos in the ZIP

For an HLS video the archive can’t just drop the streaming .m3u8 (its file:// paths won’t resolve offline). _add_video_asset_to_zip (src/service/archive_service.py:157) instead:
  • Downloads the original playlist, extracts segment names, and derives the segment prefix from the playlist’s own S3 parent dir (Path(asset.s3_key).parent).
  • Writes a rewritten local playlist (_create_local_m3u8) whose segment lines are bare relative filenames, into videos/{video_stem}/{name}.m3u8.
  • Downloads each .ts next to it.
The S3 playlist stays untouched (still used for UI streaming); only the copy inside the ZIP is localized for offline playback.

Download URLs & lifecycle

get_archive_download_url(execution_id, regenerate=False) (src/service/archive_service.py:308) is the read entry point. It regenerates the ZIP when regenerate=True, when no archive exists, or when the existing one is older than 7 days (ARCHIVE_VALIDITY_DAYS), then returns a status dict:
statusMeaningurl
readyArchive present/fresh7-day presigned GET
no_assetsExecution has no ready assetsnull
errorRegeneration or presign failednull
The presigned URL uses ARCHIVE_URL_EXPIRATION_SECONDS (7 days) and an attachment; filename="..." disposition. delete_archive (src/service/archive_service.py:370) removes the object and clears both archive_s3_key and archive_generated_at.

Demo assets (SITL)

When a SITL drone is created, AssetService.create_demo_assets (src/service/asset_service.py:413) seeds one demo image (a 640×480 solid-blue JPEG plus thumbnail) and one demo HLS video (a 2-segment .m3u8 with synthetic .ts files) so the fleet UI has something to render immediately. Failures are logged, not raised — demo-asset problems never fail SITL creation. See SITL Drone Lifecycle.

Gotcha: two different thumbnail paths

Thumbnails are read from two different, incompatible keys depending on which service serves the response — reconcile this before unifying asset code.
  • AssetService writes and reads thumbnails at drone/{drone_id}/thumbnail/{file_name} (__get_thumbnail_path, src/service/asset_service.py:410). This is the only place a thumbnail object is ever written.
  • ExecutionService._enrich_asset_with_urls builds thumbnail URLs at thumbnails/{asset.s3_key} — i.e. thumbnails/drone/{drone_id}/image/{file_name} (src/service/execution_service.py:540).
These never point at the same object. Thumbnails surfaced through the executions endpoints therefore reference a key that was never written and 403/404 on fetch, while thumbnails from the plain asset listings work. The canonical location is the AssetService one; any refactor that consolidates asset serialization must standardize on drone/{drone_id}/thumbnail/{file_name}.

Configuration

Env varDefaultUsed by
ASSET_BUCKETskyhub-prod-assetsBoth services’ S3 bucket
REGIONeu-central-1Read by AssetService but not applied to its client; used elsewhere for boto3
Presign lifetimes and archive validity are hardcoded, not env-driven:
ConstantValueLocation
Asset presign expiry3 hours (60*60*3)src/service/asset_service.py:30
Archive presign expiry7 dayssrc/service/archive_service.py:20
Archive validity before regen7 dayssrc/service/archive_service.py:21
Thumbnail size192×108src/service/asset_service.py:80
Related pages: Executions, Assets & Reports API for the HTTP surface, Executions, Log Analysis & Reports for how archives feed the report pipeline, Janus Video Rooms for live video, and the Database Schema Overview for the Asset and MissionExecution models.