@packiko/video-sdk
v0.4.0
Published
Browser SDK for the Packiko video service: direct-to-Blob upload, camera capture, and playback URL resolution. Framework-agnostic core with an optional React hook.
Readme
@packiko/video-sdk
Browser SDK for the Packiko video service: direct-to-Blob upload, camera capture, and playback URL resolution. Framework-agnostic core with an optional React hook.
Published to public npm (MIT) from 0.4.0. No registry setup and no token — see docs/sdk-consumer-setup.md.
Install
pnpm add @packiko/video-sdkUsage
Record evidence with createDurableRecorder. It commits the clip to local
storage before it reports success, so leaving the screen, going offline, or
reloading after stop() does not lose it — the upload continues on its own.
import { createUploadQueue, createDurableRecorder } from '@packiko/video-sdk';
// Once per application, not once per screen — it outlives the capture UI.
const queue = createUploadQueue({ apiBaseUrl, publicKey }, { attach });
const recorder = createDurableRecorder({
queue,
getContext: () => ({ orderRef: currentOrder.ref, context: { doc: currentOrder.id } }),
});
const session = await recorder.start();
videoEl.srcObject = session.previewStream;
await session.stop(); // durable here; no network needed
recorder.dispose(); // safe from a route cleanup, and safe to call twice| Event | Outcome |
| --- | --- |
| Leaving the screen mid-recording | Rescued — dispose() finalizes rather than dropping |
| Offline at stop() | Durable anyway; the upload waits for the network |
| Reload once stop() resolved | Safe; the queue resumes it on the next load |
| Reload or tab close mid-recording | Best effort — the browser may kill the page first |
| Power loss mid-recording | Lost; no finalized clip has ever existed |
Recordings are video only. audio is off in the durable recorder's default
profile, to keep clips inside their size budget and to avoid capturing workplace
conversation. Pass capture: { audio: true } if a deployment genuinely needs it.
Driving capture yourself
The lower-level pieces stay available for hosts that manage their own lifecycle. Note that nothing below survives the screen unmounting mid-recording:
const recorder = createRecorder({ apiBaseUrl, publicKey });
const capture = await recorder.capture({
videoBitsPerSecond: 1_500_000, // optional; omit for browser default
}); // getUserMedia → CaptureHandle
capture.start();
const blob = await capture.stop(); // finalized Blob
await recorder.upload(blob, { orderRef }).promise; // token → PUT → confirm// optional item-details snapshot — labels the video for AI training
await recorder.upload(blob, {
orderRef,
items: [
{ sku: 'SKU-1', name: 'เสื้อยืดสีแดง', qty: 2, image_url: 'https://cdn.example.com/p/1.jpg', weight_g: 350 },
],
}).promise;Items are validated client-side before any byte is uploaded, with the same
rules and error codes as the server (items_invalid / duplicate_item_sku).
Item fields are product data only — no personal data; image_url must
point at a product image, never a photo that may capture customer data
(shipping labels, addresses); the service never fetches image_url (permanent
SSRF constraint).
// React (peer dep ^18 || ^19) — separate sub-export
import { useRecorder } from '@packiko/video-sdk/react';<!-- zero-build <script> tag: global `PackikoVideo` (core only, no React) -->
<script src="https://.../@packiko/video-sdk/dist/index.global.js"></script>Durable upload queue
createUploadQueue keeps a recorded clip safe from the moment enqueue()
resolves: the clip is persisted locally before any network request, survives
refresh, crash, and offline periods, then uploads and attaches in the background
with automatic retries. Create one queue instance per app — jobs keep
settling after the screen that enqueued them is gone.
import { createUploadQueue } from '@packiko/video-sdk';
type Ctx = { documentId?: string; orderRef: string };
const queue = createUploadQueue<Ctx>(
{ apiBaseUrl, publicKey, userToken },
{
// Is YOUR document ready to accept the clip? false = the job parks and
// waits (not a failure) until you nudge() or the queue re-checks.
releaseWhen: (job) => documentExists(job.context.documentId),
// Bind the uploaded clip to your document. MUST be idempotent — the queue
// may call again for a clip that is already bound (e.g. after a crash);
// a repeat call must return 'ok'.
attach: async (job) => {
const res = await bindClip(job.context.documentId, job.videoId);
if (res.ok) return { status: 'ok', refId: res.bindingId };
if (res.docNotReady) return { status: 'parked' }; // gate re-arms
if (res.transient) return { status: 'retry', code: res.code };
return { status: 'failed', code: res.code }; // needs a human
},
// Multi-user apps: whose clip is this? See "Multi-user apps" below.
getOwnerId: () => session.userId ?? null,
},
);
const job = await queue.enqueue({ blob, context, orderRef });
// durable from here — navigating away or losing connectivity cannot lose the clip
// the moment your document becomes ready, bind in the same cycle:
await queue.nudge((job) => job.context.documentId === createdDocumentId);enqueue() rejects only when persisting the clip locally fails (code
storage_quota_exceeded / storage_write_failed) — no network is touched
before it resolves. context is yours: the SDK stores it verbatim (it must be
structured-cloneable) and hands it back in every callback and outcome.
Both callbacks are optional. Upload-only integrations omit attach (and
releaseWhen): a job then completes as soon as the upload is confirmed,
emitting 'bound' without a refId.
Watching the queue
getSnapshot() / subscribe() drive a status tray (the snapshot reference is
stable between changes — useSyncExternalStore-ready). Each UploadJob carries
its stage:
| stage | meaning |
|---|---|
| queued → uploading → uploaded → attaching | the happy path; the job disappears once upload and attach both succeed (upload-only: once the upload is confirmed) |
| retry | transient failure or an unmet releaseWhen gate — the queue retries on its own schedule (nextRetryAt is display-only) |
| review | needs a human: show it, offer retry(jobId) / download(jobId) |
onOutcome() fires terminal events for toasts — 'bound', 'bind-failed'
(your attach code, echoed verbatim), 'upload-failed' (an SDK
PackikoErrorCode; pair with describeError for user-facing copy). hydrated
is false until persisted jobs have been read back on startup — render a
neutral state, not "empty". A non-null storageError means local persistence
itself is failing: surface it and offer download.
import { describeError } from '@packiko/video-sdk';
const unsubscribe = queue.subscribe(() => {
const { hydrated, jobs, storageError } = queue.getSnapshot();
renderTray({ hydrated, jobs, storageError }); // re-render on every change
});
const offOutcome = queue.onOutcome((outcome) => {
if (outcome.status === 'bound') toast.success(`clip attached (${outcome.videoId})`);
else if (outcome.status === 'bind-failed') toast.error(myCopyFor(outcome.code));
else toast.error(describeError(outcome.code, 'th')); // upload-failed
});
// tray row actions:
await queue.retry(job.id); // re-arm a 'review'/'retry' job now
queue.download(job.id); // hand the local clip to the user as a fileHuman actions: retry(jobId) re-arms a review/retry job immediately (a file
that already uploaded is never uploaded again); download(jobId) hands the
locally stored clip to the user as a file. drain() asks the queue to make
progress now — normally unnecessary (it wakes itself on enqueue, online, focus,
and its retry schedule); call it from e.g. a service-worker message handler.
Multi-user apps
Pass getOwnerId and the queue captures who was signed in at enqueue. If a
different user is signed in when the job is processed, the job moves to
review with code owner_mismatch — no request is ever sent under the wrong
account. When the original user signs back in, retry(jobId) resumes it.
Omit getOwnerId in single-user apps.
Fast screens: freeze the binding at recording start
If one screen mounts a single recorder and swaps props to the next order while
the camera is still flushing, reading live props at enqueue can bind the clip
to the wrong order. Freeze the binding when recording starts and resolve it
at enqueue with resolveClipBinding (pure, framework-agnostic):
import { resolveClipBinding, type ClipBinding } from '@packiko/video-sdk';
const frozen: ClipBinding = { ref: orderRef, documentId }; // captured at start()
// ... recording finishes; the screen may already show the next order ...
const bound = resolveClipBinding(frozen, { ref: liveRef, documentId: liveDocumentId });
await queue.enqueue({ blob, context: bound, orderRef: bound.ref });Frozen values always win. One exception: a document created during/after
recording — its late-arriving documentId is accepted only while the live
ref still equals the frozen one.
React
import { useUploadQueue } from '@packiko/video-sdk/react';
function UploadTray({ queue }: { queue: UploadQueue<Ctx> }) {
const { hydrated, jobs, storageError, retry, nudge, download } = useUploadQueue(queue);
if (!hydrated) return null;
return <>{jobs.map((j) => <Row key={j.id} job={j} onRetry={() => retry(j.id)} />)}</>;
}Headless (no components, no CSS), SSR-safe, and React stays an optional peer dependency — non-React apps import only the core entry.
Background Sync (optional)
createUploadQueue already retries on its own foreground triggers (enqueue,
online, focus, retry timer) and requests durable storage on enqueue. On
Chromium browsers you can additionally let the browser wake your service worker
when connectivity returns — even with the tab backgrounded. Safari/Firefox have
no Background Sync: the helper resolves false silently there and the queue
behaves exactly as without it.
The SDK does not ship a service worker — the recipe below goes in your app's own SW.
// page: register a one-shot sync after each enqueue (best-effort, never throws)
import { createUploadQueue, registerBackgroundSync, UPLOAD_SYNC_TAG } from '@packiko/video-sdk';
const queue = createUploadQueue(config, options);
await queue.enqueue({ blob, context, orderRef });
void registerBackgroundSync(); // or pass your own ServiceWorkerRegistration
// page: drain when the service worker asks
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'packiko-video:drain') void queue.drain();
});// sw.js (your app's service worker)
self.addEventListener('sync', (event) => {
if (event.tag !== 'packiko-video-sdk:drain') return; // UPLOAD_SYNC_TAG
event.waitUntil(
self.clients
// includeUncontrolled: a freshly loaded page is not yet controlled by
// the SW (until the next navigation) but must still get the message.
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clients) => clients.forEach((c) => c.postMessage({ type: 'packiko-video:drain' }))),
);
});With no open page the message goes nowhere — the queue then resumes on the next visit (it hydrates and drains on construction). Sync registrations are one-shot, so re-register after each enqueue; re-registering a still-pending tag is a cheap no-op.
