All work
P / 04 · 2025
Case study

Tessact AI

Video ingest and analysis pipeline

The pipeline behind every video upload, from presigned multipart transfer to indexed scenes, built so nothing heavy touches the request path.

Product preview
(I) — Core premise

Video enters the queue.

A video upload fans out into parallel background work that produces preview assets, video derivatives, transcription, and indexed scene intelligence.

The system separates fast media work (metadata, thumbnails, scrubs) from long-running analysis (transcoding, transcription, detection) by routing them to different queues backed by different worker images. What leaves the pipeline is a structured, searchable asset, not just a stored file.

The engineering problem was keeping those branches observable, failure-isolated, and independently switchable per organization, without any of it touching the upload request or blocking the file from appearing in the library.

Upload branches
5
Queues touched
6
Output groups
4
Join point
1
Execution model
Async
Branch control
Per-org
(II) — Upload

Bytes never touch the API.

  1. 01

    Multipart straight to object storage

    The browser asks the API for presigned part URLs, then uploads 10MB parts directly to object storage. The API sees the request that opens the upload and the request that closes it, never the file itself. Nothing about the size of a video affects request handling.

  2. 02

    Bounded concurrency with per-part retry

    Three parts are in flight at any time. Each part retries up to three times with exponential backoff, carries its own abort signal, and is timed out independently. A single flaky part costs one part, not the upload.

  3. 03

    Finalize is the only fan-out point

    Once every part has an ETag, one finalize call completes the multipart upload, records the original as a tracked artifact, and enqueues the background branches. It returns before any of them start, and a duplicate finalize is a no-op rather than a second pipeline.

(III) — Pipeline outputs

What leaves the other end.

IPreview assets
  • Thumbnail (quality-scored)
  • Scrub contact sheet
  • Hover-preview sprites
  • AVIF with WebP fallback
IIVideo derivatives
  • Normalized transcode
  • Watermarked copy
  • Adaptive streaming package
  • Time-sliced analysis chunks
IIITranscription + timing
  • Audio extraction
  • Managed transcription
  • Speaker labels + subtitles
  • Speech and silence segments
IVScene intelligence
  • Face tracks (chunked detection)
  • Shot boundaries
  • Per-scene structured extraction
  • Indexed, searchable scenes
(IV) — Pipeline fan-out

One upload,
five parallel queues.

Finalizing an upload enqueues independent background tasks, each routed to a queue sized for its runtime. Three run for every video. Two are per-organization. None of them block the upload response, and none of them can starve another.

triggerUpload completeupload finalizerPreview + metadatamedia queue · ffprobe · thumbnail · scrub · spritesalways-onAnalysis orchestratortranscode queue · integrity → transcode → flagged fan-outalways-onWatermark derivativetranscode queue · runs parallel to the orchestratoralways-onStreaming packageadaptive streaming · org flag + org settingorg flagCompliance detectiondetection queue · alternate path when AI analysis is offorg flagsimplified · search indexing is triggered inside the orchestrator, not here
(V) — Queue architecture

Runtime decides the queue.

Every background task is routed by name to one of a set of queues grouped into runtime bands: instant, fast, medium, and heavy. Queues map to worker images and to Kubernetes deployments, so a queue is also a scaling unit and a dependency boundary. A worker that only dispatches jobs doesn't ship a media toolchain.

The ingest path alone crosses six of them. Thumbnails and sprites go to the media queue. The analysis orchestrator and the watermark derivative go to transcode-class queues. Chunk dispatch, the long-running detection work, and the callback-driven subtitle work are three separate detection queues. Outbound indexing sits on its own queue so a slow external call can't occupy a worker that something latency-sensitive needs.

The routing table is the honest description of the system. Reading it tells you what runs where and what competes with what, without reading a single task body.

(VI) — Media processing

