diff --git a/apps/web/app/api/clips/ingest/route.ts b/apps/web/app/api/clips/ingest/route.ts new file mode 100644 index 0000000..cc3878b --- /dev/null +++ b/apps/web/app/api/clips/ingest/route.ts @@ -0,0 +1,115 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { env } from "@/lib/env"; +import { createServiceClient } from "@/lib/supabase/service"; +import { buildStoragePaths, parseIngestForm } from "@/lib/ingest/parse"; +import type { IngestResponse } from "@/lib/ingest/types"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const VIDEO_BUCKET = "clips"; +const THUMBNAIL_BUCKET = "thumbnails"; + +export async function POST(request: NextRequest) { + const auth = request.headers.get("authorization"); + if (!env.INGEST_SECRET || auth !== `Bearer ${env.INGEST_SECRET}`) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + let form: FormData; + try { + form = await request.formData(); + } catch { + return NextResponse.json( + { error: "expected multipart/form-data" }, + { status: 400 }, + ); + } + + const parsed = parseIngestForm(form); + if (!parsed.ok) { + return NextResponse.json({ error: parsed.error }, { status: parsed.status }); + } + const { meta, video, thumbnail } = parsed; + + const supabase = createServiceClient(); + + const { data: camera, error: cameraErr } = await supabase + .from("cameras") + .select("id") + .eq("caltrans_id", meta.caltransId) + .maybeSingle(); + if (cameraErr) { + return NextResponse.json({ error: cameraErr.message }, { status: 500 }); + } + if (!camera) { + return NextResponse.json( + { error: `unknown caltrans_id: ${meta.caltransId}` }, + { status: 404 }, + ); + } + + const clipId = crypto.randomUUID(); + const { storagePath, thumbnailPath } = buildStoragePaths( + camera.id, + clipId, + video.type, + thumbnail.type, + ); + + const videoUpload = await supabase.storage + .from(VIDEO_BUCKET) + .upload(storagePath, video, { + contentType: video.type || "video/webm", + upsert: false, + }); + if (videoUpload.error) { + return NextResponse.json( + { error: `video upload: ${videoUpload.error.message}` }, + { status: 500 }, + ); + } + + const thumbUpload = await supabase.storage + .from(THUMBNAIL_BUCKET) + .upload(thumbnailPath, thumbnail, { + contentType: thumbnail.type || "image/jpeg", + upsert: false, + }); + if (thumbUpload.error) { + await supabase.storage.from(VIDEO_BUCKET).remove([storagePath]); + return NextResponse.json( + { error: `thumbnail upload: ${thumbUpload.error.message}` }, + { status: 500 }, + ); + } + + const { error: insertErr } = await supabase.from("clips").insert({ + id: clipId, + camera_id: camera.id, + incident_id: meta.incidentId ?? null, + started_at: meta.startedAt, + duration_s: meta.durationS, + storage_path: storagePath, + thumbnail_path: thumbnailPath, + }); + if (insertErr) { + await supabase.storage.from(VIDEO_BUCKET).remove([storagePath]); + await supabase.storage.from(THUMBNAIL_BUCKET).remove([thumbnailPath]); + return NextResponse.json( + { error: `db insert: ${insertErr.message}` }, + { status: 500 }, + ); + } + + if (meta.tags.length > 0) { + const rows = meta.tags.map((tag) => ({ clip_id: clipId, tag })); + const tagErr = await supabase.from("clip_tags").insert(rows); + if (tagErr.error) { + console.warn(`clip ${clipId} tag insert failed: ${tagErr.error.message}`); + } + } + + const body: IngestResponse = { clipId, storagePath, thumbnailPath }; + return NextResponse.json(body, { status: 201 }); +} diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts index 0251a01..3cdc3b9 100644 --- a/apps/web/lib/env.ts +++ b/apps/web/lib/env.ts @@ -6,6 +6,7 @@ const schema = z.object({ SUPABASE_SERVICE_ROLE_KEY: z.string().min(20).optional(), DATABASE_URL: z.string().url().optional(), CRON_SECRET: z.string().min(16).optional(), + INGEST_SECRET: z.string().min(16).optional(), }); const blank = (v: string | undefined) => (v && v.length > 0 ? v : undefined); @@ -16,4 +17,5 @@ export const env = schema.parse({ SUPABASE_SERVICE_ROLE_KEY: blank(process.env.SUPABASE_SERVICE_ROLE_KEY), DATABASE_URL: blank(process.env.DATABASE_URL), CRON_SECRET: blank(process.env.CRON_SECRET), + INGEST_SECRET: blank(process.env.INGEST_SECRET), }); diff --git a/apps/web/lib/ingest/parse.test.ts b/apps/web/lib/ingest/parse.test.ts new file mode 100644 index 0000000..b8033c1 --- /dev/null +++ b/apps/web/lib/ingest/parse.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import { + buildStoragePaths, + extFromMime, + parseIngestForm, +} from "./parse"; +import { INGEST_FIELDS } from "./types"; + +function makeForm( + overrides: Partial> = {}, + files: { video?: Blob | null; thumbnail?: Blob | null } = {}, +): FormData { + const form = new FormData(); + const defaults: Record = { + caltransId: "TVD04--001", + startedAt: "2026-05-16T12:34:56.000Z", + durationS: "30", + tags: undefined, + incidentId: undefined, + video: undefined, + thumbnail: undefined, + }; + const merged = { ...defaults, ...overrides }; + for (const key of Object.keys(INGEST_FIELDS) as (keyof typeof INGEST_FIELDS)[]) { + const value = merged[key]; + if (value !== undefined) form.set(INGEST_FIELDS[key], value); + } + const video = files.video === undefined ? new Blob(["v"], { type: "video/webm" }) : files.video; + const thumbnail = + files.thumbnail === undefined ? new Blob(["t"], { type: "image/jpeg" }) : files.thumbnail; + if (video) form.set(INGEST_FIELDS.video, video); + if (thumbnail) form.set(INGEST_FIELDS.thumbnail, thumbnail); + return form; +} + +describe("parseIngestForm", () => { + it("accepts a well-formed payload", () => { + const result = parseIngestForm(makeForm({ tags: "collision,fire" })); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.meta.caltransId).toBe("TVD04--001"); + expect(result.meta.durationS).toBe(30); + expect(result.meta.tags).toEqual(["collision", "fire"]); + expect(result.meta.incidentId).toBeUndefined(); + } + }); + + it("rejects when video file is missing", () => { + const result = parseIngestForm(makeForm({}, { video: null })); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(400); + expect(result.error).toMatch(/video and thumbnail/); + } + }); + + it("rejects when thumbnail file is missing", () => { + const result = parseIngestForm(makeForm({}, { thumbnail: null })); + expect(result.ok).toBe(false); + }); + + it("rejects invalid started_at", () => { + const result = parseIngestForm(makeForm({ startedAt: "not-a-date" })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(400); + }); + + it("rejects negative duration", () => { + const result = parseIngestForm(makeForm({ durationS: "-1" })); + expect(result.ok).toBe(false); + }); + + it("rejects duration over one hour", () => { + const result = parseIngestForm(makeForm({ durationS: "3601" })); + expect(result.ok).toBe(false); + }); + + it("rejects malformed incident_id", () => { + const result = parseIngestForm(makeForm({ incidentId: "not-a-uuid" })); + expect(result.ok).toBe(false); + }); + + it("treats empty tags as an empty list", () => { + const result = parseIngestForm(makeForm({ tags: "" })); + expect(result.ok).toBe(true); + if (result.ok) expect(result.meta.tags).toEqual([]); + }); + + it("trims whitespace in tags and drops empties", () => { + const result = parseIngestForm(makeForm({ tags: " a , , b " })); + expect(result.ok).toBe(true); + if (result.ok) expect(result.meta.tags).toEqual(["a", "b"]); + }); +}); + +describe("extFromMime", () => { + it.each([ + ["video/webm", "webm", "webm"], + ["video/mp4", "webm", "mp4"], + ["image/jpeg", "jpg", "jpg"], + ["image/png", "jpg", "png"], + ["IMAGE/JPEG", "jpg", "jpg"], + ["", "webm", "webm"], + ["application/octet-stream", "webm", "webm"], + ])("mime=%s fallback=%s -> %s", (mime, fallback, expected) => { + expect(extFromMime(mime, fallback)).toBe(expected); + }); +}); + +describe("buildStoragePaths", () => { + it("composes {cameraId}/{clipId}.{ext} for both buckets", () => { + const paths = buildStoragePaths( + "cam-uuid", + "clip-uuid", + "video/mp4", + "image/png", + ); + expect(paths.storagePath).toBe("cam-uuid/clip-uuid.mp4"); + expect(paths.thumbnailPath).toBe("cam-uuid/clip-uuid.png"); + }); + + it("falls back to webm and jpg for unknown mimes", () => { + const paths = buildStoragePaths("cam", "clip", "", ""); + expect(paths.storagePath).toBe("cam/clip.webm"); + expect(paths.thumbnailPath).toBe("cam/clip.jpg"); + }); +}); diff --git a/apps/web/lib/ingest/parse.ts b/apps/web/lib/ingest/parse.ts new file mode 100644 index 0000000..bd58106 --- /dev/null +++ b/apps/web/lib/ingest/parse.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; +import { INGEST_FIELDS } from "./types"; + +export const ingestMetaSchema = z.object({ + caltransId: z.string().min(1), + startedAt: z.string().datetime({ offset: true }), + durationS: z.coerce.number().int().positive().max(60 * 60), + incidentId: z.string().uuid().optional(), + tags: z + .string() + .optional() + .transform((s) => + s + ? s + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + : [], + ), +}); + +export type IngestMeta = z.infer; + +export interface ParseResult { + ok: true; + meta: IngestMeta; + video: Blob; + thumbnail: Blob; +} + +export interface ParseError { + ok: false; + status: number; + error: string; +} + +export function parseIngestForm(form: FormData): ParseResult | ParseError { + const video = form.get(INGEST_FIELDS.video); + const thumbnail = form.get(INGEST_FIELDS.thumbnail); + if (!(video instanceof Blob) || !(thumbnail instanceof Blob)) { + return { + ok: false, + status: 400, + error: "video and thumbnail files required", + }; + } + + const parsed = ingestMetaSchema.safeParse({ + caltransId: form.get(INGEST_FIELDS.caltransId), + startedAt: form.get(INGEST_FIELDS.startedAt), + durationS: form.get(INGEST_FIELDS.durationS), + incidentId: form.get(INGEST_FIELDS.incidentId) || undefined, + tags: form.get(INGEST_FIELDS.tags) || undefined, + }); + if (!parsed.success) { + return { + ok: false, + status: 400, + error: parsed.error.issues[0]?.message ?? "invalid payload", + }; + } + + return { ok: true, meta: parsed.data, video, thumbnail }; +} + +const MIME_EXT: Record = { + "video/webm": "webm", + "video/mp4": "mp4", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/png": "png", + "image/webp": "webp", +}; + +export function extFromMime(mime: string, fallback: string): string { + if (!mime) return fallback; + return MIME_EXT[mime.toLowerCase()] ?? fallback; +} + +export function buildStoragePaths( + cameraId: string, + clipId: string, + videoMime: string, + thumbnailMime: string, +): { storagePath: string; thumbnailPath: string } { + return { + storagePath: `${cameraId}/${clipId}.${extFromMime(videoMime, "webm")}`, + thumbnailPath: `${cameraId}/${clipId}.${extFromMime(thumbnailMime, "jpg")}`, + }; +} diff --git a/apps/web/lib/ingest/types.ts b/apps/web/lib/ingest/types.ts new file mode 100644 index 0000000..8eac78e --- /dev/null +++ b/apps/web/lib/ingest/types.ts @@ -0,0 +1,26 @@ +/** + * Wire contract for POST /api/clips/ingest. + * + * Producers (e.g. the OpenClaw camera worker) send a multipart/form-data + * request with these fields. Keep field names stable — they are the public + * surface this app exposes to the detection pipeline. + */ +export const INGEST_FIELDS = { + caltransId: "caltrans_id", + startedAt: "started_at", + durationS: "duration_s", + tags: "tags", + incidentId: "incident_id", + video: "video", + thumbnail: "thumbnail", +} as const; + +export interface IngestResponse { + clipId: string; + storagePath: string; + thumbnailPath: string; +} + +export interface IngestError { + error: string; +} diff --git a/apps/web/lib/supabase/service.ts b/apps/web/lib/supabase/service.ts new file mode 100644 index 0000000..5d10074 --- /dev/null +++ b/apps/web/lib/supabase/service.ts @@ -0,0 +1,15 @@ +import { createClient as createSupabaseClient } from "@supabase/supabase-js"; +import { env } from "@/lib/env"; + +export function createServiceClient() { + if (!env.SUPABASE_SERVICE_ROLE_KEY) { + throw new Error("SUPABASE_SERVICE_ROLE_KEY not set"); + } + return createSupabaseClient( + env.NEXT_PUBLIC_SUPABASE_URL, + env.SUPABASE_SERVICE_ROLE_KEY, + { + auth: { persistSession: false, autoRefreshToken: false }, + }, + ); +} diff --git a/apps/web/package.json b/apps/web/package.json index 2b44aff..d65db44 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,8 @@ "build": "next build", "start": "next start", "lint": "next lint", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@caltrans/db": "workspace:*", @@ -33,6 +34,7 @@ "eslint": "^9.0.0", "eslint-config-next": "15.5.18", "tailwindcss": "^4.0.0-beta.7", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "vitest": "^2.1.0" } } diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..718be44 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; + +export default defineConfig({ + test: { + environment: "node", + include: ["lib/**/*.test.ts"], + }, + resolve: { + alias: { + "@": fileURLToPath(new URL("./", import.meta.url)), + }, + }, +});