T-HUB/apps/api/src/video/video.service.ts
glass beadian b24ebc5862 fix: logic-audit P0/P1 wave — CORS PUT, chunked cap, presign checksums, logout await, refresh rotation race
From the logic audit (HEAD 04f5f7e):
- P0 CORS: enableCors methods lacked PUT — browsers could not chunk-upload
  at all (cross-origin preflight refused)
- P0 chunked: validateUpload enforced the 2 GiB whole-body cap on chunked
  completes, so every >2 GiB chunked upload failed after full upload
- P1 presignPut: the SDK's flexible-checksum default added an
  x-amz-checksum-crc32 header browsers cannot reproduce; S3Client now uses
  requestChecksumCalculation WHEN_REQUIRED
- P1 refresh rotation race: concurrent refreshes both succeeded (consume
  ignored rowCount) forking the token family; consume now returns whether
  it won and the loser revokes the chain
- P2 logout: awaits the revocation and ignores missing/forged bearers
honestly
2026-10-18 10:18:02 +00:00

776 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { randomUUID } from "node:crypto";
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BadRequestException, Inject, Injectable, NotFoundException, Optional, ServiceUnavailableException } from "@nestjs/common";
import type { PoolClient } from "pg";
import { appendTenantAudit, type AuditEvent } from "../audit/audit.service.js";
import { withTenant } from "../db/client.js";
import { probeVideoFile, type VideoProbeResult } from "./ffprobe.js";
import {
S3Storage, s3ConfigFromEnv, type StorageService,
directEnabled, directObjectKey,
} from "../storage/storage.js";
import { type ProduceRequest } from "./produce-request.js";
import { MAX_CHUNKED_UPLOAD_BYTES, MAX_VIDEO_UPLOAD_BYTES } from "./upload-limits.js";
const NO_OP_TRANSCODE_NOTE = "Transcode skipped: original stored object retained; rendering belongs to the media-worker compositor.";
export type VideoProcessingJobState = "queued" | "probing" | "transcoding" | "complete" | "failed";
export interface VideoAssetRecord {
id: string;
workspaceId: string;
storageKey: string;
mimeType: string;
sizeBytes: number;
width?: number;
height?: number;
durationMs?: number;
frameRate?: number;
codec?: string;
audioCodec?: string;
thumbnailKey?: string;
}
export type VideoProcessingJobKind = "probe" | "compose" | "mix";
export interface VideoProcessingJobRecord {
id: string;
workspaceId: string;
videoAssetId: string;
state: VideoProcessingJobState;
/** What the media-worker should do; 'probe' is the ingest default. */
kind: VideoProcessingJobKind;
/** Producer request (kind compose/mix): { ...request, requestedBy }. */
input?: ProduceJobInput;
/** The video_assets row this job writes when it finishes. */
producedAssetId?: string;
progressPercent?: number;
outputKey?: string;
error?: string;
processingNote?: string;
}
export interface ProduceJobInput extends ProduceRequest {
requestedBy: string;
}
export interface ProduceRequestResult {
/** The newly queued compose/mix job. */
job: VideoProcessingJobRecord;
videoAsset: VideoAssetRecord;
}
export interface VideoPublicationSummary {
connectionId: string;
remoteId: string;
platform: string;
publishedAt: Date;
}
export interface VideoLibraryEntryRecord {
videoAsset: VideoAssetRecord;
processingState: VideoProcessingJobState;
publications: VideoPublicationSummary[];
}
export interface VideoLibraryPage {
items: VideoLibraryEntryRecord[];
nextCursor?: string;
}
export interface VideoStatus {
videoAsset: VideoAssetRecord;
processingJob: VideoProcessingJobRecord | null;
}
export type VideoUploadResult = VideoStatus;
/** GET /video/direct/config — the browser's capability check before it seals. */
export interface DirectUploadConfig {
enabled: boolean;
publicKeyJwk: { kty: "EC"; crv: "P-256"; x: string; y: string } | null;
maxBytes: number;
}
/** POST /video/direct/init response. Nothing is persisted until complete. */
export interface DirectUploadInit {
uploadId: string;
objectKey: string;
putUrl: string;
}
/** POST /video/direct/complete request (HEAD-checked, then registered + queued). */
export interface DirectUploadCompleteInput {
uploadId: string;
objectKey: string;
sizeBytes: number;
mimeType: string;
filename: string;
}
/** Presigned PUT lifetime for a browser upload (spec: 900 s). */
export const DIRECT_PUT_TTL_SECONDS = 900;
const DIRECT_KEY_PREFIX = "direct/";
const DIRECT_OBJECT_SUFFIX = ".enc";
type TenantTransaction = <T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>) => Promise<T>;
type AuditAppender = (workspaceId: string, event: AuditEvent) => Promise<string>;
export interface VideoServiceDependencies {
tenantTransaction: TenantTransaction;
appendAudit: AuditAppender;
probe: (inputPath: string) => Promise<VideoProbeResult>;
storage?: StorageService;
}
export const VIDEO_SERVICE_DEPENDENCIES = Symbol("VIDEO_SERVICE_DEPENDENCIES");
const DEFAULT_DEPENDENCIES: VideoServiceDependencies = {
tenantTransaction: withTenant,
appendAudit: appendTenantAudit,
probe: probeVideoFile,
};
@Injectable()
export class VideoService {
private readonly dependencies: VideoServiceDependencies;
private storage?: StorageService;
constructor(
@Optional() @Inject(VIDEO_SERVICE_DEPENDENCIES) dependencies?: Partial<VideoServiceDependencies>,
) {
this.dependencies = { ...DEFAULT_DEPENDENCIES, ...dependencies };
this.storage = dependencies?.storage;
}
async uploadVideoBytes(workspaceId: string, bytes: Buffer, mimeType: string): Promise<VideoUploadResult> {
validateUpload("pending", mimeType, bytes.length);
const storage = this.requireStorage();
const stored = await storage.putContent(bytes, mimeType).catch(() => {
throw new ServiceUnavailableException("Video storage unavailable: could not store encrypted upload in S3/MinIO.");
});
return this.registerAndProbe(workspaceId, stored.key, stored.contentType, stored.sizeBytes);
}
async importVideoBytes(
workspaceId: string,
bytes: Buffer,
mimeType: string,
source: "drive" | "chat",
sourceRef: string,
): Promise<VideoUploadResult> {
validateUpload("pending", mimeType, bytes.length);
const storage = this.requireStorage();
const stored = await storage.putContent(bytes, mimeType).catch(() => {
throw new ServiceUnavailableException("Video storage unavailable: could not store imported video.");
});
const result = await this.registerAndProbe(workspaceId, stored.key, stored.contentType, stored.sizeBytes);
await this.audit(workspaceId, "video.imported", result.videoAsset.id, { source, sourceRef, sizeBytes: bytes.length });
return result;
}
/**
* Chunked-ingest tail end: seal + store + probe an already-assembled file on
* local disk (see chunked-upload.ts). The bytes never sat in a request body,
* so the 2 GiB whole-body cap does not apply — MAX_CHUNKED_UPLOAD_BYTES does.
*
* MEMORY: reading the assembled file into one Buffer peaks around 2× file
* size of RAM (seal takes a Buffer and S3 takes the sealed copy). A 20 GB
* upload therefore needs a host with ≥ 48 GB RAM; /tmp must also hold the
* file itself.
*/
async uploadVideoFromPath(
workspaceId: string,
inputPath: string,
mimeType: string,
sizeBytes: number,
): Promise<VideoUploadResult> {
validateUpload("pending", mimeType, sizeBytes);
if (sizeBytes > MAX_CHUNKED_UPLOAD_BYTES) {
throw new BadRequestException("video exceeds the 100 GiB chunked upload limit");
}
const onDisk = await stat(inputPath).catch(() => {
throw new BadRequestException("assembled upload file is missing on disk");
});
if (onDisk.size !== sizeBytes) {
throw new BadRequestException(
`assembled upload is ${onDisk.size} bytes on disk but declared ${sizeBytes}`,
);
}
const storage = this.requireStorage();
const bytes = await readFile(inputPath).catch(() => {
throw new ServiceUnavailableException("could not read the assembled upload from disk");
});
const stored = await storage.putContent(bytes, mimeType).catch(() => {
throw new ServiceUnavailableException("Video storage unavailable: could not store encrypted upload in S3/MinIO.");
});
return this.registerAndProbe(workspaceId, stored.key, stored.contentType, stored.sizeBytes);
}
// -------------------------------------------------------------------------
// Direct-to-R2 (browser-sealed SPENC2): the api mints the presign and the
// rows; the payload goes browser → R2 → media-worker. No video bytes ever
// touch this process, so the chunked/whole-body memory ceilings do not apply.
// -------------------------------------------------------------------------
/** Whether this deployment can unwrap browser-sealed frames (DIRECT_EC_PRIVATE_KEY). */
async directUploadConfig(): Promise<DirectUploadConfig> {
const storage = this.requireStorage();
const publicKeyJwk = await storage.directPublicJwk();
return {
enabled: directEnabled(),
publicKeyJwk,
maxBytes: MAX_CHUNKED_UPLOAD_BYTES,
};
}
/**
* Mint the presigned PUT for a browser upload. The size is bounded up front
* so an oversized pick fails before any bytes are sealed; nothing is
* persisted — the asset row only appears on complete, so an abandoned PUT
* leaves an orphan object (no row), never a dangling row without an object.
*/
async initDirectUpload(
workspaceId: string,
input: { filename: string; mimeType: string; sizeBytes: number },
): Promise<DirectUploadInit> {
validateDirectUpload(input);
if ((await this.requireStorage().directPublicJwk()) === null) {
throw new ServiceUnavailableException("DIRECT_EC_PRIVATE_KEY is not configured");
}
// ONE uuid: the object key binds to the upload id so /complete can pin it.
const uploadId = randomUUID();
const objectKey = `direct/${uploadId}.enc`;
const putUrl = await this.requireStorage()
.presignPut(objectKey, input.mimeType, DIRECT_PUT_TTL_SECONDS)
.catch(() => { throw new ServiceUnavailableException("Video storage is unavailable: could not presign the direct PUT"); });
return { uploadId, objectKey, putUrl };
}
/**
* Register a completed direct upload. The object is HEAD-checked (exists,
* exact declared size) BEFORE any row is written, so metadata can never point
* at a missing or partial PUT — the same gate /files/direct/complete uses.
*
* The probe deliberately does NOT run here: the insert queues kind 'probe'
* for the media-worker, which downloads the sealed object off R2, decrypts it
* and runs ffprobe itself. This box never touches video bytes on this path.
*/
async completeDirectUpload(workspaceId: string, input: DirectUploadCompleteInput): Promise<VideoStatus> {
if (!isUuid(input.uploadId)) throw new BadRequestException("uploadId must be a UUID");
if (input.objectKey !== `${DIRECT_KEY_PREFIX}${input.uploadId}${DIRECT_OBJECT_SUFFIX}`) {
throw new BadRequestException("objectKey must be direct/<uploadId>.enc");
}
validateDirectUpload(input);
const head = await this.requireStorage().headObject(input.objectKey).catch(() => {
throw new ServiceUnavailableException("Video storage is unavailable: could not verify the uploaded object");
});
if (!head.exists || head.contentLength !== input.sizeBytes) {
throw new BadRequestException("uploaded object is missing or its size does not match sizeBytes");
}
const registered = await this.dependencies.tenantTransaction(workspaceId, async (client) => {
const assetResult = await client.query(
`INSERT INTO video_assets (workspace_id, storage_key, mime_type, size_bytes)
VALUES ($1, $2, $3, $4) RETURNING *`,
[workspaceId, input.objectKey, input.mimeType, input.sizeBytes],
);
const videoAsset = assetFromRow(requiredRow(assetResult.rows[0], "created video asset"));
// kind 'probe' is 039's default: the media-worker claims it and runs ffprobe.
const jobResult = await client.query(
`INSERT INTO video_processing_jobs (workspace_id, video_asset_id, state, kind)
VALUES ($1, $2, 'queued', 'probe') RETURNING *`,
[workspaceId, videoAsset.id],
);
return {
videoAsset,
processingJob: jobFromRow(requiredRow(jobResult.rows[0], "created video processing job")),
};
});
await this.audit(workspaceId, "video.uploaded", registered.videoAsset.id, {
mimeType: input.mimeType,
sizeBytes: input.sizeBytes,
uploadPath: "direct",
});
// The worker probes; status reports the queued job the drawer polls on.
return this.getVideoStatus(registered.videoAsset.id, workspaceId);
}
async uploadVideo(
workspaceId: string,
storageKey: string,
mimeType: string,
sizeBytes: number,
): Promise<VideoUploadResult> {
validateUpload(storageKey, mimeType, sizeBytes);
// Legacy metadata registration is accepted only for an object that really exists.
const bytes = await this.requireStorage().getObject(storageKey).catch(() => {
throw new BadRequestException("storageKey must identify an existing readable encrypted object; upload a file first");
});
if (bytes.length !== sizeBytes) throw new BadRequestException("sizeBytes does not match the stored object");
return this.registerAndProbe(workspaceId, storageKey, mimeType, sizeBytes);
}
private async registerAndProbe(
workspaceId: string, storageKey: string, mimeType: string, sizeBytes: number,
): Promise<VideoUploadResult> {
const uploaded = await this.dependencies.tenantTransaction(workspaceId, async (client) => {
const assetResult = await client.query(
`INSERT INTO video_assets (workspace_id, storage_key, mime_type, size_bytes)
VALUES ($1, $2, $3, $4) RETURNING *`,
[workspaceId, storageKey, mimeType, sizeBytes],
);
const videoAsset = assetFromRow(requiredRow(assetResult.rows[0], "created video asset"));
const jobResult = await client.query(
`INSERT INTO video_processing_jobs (workspace_id, video_asset_id, state)
VALUES ($1, $2, 'queued') RETURNING *`,
[workspaceId, videoAsset.id],
);
return {
videoAsset,
processingJob: jobFromRow(requiredRow(jobResult.rows[0], "created video processing job")),
};
});
try {
await this.probeVideo(uploaded.videoAsset.id, workspaceId);
} catch (error) {
// Probe errors are recorded on the job; return its ID and failure in status.
const status = await this.getVideoStatus(uploaded.videoAsset.id, workspaceId);
if (status.processingJob === null || status.processingJob.state !== "failed") throw error;
}
await this.audit(workspaceId, "video.uploaded", uploaded.videoAsset.id, {
mimeType,
sizeBytes,
});
return this.getVideoStatus(uploaded.videoAsset.id, workspaceId);
}
async getVideoStatus(assetId: string, workspaceId: string): Promise<VideoStatus> {
return this.dependencies.tenantTransaction(workspaceId, async (client) => {
const result = await client.query(
`SELECT va.*,
j.id AS job_id, j.video_asset_id AS job_video_asset_id,
j.state AS job_state, j.progress_percent AS job_progress_percent,
j.output_key AS job_output_key, j.error AS job_error,
j.kind AS job_kind, j.input_json AS job_input_json, j.produced_asset_id AS job_produced_asset_id
FROM video_assets va
LEFT JOIN LATERAL (
SELECT * FROM video_processing_jobs
WHERE video_asset_id = va.id
ORDER BY created_at DESC, id DESC LIMIT 1
) j ON true
WHERE va.id = $1`,
[assetId],
);
if (result.rowCount !== 1) throw new NotFoundException(`video ${assetId} not found`);
const row = requiredRow(result.rows[0], "video status");
// A jobless asset is a legitimate state (pre-probe direct registrations,
// legacy rows): surface it instead of 500ing the library click.
if (row.job_id === null || row.job_id === undefined) {
return { videoAsset: assetFromRow(row), processingJob: null };
}
return { videoAsset: assetFromRow(row), processingJob: joinedJobFromRow(row) };
});
}
async probeVideo(assetId: string, workspaceId: string): Promise<VideoAssetRecord> {
const claimed = await this.dependencies.tenantTransaction(workspaceId, async (client) => {
const asset = await client.query("SELECT * FROM video_assets WHERE id = $1", [assetId]);
if (asset.rowCount !== 1) throw new NotFoundException(`video ${assetId} not found`);
const changed = await client.query(
`UPDATE video_processing_jobs
SET state = 'probing', progress_percent = 10, error = NULL, updated_at = now()
WHERE id = (
SELECT id FROM video_processing_jobs
WHERE video_asset_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1
) AND state = 'queued'
RETURNING id`,
[assetId],
);
if (changed.rowCount !== 1) {
const latest = await client.query(
`SELECT state FROM video_processing_jobs
WHERE video_asset_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1`,
[assetId],
);
const state = latest.rows[0]?.state;
throw new BadRequestException(
state === undefined
? `video ${assetId} has no processing job`
: `cannot probe video in processing state ${String(state)}`,
);
}
return {
storageKey: String(asset.rows[0]?.storage_key),
jobId: String(changed.rows[0]?.id),
};
});
let metadata: VideoProbeResult;
let videoAsset: VideoAssetRecord;
try {
const bytes = await this.requireStorage().getObject(claimed.storageKey);
const directory = await mkdtemp(join(tmpdir(), "tmbh-video-"));
try {
const inputPath = join(directory, "input");
await writeFile(inputPath, bytes, { mode: 0o600 });
metadata = await this.dependencies.probe(inputPath);
} finally {
await rm(directory, { recursive: true, force: true });
}
videoAsset = await this.dependencies.tenantTransaction(workspaceId, async (client) => {
const updated = await client.query(
`UPDATE video_assets
SET width = $2, height = $3, duration_ms = $4, frame_rate = $5,
codec = $6, audio_codec = $7
WHERE id = $1 RETURNING *`,
[
assetId,
metadata.width ?? null,
metadata.height ?? null,
metadata.durationMs ?? null,
metadata.frameRate ?? null,
metadata.codec ?? null,
metadata.audioCodec ?? null,
],
);
if (updated.rowCount !== 1) throw new NotFoundException(`video ${assetId} not found`);
const transcoding = await client.query(
`UPDATE video_processing_jobs
SET state = 'transcoding', progress_percent = 90, error = NULL, updated_at = now()
WHERE id = $1 AND state = 'probing' RETURNING id`,
[claimed.jobId],
);
if (transcoding.rowCount !== 1) throw new Error(`video processing job ${claimed.jobId} lost its claim`);
return assetFromRow(requiredRow(updated.rows[0], "probed video asset"));
});
// This slice deliberately retains the source. The compositor owns real transcoding.
await this.dependencies.tenantTransaction(workspaceId, async (client) => {
const completed = await client.query(
`UPDATE video_processing_jobs
SET state = 'complete', progress_percent = 100, output_key = $2, error = NULL, updated_at = now()
WHERE id = $1 AND state = 'transcoding' RETURNING id`,
[claimed.jobId, claimed.storageKey],
);
if (completed.rowCount !== 1) throw new Error(`video processing job ${claimed.jobId} lost its claim`);
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await this.dependencies.tenantTransaction(workspaceId, async (client) => {
await client.query(
`UPDATE video_processing_jobs
SET state = 'failed', error = $2, updated_at = now()
WHERE id = $1 AND state IN ('probing', 'transcoding')`,
[claimed.jobId, message],
);
});
throw error;
}
await this.audit(workspaceId, "video.probed", assetId, { ...metadata, processingNote: NO_OP_TRANSCODE_NOTE });
return videoAsset;
}
async listVideos(
workspaceId: string,
cursor: string | undefined,
limit: number,
): Promise<VideoLibraryPage> {
if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
throw new BadRequestException("limit must be an integer between 1 and 200");
}
const decoded = cursor === undefined || cursor === "" ? undefined : decodeCursor(cursor);
return this.dependencies.tenantTransaction(workspaceId, async (client) => {
const result = await client.query(
`SELECT va.*,
COALESCE(j.state, 'queued') AS processing_state,
COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'connectionId', p.connection_id,
'remoteId', p.remote_id,
'platform', c.provider,
'publishedAt', p.published_at
) ORDER BY p.published_at DESC)
FROM publications p
JOIN drafts d ON d.id = p.draft_id
JOIN connections c ON c.id = p.connection_id
WHERE d.post ->> 'videoAssetId' = va.id::text
OR d.post -> 'mediaAssetIds' ? va.id::text
), '[]'::jsonb) AS publications
FROM video_assets va
LEFT JOIN LATERAL (
SELECT state FROM video_processing_jobs
WHERE video_asset_id = va.id
ORDER BY created_at DESC, id DESC LIMIT 1
) j ON true
WHERE ($1::timestamptz IS NULL OR (va.created_at, va.id) < ($1::timestamptz, $2::uuid))
ORDER BY va.created_at DESC, va.id DESC
LIMIT $3`,
[decoded?.createdAt ?? null, decoded?.id ?? null, limit + 1],
);
const rows = result.rows.map((row: unknown) => requiredRow(row, "video library entry"));
const hasMore = rows.length > limit;
const pageRows = rows.slice(0, limit);
const items = pageRows.map(libraryEntryFromRow);
const last = pageRows[pageRows.length - 1];
return {
items,
...(hasMore && last !== undefined
? { nextCursor: encodeCursor({ createdAt: new Date(String(last.created_at)).toISOString(), id: String(last.id) }) }
: {}),
};
});
}
/**
* Queue a derived asset: burn overlays (and optionally mix a voiceover) over an
* existing asset. Validates nothing itself — the controller parses the request
* with parseProduceRequest before calling, and the media-worker re-validates.
*/
async produceVideo(
assetId: string,
workspaceId: string,
request: ProduceRequest,
requestedBy: string,
): Promise<ProduceRequestResult> {
const input: ProduceJobInput = { ...request, requestedBy };
const kind: VideoProcessingJobKind = request.voiceover === undefined ? "compose" : "mix";
const created = await this.dependencies.tenantTransaction(workspaceId, async (client) => {
const source = await client.query("SELECT * FROM video_assets WHERE id = $1", [assetId]);
if (source.rowCount !== 1) throw new NotFoundException(`video ${assetId} not found`);
const jobResult = await client.query(
`INSERT INTO video_processing_jobs (workspace_id, video_asset_id, state, kind, input_json)
VALUES ($1, $2, 'queued', $3, $4::jsonb) RETURNING *`,
[workspaceId, assetId, kind, JSON.stringify(input)],
);
return {
videoAsset: assetFromRow(requiredRow(source.rows[0], "source video asset")),
job: jobFromRow(requiredRow(jobResult.rows[0], "created produce job")),
};
});
await this.audit(workspaceId, "video.produce_requested", assetId, {
kind,
jobId: created.job.id,
overlayCount: request.overlays?.length ?? 0,
voiceover: request.voiceover === undefined ? null : { assetId: request.voiceover.assetId, duckOriginal: request.voiceover.duckOriginal ?? false },
outputLabel: request.outputLabel ?? null,
lane: request.lane ?? null,
});
return created;
}
async deleteVideo(assetId: string, workspaceId: string): Promise<void> {
// Objects are shared by content hash across assets/workspaces. Retain for future
// reference-aware GC; deleting here could destroy another asset's source.
await this.dependencies.tenantTransaction(workspaceId, async (client) => {
const removed = await client.query("DELETE FROM video_assets WHERE id = $1 RETURNING id", [assetId]);
if (removed.rowCount !== 1) throw new NotFoundException(`video ${assetId} not found`);
});
await this.audit(workspaceId, "video.deleted", assetId, {});
}
private requireStorage(): StorageService {
if (this.storage !== undefined) return this.storage;
const config = s3ConfigFromEnv();
if (!config.accessKeyId?.trim() || !config.secretAccessKey?.trim()) {
throw new ServiceUnavailableException(
"Video storage is not configured: set S3_ACCESS_KEY and S3_SECRET_KEY, plus S3_ENDPOINT for MinIO.",
);
}
try {
this.storage = new S3Storage(config);
} catch {
throw new ServiceUnavailableException("Video storage configuration is invalid; check S3 settings and S3_KEK.");
}
return this.storage;
}
private async audit(
workspaceId: string,
action: "video.uploaded" | "video.probed" | "video.deleted" | "video.imported" | "video.produce_requested",
targetId: string,
diff: Record<string, unknown>,
): Promise<void> {
await this.dependencies.appendAudit(workspaceId, {
actorUserId: null,
actorRole: "api",
action,
targetType: "video_asset",
targetId,
diff,
});
}
}
/** Shared validation for the direct init/complete payloads (no sniffing: the declared type stands). */
function validateDirectUpload(input: { filename?: string; mimeType: string; sizeBytes: number }): void {
if (input.filename !== undefined && (input.filename.trim() === "" || input.filename.length > 255)) {
throw new BadRequestException("filename must be 1-255 characters");
}
if (!input.mimeType.toLowerCase().startsWith("video/")) {
throw new BadRequestException("mimeType must be a video media type");
}
if (!Number.isSafeInteger(input.sizeBytes) || input.sizeBytes < 1) {
throw new BadRequestException("sizeBytes must be a positive safe integer");
}
if (input.sizeBytes > MAX_CHUNKED_UPLOAD_BYTES) {
throw new BadRequestException("video exceeds the 100 GiB chunked upload limit");
}
}
function validateUpload(storageKey: string, mimeType: string, sizeBytes: number): void {
if (storageKey.trim() === "") throw new BadRequestException("storageKey must be a non-empty string");
if (!mimeType.toLowerCase().startsWith("video/")) {
throw new BadRequestException("mimeType must be a video media type");
}
if (!Number.isSafeInteger(sizeBytes) || sizeBytes < 1) {
throw new BadRequestException("sizeBytes must be a positive safe integer");
}
if (sizeBytes > MAX_CHUNKED_UPLOAD_BYTES) throw new BadRequestException("video exceeds the chunked upload limit");
}
function requiredRow(row: unknown, description: string): Record<string, unknown> {
if (typeof row !== "object" || row === null || Array.isArray(row)) {
throw new Error(`database did not return ${description}`);
}
return row as Record<string, unknown>;
}
function assetFromRow(row: Record<string, unknown>): VideoAssetRecord {
return {
id: String(row.id),
workspaceId: String(row.workspace_id),
storageKey: String(row.storage_key),
mimeType: String(row.mime_type),
sizeBytes: safeDatabaseNumber(row.size_bytes, "size_bytes"),
...optionalNumber("width", row.width),
...optionalNumber("height", row.height),
...optionalNumber("durationMs", row.duration_ms),
...optionalNumber("frameRate", row.frame_rate),
...optionalString("codec", row.codec),
...optionalString("audioCodec", row.audio_codec),
...optionalString("thumbnailKey", row.thumbnail_key),
...optionalString("derivedFrom", row.derived_from),
};
}
function jobFromRow(row: Record<string, unknown>): VideoProcessingJobRecord {
return {
id: String(row.id),
workspaceId: String(row.workspace_id),
videoAssetId: String(row.video_asset_id),
state: processingState(row.state),
kind: processingKind(row.kind),
...(row.input_json === null || row.input_json === undefined ? {} : { input: requiredRow(row.input_json, "job input") as unknown as ProduceJobInput }),
...optionalString("producedAssetId", row.produced_asset_id),
...optionalNumber("progressPercent", row.progress_percent),
...optionalString("outputKey", row.output_key),
...optionalString("error", row.error),
};
}
function joinedJobFromRow(row: Record<string, unknown>): VideoProcessingJobRecord {
const job = jobFromRow({
id: row.job_id,
workspace_id: row.workspace_id,
video_asset_id: row.job_video_asset_id,
state: row.job_state,
progress_percent: row.job_progress_percent,
output_key: row.job_output_key,
error: row.job_error,
kind: row.job_kind,
input_json: row.job_input_json,
produced_asset_id: row.job_produced_asset_id,
});
return {
...job,
...(job.state === "complete" && job.outputKey === row.storage_key ? { processingNote: NO_OP_TRANSCODE_NOTE } : {}),
};
}
function libraryEntryFromRow(row: Record<string, unknown>): VideoLibraryEntryRecord {
const rawPublications = Array.isArray(row.publications) ? row.publications : [];
return {
videoAsset: assetFromRow(row),
processingState: processingState(row.processing_state),
publications: rawPublications.map((publication) => publicationFromJson(requiredRow(publication, "publication"))),
};
}
function publicationFromJson(row: Record<string, unknown>): VideoPublicationSummary {
return {
connectionId: String(row.connectionId),
remoteId: String(row.remoteId),
platform: String(row.platform),
publishedAt: new Date(String(row.publishedAt)),
};
}
function processingKind(value: unknown): VideoProcessingJobKind {
if (value === "probe" || value === "compose" || value === "mix") return value;
// Rows created before migration 039 have no kind and are probe jobs.
if (value === null || value === undefined) return "probe";
throw new Error(`invalid video processing job kind: ${String(value)}`);
}
function processingState(value: unknown): VideoProcessingJobState {
if (
value === "queued" ||
value === "probing" ||
value === "transcoding" ||
value === "complete" ||
value === "failed"
) {
return value;
}
throw new Error(`invalid video processing state: ${String(value)}`);
}
function safeDatabaseNumber(value: unknown, field: string): number {
const number = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
if (!Number.isSafeInteger(number)) throw new Error(`${field} is outside JavaScript's safe integer range`);
return number;
}
function optionalNumber<K extends string>(key: K, value: unknown): Partial<Record<K, number>> {
if (value === null || value === undefined) return {};
const number = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
if (!Number.isFinite(number)) throw new Error(`${key} is not numeric`);
return { [key]: number } as Record<K, number>;
}
function optionalString<K extends string>(key: K, value: unknown): Partial<Record<K, string>> {
return value === null || value === undefined ? {} : { [key]: String(value) } as Record<K, string>;
}
interface VideoCursor {
createdAt: string;
id: string;
}
function encodeCursor(cursor: VideoCursor): string {
return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
}
function decodeCursor(cursor: string): VideoCursor {
try {
const decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as unknown;
const row = requiredRow(decoded, "video cursor");
const createdAt = String(row.createdAt);
const id = String(row.id);
if (!Number.isFinite(Date.parse(createdAt)) || !isUuid(id)) throw new Error("invalid fields");
return { createdAt, id };
} catch {
throw new BadRequestException("cursor is invalid");
}
}
function isUuid(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}