Fast work, always.

  1. 01

    Quality-scored thumbnails

    Ten candidate frames are sampled between 20% and 80% of the duration, deliberately skipping the weak openings and endings common in uploaded footage. Each is scored on sharpness, contrast, and saturation, normalized against the best candidate in the set, and the strongest frame wins. The result ships as AVIF, falling back to WebP if the encode fails.

  2. 02

    Scrub and sprite sheets

    Frames sampled on a fixed interval are tiled into a contact sheet and a hover-preview sprite sheet, so the library can scrub a timeline without loading the video. Videos too short to fill a sheet skip it rather than producing a degenerate one.

  3. 03

    Technical metadata on arrival

    Duration, dimensions, frame rate, and codec details are probed on the first download and written back immediately. That makes the asset usable in the library within seconds, long before any analysis completes.

(VII) — Orchestration

Heavy work, conditional.

  1. 01

    Corruption check before anything expensive

    The orchestrator runs an integrity and duration probe before it spawns anything. A corrupted file is marked terminally and the branch ends there. No transcription request, no detection jobs, no partial job state to reconcile later.

  2. 02

    Transcription starts before the transcode

    Transcription is dispatched immediately after the integrity check, not after the transcode. It only needs an extracted audio track, so making it wait on a full video transcode would add minutes to the longest path in the pipeline for no reason. It is the first branch out and, with shot detection, one of the two the join waits on.

  3. 03

    The transcode is what later stages read

    A normalized copy is produced next, and the stages after it read that copy instead of the raw upload. Chunking, detection, and per-scene cutting all get consistent codec, frame rate, and resolution assumptions, which removes an entire category of format edge cases from the downstream code.

  4. 04

    Feature flags decide the fan-out

    Which branches exist at all is read from per-organization flags at the moment the tasks are scheduled. One flag opens the full analysis fan-out. A narrower one runs transcription for captions only and stops before scene assembly. A third routes to a compliance-detection path instead. The workers themselves stay unconditional.

(VIII) — Analysis

Four jobs, in parallel,
two of them gating.

Transcription branches off ahead of the transcode. Speech timing, chunked face analysis, and full-video shot detection branch off after it. Only transcription and shot detection gate scene assembly. Face analysis matters later, when the asset's final status is decided.

01Integrity checkcorrupted → stop, nothing spawned02Normalized transcodeworking copy for later stagesTranscriptionstarts before the transcodeASpeech timingspeech segments + silence gapsBFace analysisfanned out over time-based chunksCShot detectionboundaries across the full videoDA + DjoinScene assemblytranscript + shotssimplified · B and C run alongside but do not gate the join · C gates the final status flip
(IX) — Job orchestration

Every async step leaves a trail.

  1. 01

    One record per logical job

    Each branch creates a durable job row keyed by file and job type, carrying status, a completion fraction, the external job id it is waiting on, structured metadata, and its last error. Active work is queryable in SQL. Completion updates the row instead of vanishing into the task runner.

  2. 02

    Chunked face processing

    Face detection splits the transcoded copy into time-based chunks and dispatches one job per chunk. Each chunk carries its own row with its time window, its storage location, and its result location. The parent's completion fraction is recomputed from its children on every callback.

  3. 03

    Completion is derived, not announced

    A chunk callback recounts its siblings rather than trusting a counter. The parent only resolves when nothing is outstanding, and it resolves to failed if any chunk failed. Aggregation fires once, guarded against a second callback re-running it, and only after the parent row has been committed.

  4. 04

    No-audio fallback by design

    If a video has no audio track, no transcription request is sent. The pipeline instead writes evenly spaced placeholder scene chunks and marks the transcription job complete. Scene assembly then runs on exactly the same code path, with no special case for silent video.

(X) — Scene assembly

Transcript meets shots.
Structured extraction fills the gaps.

Assembly aligns transcript-derived scene boundaries to shot boundaries, fills any remaining gaps so the full duration is covered, cuts each scene into its own clip, and runs per-scene structured extraction with the neighbouring scenes as context. The result is written as scene rows and indexed for search.

Transcript scenessentence chunks · silence gapsShot boundariescamera change detectionjoin01Align + fill gapsfull-duration coverage02Per-scene clipcut + staged for analysis03Structured extractionscene metadata payload04Scene indexscene rows + search indexsimplified · assembly starts only once both transcription and shot detection have completed
(XI) — Transcript preprocessing

