Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions apps/web/app/api/clips/ingest/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
2 changes: 2 additions & 0 deletions apps/web/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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),
});
127 changes: 127 additions & 0 deletions apps/web/lib/ingest/parse.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<keyof typeof INGEST_FIELDS, string>> = {},
files: { video?: Blob | null; thumbnail?: Blob | null } = {},
): FormData {
const form = new FormData();
const defaults: Record<keyof typeof INGEST_FIELDS, string | undefined> = {
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");
});
});
90 changes: 90 additions & 0 deletions apps/web/lib/ingest/parse.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ingestMetaSchema>;

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<string, string> = {
"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")}`,
};
}
26 changes: 26 additions & 0 deletions apps/web/lib/ingest/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
15 changes: 15 additions & 0 deletions apps/web/lib/supabase/service.ts
Original file line number Diff line number Diff line change
@@ -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 },
},
);
}
Loading
Loading