#!/usr/bin/env bash # Requires Bash, curl 7.76+, jq, Python 3, ffprobe and uuidgen. # Place a video at talk.mp4 and set CERUL_API_KEY before running. # Processing and search consume credits. OCR is off by default. set -euo pipefail : "${CERUL_API_KEY:?Set CERUL_API_KEY before running}" CERUL_QUICKSTART_RUN_ID=$(uuidgen) export CERUL_QUICKSTART_RUN_ID export OCR_ENABLED=false CERUL_TMP=$(mktemp -d) trap 'rm -rf "$CERUL_TMP"' EXIT cerul() { curl --fail-with-body --silent --show-error \ --connect-timeout 15 --max-time 120 \ -H "Authorization: Bearer $CERUL_API_KEY" \ -H "Content-Type: application/json" "$@" } cerul https://api.cerul.ai/videos | jq '.data' FILE_SIZE=$(wc -c < ./talk.mp4 | tr -d ' ') CONTENT_SHA256=$(python3 - <<'PY' import hashlib digest = hashlib.sha256() with open('talk.mp4', 'rb') as source: for chunk in iter(lambda: source.read(1024 * 1024), b''): digest.update(chunk) print(digest.hexdigest()) PY ) DURATION_SECONDS=$(ffprobe -v error -show_entries format=duration -of csv=p=0 ./talk.mp4) VIDEO=$(cerul https://api.cerul.ai/videos \ -H "Idempotency-Key: add-video-$CERUL_QUICKSTART_RUN_ID" \ -d "$(jq -n --argjson size "$FILE_SIZE" \ '{filename:"talk.mp4",media_type:"video/mp4",byte_size:$size}')") CERUL_VIDEO_ID=$(printf '%s' "$VIDEO" | jq -er '.data.id') export CERUL_VIDEO_ID printf 'Video ID: %s\n' "$CERUL_VIDEO_ID" UPLOAD_CONTENT_TYPE=$(printf '%s' "$VIDEO" | jq -er '.data.upload.required_headers["Content-Type"]') printf '%s' "$VIDEO" | jq -e '.data.upload.parts | length > 0' >/dev/null printf '%s' "$VIDEO" | jq -c '.data.upload.parts[]' > "$CERUL_TMP/parts.jsonl" # Upload exact byte ranges using only the signed headers, without the API Key. OFFSET=0 while IFS= read -r PART; do PART_URL=$(printf '%s' "$PART" | jq -er '.url') PART_SIZE=$(printf '%s' "$PART" | jq -er '.byte_size') python3 - "$OFFSET" "$PART_SIZE" "$CERUL_TMP/part.bin" <<'PY' import sys offset, size, target = sys.argv[1:] remaining = int(size) with open('talk.mp4', 'rb') as source, open(target, 'wb') as part: source.seek(int(offset)) while remaining: chunk = source.read(min(1024 * 1024, remaining)) if not chunk: raise RuntimeError('Source shorter than reserved upload') part.write(chunk) remaining -= len(chunk) PY curl --fail-with-body --silent --show-error -X PUT "$PART_URL" \ --connect-timeout 15 --max-time 1800 \ -H "Content-Type: $UPLOAD_CONTENT_TYPE" -H "Content-Length: $PART_SIZE" \ --upload-file "$CERUL_TMP/part.bin" OFFSET=$((OFFSET + PART_SIZE)) done < "$CERUL_TMP/parts.jsonl" # Defaults to speech and visual indexing. OCR is an explicit paid option. cerul "https://api.cerul.ai/videos/$CERUL_VIDEO_ID/complete" \ -H "Idempotency-Key: complete-video-$CERUL_QUICKSTART_RUN_ID" \ -d "$(jq -n --argjson duration "$DURATION_SECONDS" --arg hash "$CONTENT_SHA256" \ --argjson ocr "$OCR_ENABLED" \ '{duration_seconds:$duration,content_sha256:$hash,processing:{ocr:{enabled:$ocr,interval_seconds:10}}}')" \ > "$CERUL_TMP/completed.json" DEADLINE=$((SECONDS + 1800)) while :; do VIDEO=$(cerul "https://api.cerul.ai/videos/$CERUL_VIDEO_ID") STATUS=$(printf '%s' "$VIDEO" | jq -er '.data.status') case "$STATUS" in ready) printf '%s' "$VIDEO" | jq '.data.coverage'; break ;; failed) printf '%s' "$VIDEO" | jq '.data.error' >&2; exit 1 ;; uploading|uploaded|processing) ;; *) echo "Unexpected video status: $STATUS" >&2; exit 1 ;; esac if (( SECONDS >= DEADLINE )); then echo "Timed out; save $CERUL_VIDEO_ID and resume polling. The server job continues." >&2 exit 1 fi sleep 5 done SEARCH=$(cerul https://api.cerul.ai/search \ -H "Idempotency-Key: search-quickstart-$CERUL_QUICKSTART_RUN_ID" \ -d "$(jq -n --arg video "$CERUL_VIDEO_ID" \ '{query:"when did they discuss scaling laws",filter:{video_ids:[$video]},limit:5}')") printf '%s' "$SEARCH" | jq '{request_id,usage,warnings,data}' # A valid search can return no results. Export only a bounded evidence span. if ! HIT=$(printf '%s' "$SEARCH" | jq -ce 'first(.data[] | select( (.evidence.start_seconds | type) == "number" and (.evidence.end_seconds | type) == "number" and .evidence.end_seconds > .evidence.start_seconds))'); then echo "No evidence span to export. Review the query or available coverage." exit 0 fi CLIP_BODY=$(printf '%s' "$HIT" | jq --arg video "$CERUL_VIDEO_ID" \ '{video_id:$video,start_s:.evidence.start_seconds,end_s:.evidence.end_seconds,evidence_ids:[.evidence.id]}') CLIP=$(cerul https://api.cerul.ai/clips \ -H "Idempotency-Key: clip-quickstart-$CERUL_QUICKSTART_RUN_ID" -d "$CLIP_BODY") CLIP_JOB_ID=$(printf '%s' "$CLIP" | jq -er '.data.id') DEADLINE=$((SECONDS + 1800)) while :; do JOB=$(cerul "https://api.cerul.ai/jobs/$CLIP_JOB_ID" -D "$CERUL_TMP/job-headers") CLIP_STATUS=$(printf '%s' "$JOB" | jq -er '.data.status') case "$CLIP_STATUS" in succeeded) break ;; failed|canceled) printf '%s' "$JOB" | jq '.data' >&2; exit 1 ;; esac if (( SECONDS >= DEADLINE )); then echo "Timed out; resume polling job $CLIP_JOB_ID. The server job continues." >&2 exit 1 fi RETRY_AFTER=$(awk 'tolower($1)=="retry-after:" {gsub("\r", "", $2); print $2}' "$CERUL_TMP/job-headers") case "$RETRY_AFTER" in ''|*[!0-9]*) RETRY_AFTER=3 ;; esac sleep "$RETRY_AFTER" done ARTIFACTS=$(cerul "https://api.cerul.ai/jobs/$CLIP_JOB_ID/artifacts") ARTIFACT_ID=$(printf '%s' "$ARTIFACTS" | jq -er '.data[0].id') cerul "https://api.cerul.ai/artifacts/$ARTIFACT_ID" | jq '.data' cerul "https://api.cerul.ai/artifacts/$ARTIFACT_ID/content" --output quickstart-clip.mp4