Silence as a boundary.

  1. 01

    Sentence chunks on silence gaps

    The transcript is segmented at its largest silence gaps, which produces semantically coherent scene candidates from speech alone. Those candidates exist before shot detection returns, which is exactly why the two have to be reconciled rather than concatenated.

  2. 02

    Gaps are filled, not skipped

    Speech does not cover a whole video. Assembly extends short trailing gaps into the preceding scene, promotes medium ones into a scene of their own, and splits long ones into evenly sized scenes. Coverage of the full duration is a property of the output, not a hope.

  3. 03

    Subtitles branch off the same completion

    The transcription callback also enqueues subtitle generation on a separate queue. Caption artifacts are produced and persisted on their own schedule, and a failure there never reaches the scene-assembly join.

(XII) — Search handoff

Where the pipeline stops.

Retrieval and ranking are a separate system, built by other people. What the pipeline owes them is a document per scene that is correct, replaceable, and still correct after the library changes underneath it. That contract is the last thing this pipeline is responsible for.

  1. 01

    One denormalized document per scene

    Each scene is written as a standalone document carrying its own copy of the video-level fields it needs, plus its workspace id. The extracted signal (people, objects, locations, actions, emotions, brands, on-screen text, synopsis, mood, dialogue) sits flat alongside the transcript and shot boundaries. Tenancy is a filter on the document, not a join at query time.

  2. 02

    Timestamps snap to shot boundaries

    Per-scene extraction returns event times relative to the scene's own clip. Those get rebased to absolute video time, then pulled onto a shot boundary whenever they land within 200ms of one. Without that step a result seeks to a frame or two before the cut it's describing, which reads as a bug in search rather than in the pipeline that fed it.

  3. 03

    Indexing is delete-then-write

    Indexing a video first removes every existing document for it, then bulk-writes the new set in small batches. Re-running analysis replaces the video's scenes instead of duplicating them, so a retry is safe. The same call also normalizes the extracted entities into relational tables, so the structured view and the searchable view are written together or not at all.

  4. 04

    Denormalization has an upkeep cost

    Because every document carries its own copy of the video's title and location, the index drifts the moment the library changes. Renaming a video rewrites its documents in place, copying one into a project reindexes them under the new id, and deleting one clears them by query. That maintenance is the price of not joining at read time, and it belongs to the pipeline, not to search.

(XIII) — Derivatives

Every artifact is inventoried.

One upload becomes a dozen objects in storage: the original, a thumbnail, a scrub sheet, a sprite sheet, a normalized transcode, a watermarked copy, a streaming package, extracted audio, analysis chunks, detection results, transcripts. Left untracked, that is an unbounded storage bill and a deletion path nobody can prove correct.

Each one is instead recorded as a row naming its bucket, its key, its type, its size, and two things that make cleanup decidable: whether it survives with the file or is disposable, and whether it can be regenerated from something else. The original is retained and not regenerable. A sprite sheet is regenerable. A chunk is deleted after processing.

Rows are unique on file, type, and a hash of the storage location, so a re-run overwrites its entry rather than accumulating duplicates of work it already did.

(XIV) — Observability

Status all the way down.

File status progression
  • Upload accepted
  • Queued for processing
  • Transcoding, then in progress
  • Completed, failed, or corrupted
Job-level tracking
  • One row per logical job
  • Chunk rows for parallel work
  • Completion fraction derived from children
  • Progress persisted outside the worker
Failure handling
  • Automatic retry on transient failures
  • Stale in-progress jobs timed out
  • Exception tracking with pipeline context
  • Operational alerts on hard failures
(XV) — Status delivery

Progress arrives as a patch.

  1. 01

    One channel per library folder

    Status changes publish to a realtime channel named for the workspace and folder the asset lives in. Clients subscribe to the view they have open, so a busy workspace doesn't push updates to people looking at something else.

  2. 02

    Refetch for structure, patch for progress

    A new file invalidates the folder query and refetches. A progress tick does not. It writes directly into the cached list, updating only the page holding that asset and preserving object identity everywhere else.

  3. 03

    Why the distinction earns its keep

    Progress events arrive continuously while a video indexes. Refetching on each one, or cloning the cache to update it, made every tick cost the whole loaded list and re-render every card. Patching in place turns a per-tick full render into one row.

