_ _ _ __ ___ _ __ _ __ _ _ __ __(_) __| | ___ ___ | '_ \ / _ \ '_ \| '_ \| | | | \ \ / /| |/ _` |/ _ \/ _ \ | |_) | __/ | | | | | | |_| | \ V / | | (_| | __/ (_) | | .__/ \___|_| |_|_| |_|\__, | \_/ |_|\__,_|\___|\___/ |_| |___/ https://video.penny.land Browser recording -> YOUR bucket -> playable video + transcript. - Media stays in your S3-compatible bucket. We only use presigned URLs you give us: no bucket keys, no copies, nothing stored but job status. - Output: faststart MP4 (or WebM) ready in seconds, the audio track, Whisper large-v3 transcript JSON and WebVTT captions. - Runs in Scaleway fr-par (Paris). No re-encoding: stream copy only. QUICKSTART 1. Get an API key from a Penny operator. You get `pv_app_….…` and a webhook secret, scoped to the storage hosts you name. 2. Upload the recording to your bucket. In the browser: import {createUploader, record} from 'https://video.penny.land/lib/penny-video.js' const up = createUploader({getPartUrls: n => yourServer.presignParts(n)}) const rec = record(mediaStream, up) // parts upload while recording const parts = await rec.stop() // -> yourServer.complete(parts) Server side: S3 CreateMultipartUpload, presign UploadPart URLs, CompleteMultipartUpload. Any S3 SDK works; lib/s3.ts does all three. 3. Presign a GET for the source and PUTs for the outputs, then: curl -X POST https://video.penny.land/v1/jobs \ -H "Authorization: Bearer $PENNY_VIDEO_KEY" \ -H "Content-Type: application/json" \ -d '{"source": "", "outputs": {"playback": "", "audio": "", "transcript": "", "captions": ""}, "metadata": {"yourId": "123"}}' 4. Poll GET /v1/jobs/, or take the webhook. ENDPOINTS POST /v1/jobs app key Queue a job. 201 -> {id, status, ...} GET /v1/jobs/:id app key Status, timings, result. Never returns URLs. POST /v1/apps operator {name, allowedHosts[], webhookUrl?} -> apiKey GET /lib/penny-video.js Browser library (ES module, ~2 KB) GET /upload Test page (passcode) GET /health {"ok":true} STATUS queued -> processing -> playable -> ready | failed playable: the playback PUT is done; transcript may still be coming. RESULT {playbackType, durationSeconds, sourceBytes, language, words, transcriptError?} error is set when status is failed. STEP while processing: step = {name, started}; names: download, probe, remux, uploadPlayback, extractAudio, uploadAudio, transcribe, uploadTranscript. timings = ms per finished step. WEBHOOK POST job.playable | job.ready | job.failed with headers x-penny-timestamp, x-penny-signature = base64url(HMAC-SHA256(webhookSecret, ".")) RULES - URLs must be https, on your app's allowedHosts, valid for >= 30 min. - Don't sign Content-Type on output PUTs; we set it (MP4/WebM/audio/json/vtt). - Browser uploads need bucket CORS: allow PUT from your origin, expose ETag. - Sources: MP4, WebM, MOV (H.264/HEVC/VP8/VP9/AV1 + AAC/Opus). 5 MB parts. - Transcripts: audio up to 25 MB (~25-50 min). Bad media fails once; network/storage errors retry 4 times. - Errors: JSON {"error": "..."} with 400/401/404/409/413.