(XVI) — Failure modes

Where it could break.

  1. 01

    Heavy work on the upload hot path

    Transcoding, transcription, and detection cannot sit on the upload request. The finalize call returns before any background task starts, and every expensive step is behind a queue.

  2. 02

    Chunk completion coordination

    Face detection dispatches many independent chunk jobs per video. Without deriving parent state from the children on every callback, completion is ambiguous, and one chunk failing has to fail the parent without silently discarding the rest.

  3. 03

    A join on two independent results

    Transcription and shot detection run independently and finish in either order, so whichever lands second starts assembly. If one never reaches a terminal state, assembly never starts, which is why every branch has to end somewhere explicit rather than just stopping.

  4. 04

    Progress moving backwards

    Parallel branches all report into one percentage, and they race. Today each writer clamps its own contribution to a ceiling, which keeps the number sane in practice but is a convention rather than an invariant. A monotonic write would make it one.

  5. 05

    Two eras of code in one path

    A newer orchestrator runs alongside the task-chained flow this pipeline is built on, and some branches check which one they're under before firing. That check is load-bearing, and it's the first thing that gets stale.

(XVII) — Tradeoffs

What I locked, what I left.

Strong choices
  • Queues sized by runtime, not by feature

    Grouping tasks by how long they run rather than which product they belong to means a two-hour transcode and a two-second thumbnail never share a worker pool.

  • Corruption check before expensive work

    Integrity validation happens before any downstream job is created. A bad file fails cheaply instead of spawning orphaned work across several services.

  • Parent/child job model for chunked work

    Chunked analysis derives parent state from its children on every callback. The parent advances only when nothing is outstanding, and aggregation runs exactly once.

  • No-audio fallback is a first-class path

    Silent video still produces placeholder scene structure, so assembly runs on the same code path with no special case.

Deliberate tradeoffs
  • Flags read at scheduling time, not runtime

    Which branches exist is decided when tasks are enqueued. Workers stay simple and the active pipeline is readable from the scheduling call, at the cost of a flag flip not affecting work already in flight.

  • Transcode everything, unconditionally

    A metadata check that skipped already-conformant files exists but is switched off. Every video gets normalized, which costs compute on files that didn't need it and buys one set of assumptions downstream.

  • Scene assembly requires both results

    Transcription and shot detection must both complete before assembly starts. If one is slow, assembly waits. There is no partial scene output.

  • Multiple analysis paths coexist

    Newer and older approaches overlap. The current upload path is clear, but the codebase still shows its evolutionary history, and some processors sit in it without being wired into the default flow.

(XVIII) — Outcomes

What the pipeline produced.

Outcome
Assets usable before analysis completes

Thumbnail, scrub, and technical metadata land seconds after upload. The library shows a real asset, with live progress on it, while the long branches are still running.

Outcome
Scene-level search on every video

Assembly produces structured, indexed scenes covering the full duration of every video that completes the analysis path, supporting search, tagging, comments, and repurpose workflows.

Outcome
Per-org rollout of analysis branches

Flags let organizations sit on different combinations: full analysis, captions only, compliance detection, streaming. New branches ship without touching the upload path or the organizations that don't want them.

(XIX) — Learnings

What stayed true.

  1. 01

    Separate queues for separate runtimes

    Splitting queues by how long work takes is not overengineering. Without it, one slow transcode delays thumbnail generation for every other upload in the system, and the symptom looks like the thumbnail code being slow.

  2. 02

    Validate before you spend

    A corruption check that ends the branch on a bad file is the highest-leverage line in the orchestrator. The alternative is orphaned jobs across several services and no clean terminal state to reconcile them against.

  3. 03

    Order branches by what they actually need

    Transcription needs an audio track, not a normalized video, so it leaves before the transcode. Auditing a pipeline for dependencies that are sequential by habit rather than by requirement is usually the cheapest latency win available.

  4. 04

    Async joins need explicit completion semantics

    A join on two independent results is only tractable because each has a durable terminal state, and because whichever finishes second is the one that starts the next stage. Without job rows, the join degenerates into polling against unstable state.

Next case study
P / 05 · 2025 — 2026

Web Video Editor

Multi-track timeline in the browser