From 0083370f40438e2ad5834860906ebb8e57efb54a Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 19:06:53 -0500 Subject: [PATCH 01/16] feat(infra): add a video id to developer transcripts --- .../0056_developer_transcript_video.sql | 2 + .../migrations/meta/0056_snapshot.json | 5846 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + packages/database/schema.ts | 11 +- 4 files changed, 5864 insertions(+), 2 deletions(-) create mode 100644 packages/database/migrations/0056_developer_transcript_video.sql create mode 100644 packages/database/migrations/meta/0056_snapshot.json diff --git a/packages/database/migrations/0056_developer_transcript_video.sql b/packages/database/migrations/0056_developer_transcript_video.sql new file mode 100644 index 00000000000..6821d00c15c --- /dev/null +++ b/packages/database/migrations/0056_developer_transcript_video.sql @@ -0,0 +1,2 @@ +ALTER TABLE `developer_transcripts` ADD `videoId` varchar(15);--> statement-breakpoint +CREATE INDEX `dev_transcripts_app_video_idx` ON `developer_transcripts` (`appId`,`videoId`,`createdAt`,`id`); \ No newline at end of file diff --git a/packages/database/migrations/meta/0056_snapshot.json b/packages/database/migrations/meta/0056_snapshot.json new file mode 100644 index 00000000000..a0d39ce1535 --- /dev/null +++ b/packages/database/migrations/meta/0056_snapshot.json @@ -0,0 +1,5846 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "e34c4bbb-bbe7-4786-ab07-0a9653f6fa17", + "prevId": "2773f4a4-e037-4a2a-9537-3e419186570c", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_in": { + "name": "expires_in", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_in": { + "name": "refresh_token_expires_in", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "tempColumn": { + "name": "tempColumn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + }, + "provider_account_id_idx": { + "name": "provider_account_id_idx", + "columns": ["providerAccountId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "accounts_id": { + "name": "accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_authorization_codes": { + "name": "agent_api_authorization_codes", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUri": { + "name": "redirectUri", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "code_hash_idx": { + "name": "code_hash_idx", + "columns": ["codeHash"], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + }, + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_authorization_codes_id": { + "name": "agent_api_authorization_codes_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_idempotency": { + "name": "agent_api_idempotency", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestHash": { + "name": "requestHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "statusCode": { + "name": "statusCode", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "user_operation_key_idx": { + "name": "user_operation_key_idx", + "columns": ["userId", "operation", "keyHash"], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_idempotency_id": { + "name": "agent_api_idempotency_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_keys": { + "name": "agent_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Cap CLI'" + }, + "scopes": { + "name": "scopes", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "token_hash_idx": { + "name": "token_hash_idx", + "columns": ["tokenHash"], + "isUnique": true + }, + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_keys_id": { + "name": "agent_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_operations": { + "name": "agent_api_operations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resourceId": { + "name": "resourceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resultResourceId": { + "name": "resultResourceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorCode": { + "name": "errorCode", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + }, + "state_updated_at_idx": { + "name": "state_updated_at_idx", + "columns": ["state", "updatedAt"], + "isUnique": false + }, + "resource_id_idx": { + "name": "resource_id_idx", + "columns": ["resourceId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_operations_id": { + "name": "agent_api_operations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth_api_keys": { + "name": "auth_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_id_created_at_idx": { + "name": "user_id_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_api_keys_id": { + "name": "auth_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorId": { + "name": "authorId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "parentCommentId": { + "name": "parentCommentId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mediaKey": { + "name": "mediaKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mediaDuration": { + "name": "mediaDuration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mediaMeta": { + "name": "mediaMeta", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "video_type_created_idx": { + "name": "video_type_created_idx", + "columns": ["videoId", "type", "createdAt", "id"], + "isUnique": false + }, + "author_id_idx": { + "name": "author_id_idx", + "columns": ["authorId"], + "isUnique": false + }, + "parent_comment_id_idx": { + "name": "parent_comment_id_idx", + "columns": ["parentCommentId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "comments_id": { + "name": "comments_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_api_keys": { + "name": "developer_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyType": { + "name": "keyType", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyPrefix": { + "name": "keyPrefix", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryptedKey": { + "name": "encryptedKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "key_hash_idx": { + "name": "key_hash_idx", + "columns": ["keyHash"], + "isUnique": true + }, + "app_key_type_idx": { + "name": "app_key_type_idx", + "columns": ["appId", "keyType"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_api_keys_appId_developer_apps_id_fk": { + "name": "developer_api_keys_appId_developer_apps_id_fk", + "tableFrom": "developer_api_keys", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_api_keys_id": { + "name": "developer_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_app_domains": { + "name": "developer_app_domains", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "varchar(253)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "developer_app_domains_appId_developer_apps_id_fk": { + "name": "developer_app_domains_appId_developer_apps_id_fk", + "tableFrom": "developer_app_domains", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_app_domains_id": { + "name": "developer_app_domains_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "app_domain_unique": { + "name": "app_domain_unique", + "columns": ["appId", "domain"] + } + }, + "checkConstraint": {} + }, + "developer_apps": { + "name": "developer_apps", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logoUrl": { + "name": "logoUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_deleted_idx": { + "name": "owner_deleted_idx", + "columns": ["ownerId", "deletedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "developer_apps_id": { + "name": "developer_apps_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_credit_accounts": { + "name": "developer_credit_accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balanceMicroCredits": { + "name": "balanceMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripePaymentMethodId": { + "name": "stripePaymentMethodId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoTopUpEnabled": { + "name": "autoTopUpEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "autoTopUpThresholdMicroCredits": { + "name": "autoTopUpThresholdMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "autoTopUpAmountCents": { + "name": "autoTopUpAmountCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "app_id_unique": { + "name": "app_id_unique", + "columns": ["appId"], + "isUnique": true + }, + "owner_idx": { + "name": "owner_idx", + "columns": ["ownerId"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_credit_accounts_appId_developer_apps_id_fk": { + "name": "developer_credit_accounts_appId_developer_apps_id_fk", + "tableFrom": "developer_credit_accounts", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_credit_accounts_id": { + "name": "developer_credit_accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_credit_transactions": { + "name": "developer_credit_transactions", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amountMicroCredits": { + "name": "amountMicroCredits", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balanceAfterMicroCredits": { + "name": "balanceAfterMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referenceType": { + "name": "referenceType", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "account_type_created_idx": { + "name": "account_type_created_idx", + "columns": ["accountId", "type", "createdAt"], + "isUnique": false + }, + "account_created_idx": { + "name": "account_created_idx", + "columns": ["accountId", "createdAt"], + "isUnique": false + }, + "account_ref_dedup_idx": { + "name": "account_ref_dedup_idx", + "columns": ["accountId", "referenceId", "referenceType"], + "isUnique": false + } + }, + "foreignKeys": { + "dev_credit_txn_account_fk": { + "name": "dev_credit_txn_account_fk", + "tableFrom": "developer_credit_transactions", + "tableTo": "developer_credit_accounts", + "columnsFrom": ["accountId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_credit_transactions_id": { + "name": "developer_credit_transactions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_daily_storage_snapshots": { + "name": "developer_daily_storage_snapshots", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshotDate": { + "name": "snapshotDate", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totalDurationMinutes": { + "name": "totalDurationMinutes", + "type": "float", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "videoCount": { + "name": "videoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "microCreditsCharged": { + "name": "microCreditsCharged", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processedAt": { + "name": "processedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "developer_daily_storage_snapshots_appId_developer_apps_id_fk": { + "name": "developer_daily_storage_snapshots_appId_developer_apps_id_fk", + "tableFrom": "developer_daily_storage_snapshots", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_daily_storage_snapshots_id": { + "name": "developer_daily_storage_snapshots_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "app_date_unique": { + "name": "app_date_unique", + "columns": ["appId", "snapshotDate"] + } + }, + "checkConstraint": {} + }, + "developer_transcripts": { + "name": "developer_transcripts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalUserId": { + "name": "externalUserId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Untitled'" + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceType": { + "name": "sourceType", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "s3Key": { + "name": "s3Key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resultKey": { + "name": "resultKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceBytes": { + "name": "sourceBytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "durationSeconds": { + "name": "durationSeconds", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requestedLanguage": { + "name": "requestedLanguage", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "mediumtext", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wordCount": { + "name": "wordCount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reservedMicroCredits": { + "name": "reservedMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "billedMicroCredits": { + "name": "billedMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "errorCode": { + "name": "errorCode", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runId": { + "name": "runId", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queuedAt": { + "name": "queuedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startedAt": { + "name": "startedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "dev_transcripts_app_list_idx": { + "name": "dev_transcripts_app_list_idx", + "columns": ["appId", "deletedAt", "createdAt", "id"], + "isUnique": false + }, + "dev_transcripts_app_user_idx": { + "name": "dev_transcripts_app_user_idx", + "columns": ["appId", "externalUserId", "createdAt"], + "isUnique": false + }, + "dev_transcripts_app_status_idx": { + "name": "dev_transcripts_app_status_idx", + "columns": ["appId", "status", "createdAt"], + "isUnique": false + }, + "dev_transcripts_status_updated_idx": { + "name": "dev_transcripts_status_updated_idx", + "columns": ["status", "updatedAt"], + "isUnique": false + }, + "dev_transcripts_app_video_idx": { + "name": "dev_transcripts_app_video_idx", + "columns": ["appId", "videoId", "createdAt", "id"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_transcripts_appId_developer_apps_id_fk": { + "name": "developer_transcripts_appId_developer_apps_id_fk", + "tableFrom": "developer_transcripts", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_transcripts_id": { + "name": "developer_transcripts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_videos": { + "name": "developer_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalUserId": { + "name": "externalUserId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Untitled'" + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fps": { + "name": "fps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "s3Key": { + "name": "s3Key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transcriptionStatus": { + "name": "transcriptionStatus", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'uploading'" + }, + "playbackId": { + "name": "playbackId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "maxResolutionTier": { + "name": "maxResolutionTier", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1080p'" + }, + "renditions": { + "name": "renditions", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storedBytes": { + "name": "storedBytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorReason": { + "name": "errorReason", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processing": { + "name": "processing", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "filesDeletedAt": { + "name": "filesDeletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "app_created_idx": { + "name": "app_created_idx", + "columns": ["appId", "createdAt"], + "isUnique": false + }, + "app_user_idx": { + "name": "app_user_idx", + "columns": ["appId", "externalUserId"], + "isUnique": false + }, + "app_deleted_idx": { + "name": "app_deleted_idx", + "columns": ["appId", "deletedAt"], + "isUnique": false + }, + "playback_id_idx": { + "name": "playback_id_idx", + "columns": ["playbackId"], + "isUnique": true + }, + "files_pending_idx": { + "name": "files_pending_idx", + "columns": ["filesDeletedAt", "deletedAt"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_videos_appId_developer_apps_id_fk": { + "name": "developer_videos_appId_developer_apps_id_fk", + "tableFrom": "developer_videos", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_videos_id": { + "name": "developer_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "directory_users": { + "name": "directory_users", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "directoryId": { + "name": "directoryId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "directory_users_organization_id_idx": { + "name": "directory_users_organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "directory_users_user_org_idx": { + "name": "directory_users_user_org_idx", + "columns": ["userId", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "directory_users_id": { + "name": "directory_users_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "folders": { + "name": "folders", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "created_by_id_idx": { + "name": "created_by_id_idx", + "columns": ["createdById"], + "isUnique": false + }, + "parent_id_idx": { + "name": "parent_id_idx", + "columns": ["parentId"], + "isUnique": false + }, + "space_id_idx": { + "name": "space_id_idx", + "columns": ["spaceId"], + "isUnique": false + }, + "public_parent_id_idx": { + "name": "public_parent_id_idx", + "columns": ["public", "parentId"], + "isUnique": false + }, + "public_space_parent_id_idx": { + "name": "public_space_parent_id_idx", + "columns": ["public", "spaceId", "parentId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "folders_id": { + "name": "folders_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "imported_videos": { + "name": "imported_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "id_idx": { + "name": "id_idx", + "columns": ["id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "imported_videos_orgId_source_source_id_pk": { + "name": "imported_videos_orgId_source_source_id_pk", + "columns": ["orgId", "source", "source_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration_installations": { + "name": "integration_installations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalId": { + "name": "externalId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installedByUserId": { + "name": "installedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryptedCredentials": { + "name": "encryptedCredentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "provider_external_id_idx": { + "name": "provider_external_id_idx", + "columns": ["provider", "externalId"], + "isUnique": true + }, + "organization_provider_display_name_idx": { + "name": "organization_provider_display_name_idx", + "columns": ["organizationId", "provider", "displayName"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "integration_installations_id": { + "name": "integration_installations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "loom_migration_imports": { + "name": "loom_migration_imports", + "columns": { + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "loom_migration_imports_request_idx": { + "name": "loom_migration_imports_request_idx", + "columns": ["requestId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "loom_migration_imports_videoId": { + "name": "loom_migration_imports_videoId", + "columns": ["videoId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "loom_migration_requests": { + "name": "loom_migration_requests", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requestedByUserId": { + "name": "requestedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspaceName": { + "name": "workspaceName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customerNote": { + "name": "customerNote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customerReply": { + "name": "customerReply", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitedAt": { + "name": "invitedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "capMessage": { + "name": "capMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expectedVideoCount": { + "name": "expectedVideoCount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "importedVideoCount": { + "name": "importedVideoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "queuedVideoCount": { + "name": "queuedVideoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "activeImportCount": { + "name": "activeImportCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "activeImportLeaseToken": { + "name": "activeImportLeaseToken", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activeImportLeaseUntil": { + "name": "activeImportLeaseUntil", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastOperatorUserId": { + "name": "lastOperatorUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastOperatorAt": { + "name": "lastOperatorAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completedAt": { + "name": "completedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "loom_migration_active_org_idx": { + "name": "loom_migration_active_org_idx", + "columns": ["activeOrganizationId"], + "isUnique": true + }, + "loom_migration_org_created_idx": { + "name": "loom_migration_org_created_idx", + "columns": ["organizationId", "createdAt"], + "isUnique": false + }, + "loom_migration_status_created_idx": { + "name": "loom_migration_status_created_idx", + "columns": ["status", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "loom_migration_requests_id": { + "name": "loom_migration_requests_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "loops_sync_jobs": { + "name": "loops_sync_jobs", + "columns": { + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "nextAttemptAt": { + "name": "nextAttemptAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "leaseToken": { + "name": "leaseToken", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "leaseUntil": { + "name": "leaseUntil", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failures": { + "name": "failures", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lastError": { + "name": "lastError", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "profileHash": { + "name": "profileHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teammateJoinedAt": { + "name": "teammateJoinedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "syncedEmail": { + "name": "syncedEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastSyncedAt": { + "name": "lastSyncedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "loops_sync_due_idx": { + "name": "loops_sync_due_idx", + "columns": ["nextAttemptAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "loops_sync_jobs_userId": { + "name": "loops_sync_jobs_userId", + "columns": ["userId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_clients": { + "name": "mcp_oauth_clients", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientName": { + "name": "clientName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUris": { + "name": "redirectUris", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "activatedAt": { + "name": "activatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "client_id_idx": { + "name": "client_id_idx", + "columns": ["clientId"], + "isUnique": true + }, + "inactive_created_at_idx": { + "name": "inactive_created_at_idx", + "columns": ["activatedAt", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_clients_id": { + "name": "mcp_oauth_clients_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_codes": { + "name": "mcp_oauth_codes", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUri": { + "name": "redirectUri", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "code_hash_idx": { + "name": "code_hash_idx", + "columns": ["codeHash"], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_codes_id": { + "name": "mcp_oauth_codes_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_registration_quotas": { + "name": "mcp_oauth_registration_quotas", + "columns": { + "windowId": { + "name": "windowId", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registrations": { + "name": "registrations", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_registration_quotas_windowId": { + "name": "mcp_oauth_registration_quotas_windowId", + "columns": ["windowId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_tokens": { + "name": "mcp_oauth_tokens", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "familyId": { + "name": "familyId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessHash": { + "name": "accessHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refreshHash": { + "name": "refreshHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessExpiresAt": { + "name": "accessExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refreshExpiresAt": { + "name": "refreshExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "access_hash_idx": { + "name": "access_hash_idx", + "columns": ["accessHash"], + "isUnique": true + }, + "refresh_hash_idx": { + "name": "refresh_hash_idx", + "columns": ["refreshHash"], + "isUnique": true + }, + "family_id_idx": { + "name": "family_id_idx", + "columns": ["familyId"], + "isUnique": false + }, + "client_active_idx": { + "name": "client_active_idx", + "columns": ["clientId", "revokedAt", "refreshExpiresAt"], + "isUnique": false + }, + "refresh_expires_at_idx": { + "name": "refresh_expires_at_idx", + "columns": ["refreshExpiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_tokens_id": { + "name": "mcp_oauth_tokens_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "media_processing_budgets": { + "name": "media_processing_budgets", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reserved_bytes": { + "name": "reserved_bytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "limit_bytes": { + "name": "limit_bytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_budget_expiry_idx": { + "name": "media_budget_expiry_idx", + "columns": ["expires_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "media_processing_budgets_id": { + "name": "media_processing_budgets_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_conversations": { + "name": "messenger_conversations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anonymousId": { + "name": "anonymousId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "takeoverByUserId": { + "name": "takeoverByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "takeoverAt": { + "name": "takeoverAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastMessageAt": { + "name": "lastMessageAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_last_message_idx": { + "name": "user_last_message_idx", + "columns": ["userId", "lastMessageAt"], + "isUnique": false + }, + "anonymous_last_message_idx": { + "name": "anonymous_last_message_idx", + "columns": ["anonymousId", "lastMessageAt"], + "isUnique": false + }, + "mode_last_message_idx": { + "name": "mode_last_message_idx", + "columns": ["mode", "lastMessageAt"], + "isUnique": false + }, + "updated_at_idx": { + "name": "updated_at_idx", + "columns": ["updatedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "messenger_conversations_id": { + "name": "messenger_conversations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_messages": { + "name": "messenger_messages", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversationId": { + "name": "conversationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anonymousId": { + "name": "anonymousId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "conversation_created_at_idx": { + "name": "conversation_created_at_idx", + "columns": ["conversationId", "createdAt"], + "isUnique": false + }, + "role_created_at_idx": { + "name": "role_created_at_idx", + "columns": ["role", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "messenger_messages_conversationId_messenger_conversations_id_fk": { + "name": "messenger_messages_conversationId_messenger_conversations_id_fk", + "tableFrom": "messenger_messages", + "tableTo": "messenger_conversations", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messenger_messages_id": { + "name": "messenger_messages_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_support_emails": { + "name": "messenger_support_emails", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversationId": { + "name": "conversationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userEmail": { + "name": "userEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "support_email_user_created_at_idx": { + "name": "support_email_user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + }, + "support_email_conversation_created_at_idx": { + "name": "support_email_conversation_created_at_idx", + "columns": ["conversationId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "support_email_conversation_fk": { + "name": "support_email_conversation_fk", + "tableFrom": "messenger_support_emails", + "tableTo": "messenger_conversations", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messenger_support_emails_id": { + "name": "messenger_support_emails_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mobile_push_claims": { + "name": "mobile_push_claims", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "created_at_idx": { + "name": "created_at_idx", + "columns": ["createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mobile_push_claims_id": { + "name": "mobile_push_claims_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mobile_push_tokens": { + "name": "mobile_push_tokens", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionHash": { + "name": "sessionHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "token_idx": { + "name": "token_idx", + "columns": ["token"], + "isUnique": true + }, + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + }, + "session_hash_idx": { + "name": "session_hash_idx", + "columns": ["sessionHash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mobile_push_tokens_id": { + "name": "mobile_push_tokens_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipientId": { + "name": "recipientId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dedupKey": { + "name": "dedupKey", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "readAt": { + "name": "readAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "org_id_idx": { + "name": "org_id_idx", + "columns": ["orgId"], + "isUnique": false + }, + "type_idx": { + "name": "type_idx", + "columns": ["type"], + "isUnique": false + }, + "read_at_idx": { + "name": "read_at_idx", + "columns": ["readAt"], + "isUnique": false + }, + "created_at_idx": { + "name": "created_at_idx", + "columns": ["createdAt"], + "isUnique": false + }, + "recipient_read_idx": { + "name": "recipient_read_idx", + "columns": ["recipientId", "readAt"], + "isUnique": false + }, + "recipient_created_idx": { + "name": "recipient_created_idx", + "columns": ["recipientId", "createdAt"], + "isUnique": false + }, + "dedup_key_idx": { + "name": "dedup_key_idx", + "columns": ["dedupKey"], + "isUnique": true + }, + "type_recipient_created_idx": { + "name": "type_recipient_created_idx", + "columns": ["type", "recipientId", "createdAt"], + "isUnique": false + }, + "type_recipient_video_created_idx": { + "name": "type_recipient_video_created_idx", + "columns": ["type", "recipientId", "videoId", "createdAt"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "notifications_id": { + "name": "notifications_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_directory_sync": { + "name": "organization_directory_sync", + "columns": { + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestedAt": { + "name": "requestedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "syncedAt": { + "name": "syncedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "leaseExpiresAt": { + "name": "leaseExpiresAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastError": { + "name": "lastError", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "heldRemovals": { + "name": "heldRemovals", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "removalsApprovedAt": { + "name": "removalsApprovedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_directory_sync_organizationId": { + "name": "organization_directory_sync_organizationId", + "columns": ["organizationId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_invites": { + "name": "organization_invites", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedEmail": { + "name": "invitedEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedByUserId": { + "name": "invitedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "invited_email_idx": { + "name": "invited_email_idx", + "columns": ["invitedEmail"], + "isUnique": false + }, + "invited_by_user_id_idx": { + "name": "invited_by_user_id_idx", + "columns": ["invitedByUserId"], + "isUnique": false + }, + "status_idx": { + "name": "status_idx", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_invites_id": { + "name": "organization_invites_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hasProSeat": { + "name": "hasProSeat", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "user_id_organization_id_idx": { + "name": "user_id_organization_id_idx", + "columns": ["userId", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_members_id": { + "name": "organization_members_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_sso": { + "name": "organization_sso", + "columns": { + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purchasedByUserId": { + "name": "purchasedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripePriceId": { + "name": "stripePriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "paidThrough": { + "name": "paidThrough", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currentPeriodEnd": { + "name": "currentPeriodEnd", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "checkoutAttemptId": { + "name": "checkoutAttemptId", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutCurrency": { + "name": "checkoutCurrency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutPriceId": { + "name": "checkoutPriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutSessionId": { + "name": "checkoutSessionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutStartedAt": { + "name": "checkoutStartedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "sso_stripe_subscription_id_idx": { + "name": "sso_stripe_subscription_id_idx", + "columns": ["stripeSubscriptionId"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_sso_organizationId": { + "name": "organization_sso_organizationId", + "columns": ["organizationId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tombstoneAt": { + "name": "tombstoneAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowedEmailDomain": { + "name": "allowedEmailDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "defaultVideoVisibility": { + "name": "defaultVideoVisibility", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customDomain": { + "name": "customDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domainVerified": { + "name": "domainVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shareableLinkIconUrl": { + "name": "shareableLinkIconUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "workosOrganizationId": { + "name": "workosOrganizationId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workosConnectionId": { + "name": "workosConnectionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "owner_id_tombstone_idx": { + "name": "owner_id_tombstone_idx", + "columns": ["ownerId", "tombstoneAt"], + "isUnique": false + }, + "custom_domain_idx": { + "name": "custom_domain_idx", + "columns": ["customDomain"], + "isUnique": false + }, + "workos_organization_id_idx": { + "name": "workos_organization_id_idx", + "columns": ["workosOrganizationId"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organizations_id": { + "name": "organizations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "s3_buckets": { + "name": "s3_buckets", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucketName": { + "name": "bucketName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessKeyId": { + "name": "accessKeyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secretAccessKey": { + "name": "secretAccessKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('aws')" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_organization_idx": { + "name": "owner_organization_idx", + "columns": ["ownerId", "organizationId"], + "isUnique": false + }, + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "organization_active_idx": { + "name": "organization_active_idx", + "columns": ["organizationId", "active", "updatedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "s3_buckets_id": { + "name": "s3_buckets_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionToken": { + "name": "sessionToken", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "session_token_idx": { + "name": "session_token_idx", + "columns": ["sessionToken"], + "isUnique": true + }, + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_videos": { + "name": "shared_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sharedByUserId": { + "name": "sharedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sharedAt": { + "name": "sharedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "shared_by_user_id_idx": { + "name": "shared_by_user_id_idx", + "columns": ["sharedByUserId"], + "isUnique": false + }, + "video_id_organization_id_idx": { + "name": "video_id_organization_id_idx", + "columns": ["videoId", "organizationId"], + "isUnique": false + }, + "video_id_folder_id_idx": { + "name": "video_id_folder_id_idx", + "columns": ["videoId", "folderId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "shared_videos_id": { + "name": "shared_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "signed_baas": { + "name": "signed_baas", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entityName": { + "name": "entityName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entityType": { + "name": "entityType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entityAddress": { + "name": "entityAddress", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signerName": { + "name": "signerName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signerTitle": { + "name": "signerTitle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "noticesEmail": { + "name": "noticesEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signatureData": { + "name": "signatureData", + "type": "longtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signedAt": { + "name": "signedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailSentAt": { + "name": "emailSentAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "signed_baa_organization_id_idx": { + "name": "signed_baa_organization_id_idx", + "columns": ["organizationId"], + "isUnique": true + }, + "signed_baa_stripe_subscription_idx": { + "name": "signed_baa_stripe_subscription_idx", + "columns": ["stripeSubscriptionId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "signed_baas_id": { + "name": "signed_baas_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "space_members_id": { + "name": "space_members_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "space_id_user_id_unique": { + "name": "space_id_user_id_unique", + "columns": ["spaceId", "userId"] + } + }, + "checkConstraint": {} + }, + "space_videos": { + "name": "space_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedById": { + "name": "addedById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + }, + "added_by_id_idx": { + "name": "added_by_id_idx", + "columns": ["addedById"], + "isUnique": false + }, + "space_id_video_id_idx": { + "name": "space_id_video_id_idx", + "columns": ["spaceId", "videoId"], + "isUnique": false + }, + "space_id_folder_id_idx": { + "name": "space_id_folder_id_idx", + "columns": ["spaceId", "folderId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "space_videos_id": { + "name": "space_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "spaces": { + "name": "spaces", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "primary": { + "name": "primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "privacy": { + "name": "privacy", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Private'" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "created_by_id_idx": { + "name": "created_by_id_idx", + "columns": ["createdById"], + "isUnique": false + }, + "public_organization_id_idx": { + "name": "public_organization_id_idx", + "columns": ["public", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "spaces_id": { + "name": "spaces_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "storage_integrations": { + "name": "storage_integrations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "encryptedConfig": { + "name": "encryptedConfig", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "googleDriveAccessToken": { + "name": "googleDriveAccessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveAccessTokenExpiresAt": { + "name": "googleDriveAccessTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveTokenRefreshLeaseId": { + "name": "googleDriveTokenRefreshLeaseId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveTokenRefreshLeaseExpiresAt": { + "name": "googleDriveTokenRefreshLeaseExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveStorageQuotaCache": { + "name": "googleDriveStorageQuotaCache", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_provider_idx": { + "name": "owner_provider_idx", + "columns": ["ownerId", "provider"], + "isUnique": false + }, + "owner_active_idx": { + "name": "owner_active_idx", + "columns": ["ownerId", "active"], + "isUnique": false + }, + "organization_provider_idx": { + "name": "organization_provider_idx", + "columns": ["organizationId", "provider"], + "isUnique": false + }, + "organization_active_idx": { + "name": "organization_active_idx", + "columns": ["organizationId", "active", "status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "storage_integrations_id": { + "name": "storage_integrations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "storage_objects": { + "name": "storage_objects", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integrationId": { + "name": "integrationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "objectKey": { + "name": "objectKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "objectKeyHash": { + "name": "objectKeyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerObjectId": { + "name": "providerObjectId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadSessionUrl": { + "name": "uploadSessionUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadStatus": { + "name": "uploadStatus", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "contentType": { + "name": "contentType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contentLength": { + "name": "contentLength", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "integration_key_hash_idx": { + "name": "integration_key_hash_idx", + "columns": ["integrationId", "objectKeyHash"], + "isUnique": true + }, + "integration_status_idx": { + "name": "integration_status_idx", + "columns": ["integrationId", "uploadStatus"], + "isUnique": false + }, + "integration_object_key_prefix_idx": { + "name": "integration_object_key_prefix_idx", + "columns": ["integrationId", "`objectKey`(191)"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + }, + "owner_id_idx": { + "name": "owner_id_idx", + "columns": ["ownerId"], + "isUnique": false + } + }, + "foreignKeys": { + "storage_objects_integrationId_storage_integrations_id_fk": { + "name": "storage_objects_integrationId_storage_integrations_id_fk", + "tableFrom": "storage_objects", + "tableTo": "storage_integrations", + "columnsFrom": ["integrationId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "storage_objects_id": { + "name": "storage_objects_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastName": { + "name": "lastName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thirdPartyStripeSubscriptionId": { + "name": "thirdPartyStripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionStatus": { + "name": "stripeSubscriptionStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionPriceId": { + "name": "stripeSubscriptionPriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferences": { + "name": "preferences", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('null')" + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "onboardingSteps": { + "name": "onboardingSteps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customBucket": { + "name": "customBucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inviteQuota": { + "name": "inviteQuota", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "defaultOrgId": { + "name": "defaultOrgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authSessionVersion": { + "name": "authSessionVersion", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "marketingOrigin": { + "name": "marketingOrigin", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verification_tokens": { + "name": "verification_tokens", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier": { + "name": "verification_tokens_identifier", + "columns": ["identifier"] + } + }, + "uniqueConstraints": { + "verification_tokens_token_unique": { + "name": "verification_tokens_token_unique", + "columns": ["token"] + } + }, + "checkConstraint": {} + }, + "video_edits": { + "name": "video_edits", + "columns": { + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceKey": { + "name": "sourceKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "editSpec": { + "name": "editSpec", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "video_edits_videoId_videos_id_fk": { + "name": "video_edits_videoId_videos_id_fk", + "tableFrom": "video_edits", + "tableTo": "videos", + "columnsFrom": ["videoId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "video_edits_videoId": { + "name": "video_edits_videoId", + "columns": ["videoId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_processing_jobs": { + "name": "video_processing_jobs", + "columns": { + "video_id": { + "name": "video_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest_sha256": { + "name": "manifest_sha256", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'committing'" + }, + "attempt_id": { + "name": "attempt_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_job_id": { + "name": "remote_job_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verification": { + "name": "verification", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "processing_state_retry_video_idx": { + "name": "processing_state_retry_video_idx", + "columns": ["state", "next_retry_at", "video_id"], + "isUnique": false + }, + "processing_state_lease_video_idx": { + "name": "processing_state_lease_video_idx", + "columns": ["state", "lease_expires_at", "video_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_processing_jobs_video_id": { + "name": "video_processing_jobs_video_id", + "columns": ["video_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_uploads": { + "name": "video_uploads", + "columns": { + "video_id": { + "name": "video_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded": { + "name": "uploaded", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total": { + "name": "total", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "mode": { + "name": "mode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'uploading'" + }, + "processing_progress": { + "name": "processing_progress", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processing_message": { + "name": "processing_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_file_key": { + "name": "raw_file_key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "phase_updated_at_video_id_idx": { + "name": "phase_updated_at_video_id_idx", + "columns": ["phase", "updated_at", "video_id"], + "isUnique": false + }, + "started_at_idx": { + "name": "started_at_idx", + "columns": ["started_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_uploads_video_id": { + "name": "video_uploads_video_id", + "columns": ["video_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_viewer_grants": { + "name": "video_viewer_grants", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedByUserId": { + "name": "invitedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "video_email_idx": { + "name": "video_email_idx", + "columns": ["videoId", "email"], + "isUnique": true + } + }, + "foreignKeys": { + "video_viewer_grants_videoId_videos_id_fk": { + "name": "video_viewer_grants_videoId_videos_id_fk", + "tableFrom": "video_viewer_grants", + "tableTo": "videos", + "columnsFrom": ["videoId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "video_viewer_grants_id": { + "name": "video_viewer_grants_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "videos": { + "name": "videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'My Video'" + }, + "bucket": { + "name": "bucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storageIntegrationId": { + "name": "storageIntegrationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fps": { + "name": "fps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transcriptionStatus": { + "name": "transcriptionStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"type\":\"MediaConvert\"}')" + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "effectiveCreatedAt": { + "name": "effectiveCreatedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "generated": { + "as": "COALESCE(\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%s.%fZ'),\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%sZ'),\n `createdAt`\n )", + "type": "stored" + } + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "xStreamInfo": { + "name": "xStreamInfo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "firstViewEmailSentAt": { + "name": "firstViewEmailSentAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isScreenshot": { + "name": "isScreenshot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "awsRegion": { + "name": "awsRegion", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "awsBucket": { + "name": "awsBucket", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoStartTime": { + "name": "videoStartTime", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audioStartTime": { + "name": "audioStartTime", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jobId": { + "name": "jobId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jobStatus": { + "name": "jobStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skipProcessing": { + "name": "skipProcessing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "owner_id_idx": { + "name": "owner_id_idx", + "columns": ["ownerId"], + "isUnique": false + }, + "owner_updated_id_idx": { + "name": "owner_updated_id_idx", + "columns": ["ownerId", "updatedAt", "id"], + "isUnique": false + }, + "is_public_idx": { + "name": "is_public_idx", + "columns": ["public"], + "isUnique": false + }, + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "storage_integration_id_idx": { + "name": "storage_integration_id_idx", + "columns": ["storageIntegrationId"], + "isUnique": false + }, + "org_owner_folder_idx": { + "name": "org_owner_folder_idx", + "columns": ["orgId", "ownerId", "folderId"], + "isUnique": false + }, + "org_owner_folder_effective_idx": { + "name": "org_owner_folder_effective_idx", + "columns": ["orgId", "ownerId", "folderId", "effectiveCreatedAt"], + "isUnique": false + }, + "org_effective_created_idx": { + "name": "org_effective_created_idx", + "columns": ["orgId", "effectiveCreatedAt"], + "isUnique": false + }, + "screenshot_transcription_created_idx": { + "name": "screenshot_transcription_created_idx", + "columns": ["isScreenshot", "transcriptionStatus", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "videos_storageIntegrationId_storage_integrations_id_fk": { + "name": "videos_storageIntegrationId_storage_integrations_id_fk", + "tableFrom": "videos", + "tableTo": "storage_integrations", + "columnsFrom": ["storageIntegrationId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "videos_id": { + "name": "videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": { + "integration_object_key_prefix_idx": { + "columns": { + "`objectKey`(191)": { + "isExpression": true + } + } + } + } + } +} diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 9f14cd20c63..f9aea0c37ca 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -393,6 +393,13 @@ "when": 1794461929204, "tag": "0055_developer_transcripts", "breakpoints": true + }, + { + "idx": 56, + "version": "5", + "when": 1794526625514, + "tag": "0056_developer_transcript_video", + "breakpoints": true } ] } diff --git a/packages/database/schema.ts b/packages/database/schema.ts index a454936392f..849461a2aeb 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -1926,7 +1926,7 @@ export type DeveloperTranscriptStatus = | "complete" | "error"; -export type DeveloperTranscriptSourceType = "upload" | "url"; +export type DeveloperTranscriptSourceType = "upload" | "url" | "video"; export const developerTranscripts = mysqlTable( "developer_transcripts", @@ -1945,11 +1945,12 @@ export const developerTranscripts = mysqlTable( .$type(), sourceType: varchar("sourceType", { length: 8, - enum: ["upload", "url"], + enum: ["upload", "url", "video"], }) .notNull() .$type(), sourceUrl: varchar("sourceUrl", { length: 2048 }), + videoId: nanoIdNullable("videoId"), s3Key: varchar("s3Key", { length: 512 }), resultKey: varchar("resultKey", { length: 512 }), sourceBytes: bigint("sourceBytes", { mode: "number", unsigned: true }), @@ -2002,6 +2003,12 @@ export const developerTranscripts = mysqlTable( table.status, table.updatedAt, ), + index("dev_transcripts_app_video_idx").on( + table.appId, + table.videoId, + table.createdAt, + table.id, + ), ], ); From 78faf978b510ff1ce9f597b033609561d47bd1bb Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 19:06:53 -0500 Subject: [PATCH 02/16] feat(infra): build WebVTT captions from word timings --- .../fixtures/developer-captions-golden.vtt | 41 ++++ .../unit/developer-transcript-vtt.test.ts | 220 ++++++++++++++++++ apps/web/lib/developer-transcript-vtt.ts | 185 +++++++++++++++ 3 files changed, 446 insertions(+) create mode 100644 apps/web/__tests__/fixtures/developer-captions-golden.vtt create mode 100644 apps/web/__tests__/unit/developer-transcript-vtt.test.ts create mode 100644 apps/web/lib/developer-transcript-vtt.ts diff --git a/apps/web/__tests__/fixtures/developer-captions-golden.vtt b/apps/web/__tests__/fixtures/developer-captions-golden.vtt new file mode 100644 index 00000000000..a857ee0bb27 --- /dev/null +++ b/apps/web/__tests__/fixtures/developer-captions-golden.vtt @@ -0,0 +1,41 @@ +WEBVTT + +00:00:00.240 --> 00:00:01.240 +Welcome back. + +00:00:01.600 --> 00:00:06.320 +Today we are walking through the +quarterly numbers for the whole company, + +00:00:06.400 --> 00:00:13.120 +region by region, and then we will look at +what changed in the product since the last + +00:00:13.200 --> 00:00:15.920 +time we all met in this room. + +00:00:18.400 --> 00:00:21.928 +We shipped 1,250 units in +Q3 2026 for $3.5 million. + +00:00:23.200 --> 00:00:26.400 +And yes I know that is a lot to take +in all at once so bear with me please. + +00:00:26.400 --> 00:00:27.520 +Let me think + +00:00:31.200 --> 00:00:33.120 +about that for a second. + +00:00:34.400 --> 00:00:37.520 +Is 5 < 7 && 9 > 2? + +00:00:37.600 --> 00:00:40.320 +Tom & Jerry said <b>yes</b> --> maybe. + +00:00:41.600 --> 00:00:42.600 +Okay. + +00:00:43.200 --> 00:00:44.800 +Line break inside end. diff --git a/apps/web/__tests__/unit/developer-transcript-vtt.test.ts b/apps/web/__tests__/unit/developer-transcript-vtt.test.ts new file mode 100644 index 00000000000..8b4dd055e81 --- /dev/null +++ b/apps/web/__tests__/unit/developer-transcript-vtt.test.ts @@ -0,0 +1,220 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + buildCaptionCues, + buildWebVtt, + CAPTION_LINE_CHARACTERS, + CAPTION_MAX_CUE_MS, + CAPTION_READING_MS_PER_CHARACTER, + type CaptionWord, + escapeVttText, + formatVttTimestamp, +} from "@/lib/developer-transcript-vtt"; + +const GOLDEN = join(__dirname, "../fixtures/developer-captions-golden.vtt"); + +const say = ( + startSeconds: number, + secondsPerWord: number, + text: string, +): CaptionWord[] => + text.split(" ").map((word, index) => { + const start = startSeconds + index * secondsPerWord; + return { + text: word, + start: Math.round(start * 1000) / 1000, + end: Math.round((start + secondsPerWord * 0.8) * 1000) / 1000, + }; + }); + +const FIXED_WORDS: CaptionWord[] = [ + ...say(0.24, 0.32, "Welcome back."), + ...say( + 1.6, + 0.4, + "Today we are walking through the quarterly numbers for the whole company, region by region, and then we will look at what changed in the product since the last time we all met in this room.", + ), + ...say(18.4, 0.36, "We shipped 1,250 units in Q3 2026 for $3.5 million."), + ...say( + 23.2, + 0.12, + "And yes I know that is a lot to take in all at once so bear with me please.", + ), + ...say(26.4, 0.4, "Let me think"), + ...say(31.2, 0.4, "about that for a second."), + ...say( + 34.4, + 0.4, + "Is 5 < 7 && 9 > 2? Tom & Jerry said yes --> maybe.", + ), + ...say(41.6, 0.4, "Okay."), + { text: "Line\nbreak inside", start: 43.2, end: 44 }, + { text: "", start: 44.2, end: 44.4 }, + { text: "end.", start: 44.4, end: 44.8 }, +]; + +type ParsedCue = { start: number; end: number; lines: string[] }; + +const parseTimestamp = (value: string) => { + const match = /^(\d{2,}):(\d{2}):(\d{2})\.(\d{3})$/.exec(value); + if (!match) throw new Error(`Bad timestamp ${value}`); + const [, hours, minutes, seconds, millis] = match; + return ( + Number(hours) * 3_600_000 + + Number(minutes) * 60_000 + + Number(seconds) * 1000 + + Number(millis) + ); +}; + +function parseVtt(text: string): ParsedCue[] { + const [header, ...blocks] = text.trimEnd().split("\n\n"); + if (header !== "WEBVTT") throw new Error("Missing WEBVTT header"); + return blocks.map((block) => { + const [timing, ...lines] = block.split("\n"); + const [start, arrow, end, ...rest] = (timing ?? "").split(" "); + if (arrow !== "-->" || rest.length > 0 || !start || !end) { + throw new Error(`Bad timing line ${timing}`); + } + if (lines.length === 0 || lines.some((line) => line.trim() === "")) { + throw new Error("Cue has an empty line"); + } + return { start: parseTimestamp(start), end: parseTimestamp(end), lines }; + }); +} + +describe("WebVTT captions from word timings", () => { + it("matches the golden file for fixed word timings", () => { + expect(buildWebVtt(FIXED_WORDS, 46)).toBe(readFileSync(GOLDEN, "utf8")); + }); + + it("parses, and its cues never overlap or go backwards", () => { + const cues = parseVtt(buildWebVtt(FIXED_WORDS, 46) ?? ""); + expect(cues.length).toBeGreaterThan(8); + let previousEnd = 0; + for (const cue of cues) { + expect(cue.start).toBeGreaterThanOrEqual(previousEnd); + expect(cue.end).toBeGreaterThan(cue.start); + previousEnd = cue.end; + } + }); + + it("keeps every cue to two lines of about 42 characters and 7 seconds", () => { + const cues = buildCaptionCues(FIXED_WORDS, 46); + for (const cue of cues) { + expect(cue.lines.length).toBeLessThanOrEqual(2); + for (const line of cue.lines) { + expect(line.length).toBeLessThanOrEqual(CAPTION_LINE_CHARACTERS); + } + expect(cue.end - cue.start).toBeLessThanOrEqual(CAPTION_MAX_CUE_MS); + } + }); + + it("keeps every word, in order", () => { + const cues = buildCaptionCues(FIXED_WORDS, 46); + expect(cues.flatMap((cue) => cue.lines).join(" ")).toBe( + FIXED_WORDS.map((word) => word.text.replace(/\s+/g, " ").trim()) + .filter(Boolean) + .join(" "), + ); + }); + + it("ends a cue at the end of a sentence", () => { + const cues = buildCaptionCues(FIXED_WORDS, 46); + expect(cues[0]?.lines).toEqual(["Welcome back."]); + expect(cues.at(-1)?.lines.join(" ")).toBe("Line break inside end."); + }); + + it("starts a new cue after a pause, even inside a sentence", () => { + const cues = buildCaptionCues(FIXED_WORDS, 46); + const before = cues.find((cue) => cue.lines.join(" ") === "Let me think"); + const after = cues.find( + (cue) => cue.lines.join(" ") === "about that for a second.", + ); + expect(before).toBeDefined(); + expect(after?.start).toBe(31_200); + expect(before?.end).toBeLessThanOrEqual(28_520); + }); + + it("escapes the characters WebVTT reserves and never emits an arrow in text", () => { + const text = buildWebVtt(FIXED_WORDS, 46) ?? ""; + expect(text).toContain("Is 5 < 7 && 9 > 2?"); + expect(text).toContain("<b>yes</b> --> maybe."); + expect(text).toContain("Tom & Jerry"); + const cueText = parseVtt(text).flatMap((cue) => cue.lines); + expect(cueText.some((line) => line.includes("-->"))).toBe(false); + expect(cueText.some((line) => /[<>]/.test(line))).toBe(false); + expect(escapeVttText("a & b < c > d")).toBe("a & b < c > d"); + }); + + it("holds quick speech on screen long enough to read, up to the next cue", () => { + const cues = buildCaptionCues(FIXED_WORDS, 46); + const quick = cues.filter( + (cue) => cue.start >= 23_200 && cue.start < 26_400, + ); + const text = quick.flatMap((cue) => cue.lines).join(" "); + expect(text).toBe( + "And yes I know that is a lot to take in all at once so bear with me please.", + ); + expect(quick).toHaveLength(1); + const spokenEnd = 25_456; + expect(quick[0]?.end).toBeGreaterThan(spokenEnd); + expect(quick[0]?.end).toBe(26_400); + expect((quick[0]?.end ?? 0) - (quick[0]?.start ?? 0)).toBeLessThan( + text.length * CAPTION_READING_MS_PER_CHARACTER, + ); + }); + + it("does not hold normal speech past its last word", () => { + const cues = buildCaptionCues(FIXED_WORDS, 46); + const shipped = cues.find((cue) => cue.lines[0]?.startsWith("We shipped")); + expect(shipped?.end).toBe(21_928); + }); + + it("writes one cue for a single word and holds it for a second", () => { + expect(buildWebVtt([{ text: "Hello", start: 2, end: 2.24 }], 10)).toBe( + "WEBVTT\n\n00:00:02.000 --> 00:00:03.000\nHello\n", + ); + }); + + it("does not hold a single word past the end of the media", () => { + expect(buildWebVtt([{ text: "Bye", start: 9.6, end: 9.84 }], 10)).toBe( + "WEBVTT\n\n00:00:09.600 --> 00:00:10.000\nBye\n", + ); + }); + + it("writes nothing when there are no words", () => { + expect(buildWebVtt([], 30)).toBeNull(); + expect(buildWebVtt(null, 30)).toBeNull(); + expect(buildWebVtt([{ text: " ", start: 1, end: 2 }], 30)).toBeNull(); + }); + + it("never lets a cue start before the one before it ended when timings overlap or go backwards", () => { + const cues = buildCaptionCues([ + { text: "First.", start: 5, end: 9 }, + { text: "Second.", start: 4, end: 6 }, + { text: "Third.", start: 6, end: 6.5 }, + { text: "Fourth.", start: Number.NaN, end: -3 }, + ]); + let previousEnd = 0; + for (const cue of cues) { + expect(cue.start).toBeGreaterThanOrEqual(previousEnd); + expect(cue.end).toBeGreaterThan(cue.start); + previousEnd = cue.end; + } + expect(cues).toHaveLength(4); + }); + + it("caps one very long word at 7 seconds and keeps it on its own line", () => { + const long = "a".repeat(60); + const cues = buildCaptionCues([{ text: long, start: 0, end: 30 }]); + expect(cues).toEqual([{ start: 0, end: 7_000, lines: [long] }]); + }); + + it("formats hours past the first", () => { + expect(formatVttTimestamp(0)).toBe("00:00:00.000"); + expect(formatVttTimestamp(3_723_045)).toBe("01:02:03.045"); + expect(formatVttTimestamp(14_399_999)).toBe("03:59:59.999"); + }); +}); diff --git a/apps/web/lib/developer-transcript-vtt.ts b/apps/web/lib/developer-transcript-vtt.ts new file mode 100644 index 00000000000..d7ebd47cc0c --- /dev/null +++ b/apps/web/lib/developer-transcript-vtt.ts @@ -0,0 +1,185 @@ +export const CAPTION_LINE_CHARACTERS = 42; +export const CAPTION_MAX_LINES = 2; +export const CAPTION_MAX_CUE_MS = 7_000; +export const CAPTION_PAUSE_MS = 1_000; +export const CAPTION_MIN_CUE_MS = 1_000; +export const CAPTION_READING_MS_PER_CHARACTER = 50; + +export type CaptionWord = { text: string; start: number; end: number }; + +export type CaptionCue = { start: number; end: number; lines: string[] }; + +type TimedWord = { text: string; start: number; end: number }; + +const SENTENCE_END = /[.!?。!?…]["'”’)\]]*$/; +const CLAUSE_END = /[,;:,、;:]["'”’)\]]*$/; + +const isControl = (char: string) => { + const code = char.codePointAt(0) ?? 0; + return code < 32 || code === 127; +}; + +const cleanText = (text: string) => + Array.from(text, (char) => (isControl(char) ? " " : char)) + .join("") + .replace(/\s+/g, " ") + .trim(); + +const milliseconds = (seconds: number) => + Number.isFinite(seconds) ? Math.max(0, Math.round(seconds * 1000)) : 0; + +function timedWords(words: readonly CaptionWord[]): TimedWord[] { + const timed: TimedWord[] = []; + let floor = 0; + for (const word of words) { + const text = typeof word?.text === "string" ? cleanText(word.text) : ""; + if (text.length === 0) continue; + const start = Math.max(floor, milliseconds(word.start)); + const end = Math.max(start, milliseconds(word.end)); + floor = start; + for (const part of text.split(" ")) { + timed.push({ text: part, start, end }); + } + } + return timed; +} + +const joined = (words: readonly TimedWord[]) => + words.map((word) => word.text).join(" "); + +function layoutLines(words: readonly TimedWord[]): string[] | null { + const text = joined(words); + if (words.length <= 1 || text.length <= CAPTION_LINE_CHARACTERS) { + return [text]; + } + let best: { lines: string[]; score: number } | null = null; + for (let split = 1; split < words.length; split++) { + const first = joined(words.slice(0, split)); + const second = joined(words.slice(split)); + const fits = (line: string, count: number) => + line.length <= CAPTION_LINE_CHARACTERS || count === 1; + if (!fits(first, split) || !fits(second, words.length - split)) continue; + const lastOfFirst = words[split - 1]?.text ?? ""; + const score = + Math.abs(first.length - second.length) - + (SENTENCE_END.test(lastOfFirst) || CLAUSE_END.test(lastOfFirst) ? 6 : 0); + if (!best || score < best.score) { + best = { lines: [first, second], score }; + } + } + return best?.lines ?? null; +} + +const fitsOneCue = (words: readonly TimedWord[]) => { + const first = words[0]; + const last = words[words.length - 1]; + if (!first || !last) return true; + if (words.length === 1) return true; + return ( + last.end - first.start <= CAPTION_MAX_CUE_MS && layoutLines(words) !== null + ); +}; + +function clauseBreak(words: readonly TimedWord[]) { + const total = joined(words).length; + for (let index = words.length - 2; index >= 0; index--) { + const kept = joined(words.slice(0, index + 1)).length; + if (kept * 2 < total) return null; + if (CLAUSE_END.test(words[index]?.text ?? "")) return index + 1; + } + return null; +} + +function groupWords(words: readonly TimedWord[]): TimedWord[][] { + const groups: TimedWord[][] = []; + let current: TimedWord[] = []; + const flush = () => { + if (current.length > 0) groups.push(current); + current = []; + }; + for (const word of words) { + const previous = current[current.length - 1]; + if (previous && word.start - previous.end > CAPTION_PAUSE_MS) flush(); + if (current.length > 0 && !fitsOneCue([...current, word])) { + const at = clauseBreak(current); + if (at !== null && fitsOneCue([...current.slice(at), word])) { + groups.push(current.slice(0, at)); + current = current.slice(at); + } else { + flush(); + } + } + current.push(word); + if (SENTENCE_END.test(word.text)) flush(); + } + flush(); + return groups; +} + +export function buildCaptionCues( + words: readonly CaptionWord[], + durationSeconds?: number | null, +): CaptionCue[] { + const groups = groupWords(timedWords(words)); + const mediaEnd = + typeof durationSeconds === "number" && durationSeconds > 0 + ? milliseconds(durationSeconds) + : null; + const cues: CaptionCue[] = []; + let previousEnd = 0; + groups.forEach((group, index) => { + const first = group[0]; + const last = group[group.length - 1]; + if (!first || !last) return; + const start = Math.max(first.start, previousEnd); + const spoken = Math.max(last.end, start); + const nextStart = groups[index + 1]?.[0]?.start; + const wanted = Math.max( + spoken, + start + CAPTION_MIN_CUE_MS, + start + joined(group).length * CAPTION_READING_MS_PER_CHARACTER, + ); + const room = + nextStart !== undefined + ? Math.max(spoken, nextStart) + : mediaEnd !== null + ? Math.max(spoken, mediaEnd) + : wanted; + const end = Math.max( + start + 1, + Math.min(wanted, room, start + CAPTION_MAX_CUE_MS), + ); + cues.push({ start, end, lines: layoutLines(group) ?? [joined(group)] }); + previousEnd = end; + }); + return cues; +} + +const pad = (value: number, width: number) => + String(value).padStart(width, "0"); + +export function formatVttTimestamp(totalMilliseconds: number) { + const whole = Math.max(0, Math.round(totalMilliseconds)); + const hours = Math.floor(whole / 3_600_000); + const minutes = Math.floor((whole % 3_600_000) / 60_000); + const seconds = Math.floor((whole % 60_000) / 1000); + return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(whole % 1000, 3)}`; +} + +export const escapeVttText = (text: string) => + text.replace(/&/g, "&").replace(//g, ">"); + +export function buildWebVtt( + words: readonly CaptionWord[] | null | undefined, + durationSeconds?: number | null, +): string | null { + const cues = buildCaptionCues(words ?? [], durationSeconds); + if (cues.length === 0) return null; + const blocks = cues.map( + (cue) => + `${formatVttTimestamp(cue.start)} --> ${formatVttTimestamp(cue.end)}\n${cue.lines + .map(escapeVttText) + .join("\n")}`, + ); + return `WEBVTT\n\n${blocks.join("\n\n")}\n`; +} From 8c7f373d9198469d028a29938dcbb6339941a755 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 19:06:53 -0500 Subject: [PATCH 03/16] docs(infra): document transcribing a stored video by id --- .../unit/developer-transcript-docs.test.ts | 55 ++++++++++++++++ apps/web/content/docs/api/rest-api.mdx | 62 ++++++++++++++----- apps/web/lib/developer-openapi.ts | 29 +++++++-- 3 files changed, 124 insertions(+), 22 deletions(-) diff --git a/apps/web/__tests__/unit/developer-transcript-docs.test.ts b/apps/web/__tests__/unit/developer-transcript-docs.test.ts index 56a92b29034..d6433f5de63 100644 --- a/apps/web/__tests__/unit/developer-transcript-docs.test.ts +++ b/apps/web/__tests__/unit/developer-transcript-docs.test.ts @@ -133,6 +133,61 @@ describe("the language parameter", () => { }); }); +describe("transcribing a stored video", () => { + const request = spec.components.schemas.CreateTranscriptRequest as { + oneOf?: { required: string[] }[]; + required?: string[]; + properties: Record; + }; + const transcript = spec.components.schemas.Transcript as { + properties: { + videoId?: { type: string[] }; + source: { properties: { type: { enum: string[] } } }; + }; + }; + + it("accepts videoId or url on Create Transcript in the OpenAPI document", () => { + expect(Object.keys(request.properties)).toEqual( + expect.arrayContaining(["url", "videoId"]), + ); + expect(request.required).toBeUndefined(); + expect(request.oneOf).toEqual([ + { required: ["url"] }, + { required: ["videoId"] }, + ]); + }); + + it("describes the videoId field and the video source type on the transcript", () => { + expect(transcript.properties.videoId?.type).toEqual(["string", "null"]); + expect(transcript.properties.source.properties.type.enum).toEqual([ + "upload", + "url", + "video", + ]); + }); + + it("documents videoId, what it costs and that captions come from it", () => { + expect(transcriptDocs).toContain( + "| `videoId` | string | One of `url` or `videoId` |", + ); + expect(transcriptDocs).toContain( + '"source": { "type": "video", "url": null }', + ); + expect(transcriptDocs).toContain('"videoId": "abc123def456"'); + expect(transcriptDocs).toContain( + "It costs the transcript price only, $0.10 per audio hour.", + ); + expect(transcriptDocs).toContain( + "Captions for the hosted player are generated from the newest completed transcript of a video in each language.", + ); + expect(transcriptDocs).toContain("`videoId`, `contentLength`, `name`"); + }); + + it("leaves the captions file and the player to the playback docs", () => { + expect(transcriptDocs).not.toMatch(/\.vtt|WebVTT|captions\//i); + }); +}); + describe("transcript docs keep up with the API", () => { it("states the characters an Idempotency-Key may contain", () => { expect(transcriptDocs).toContain( diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index fc0b3d67472..29e9a90e092 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -611,6 +611,7 @@ A transcript request can be refused. The response carries a `code` and a `messag |--------|------|---------| | `400` | `invalid_url` | The URL is missing, longer than 2,048 characters, not http or https, on a port other than the default, or carries a username or password | | `400` | `url_not_public` | The URL points to a private or local address | +| `400` | `invalid_video_id` | `videoId` is empty or longer than 64 characters | | `400` | `invalid_name` | The name is longer than 255 characters | | `400` | `invalid_language` | The language is not `auto` or one of the supported codes. The hint lists them | | `400` | `invalid_user_id` | The user ID is longer than 255 characters | @@ -625,6 +626,8 @@ A transcript request can be refused. The response carries a `code` and a `messag | `402` | `insufficient_credits` | The available balance is under $0.01. The message says how much to add | | `403` | `infra_not_available` | Cap Infra is not enabled for this account | | `404` | `transcript_not_found` | No transcript with this ID belongs to the app | +| `404` | `video_not_found` | No video with this ID belongs to the app, or it was deleted | +| `409` | `video_not_ready` | The video is not ready yet, or it failed to process and has no file to transcribe. The message says which | | `409` | `idempotency_conflict` | The `Idempotency-Key` was already used with a different request, or for a transcript that was deleted. The message names the field that differs | | `409` | `upload_not_found` | The file has not been uploaded yet. If the upload URL expired, repeat the create request with the same `Idempotency-Key` to get a new one | | `503` | `speech_unavailable` | Transcription is temporarily unavailable. Retry later | @@ -637,13 +640,14 @@ POST /api/developer/v1/transcripts | Field | Type | Required | Description | |-------|------|----------|-------------| -| `url` | string | Yes | Public `http` or `https` URL of an audio or video file | -| `name` | string | No | Display name (defaults to the file name) | +| `url` | string | One of `url` or `videoId` | Public `http` or `https` URL of an audio or video file | +| `videoId` | string | One of `url` or `videoId` | ID of a ready video of this app. See [Transcribe a video you already uploaded](/docs/api/rest-api#transcribe-a-video-you-already-uploaded) | +| `name` | string | No | Display name (defaults to the file name, or to the video's name) | | `language` | string | No | The language of the audio if you know it, or `auto` (default). See [Languages](/docs/api/rest-api#languages) | | `userId` | string | No | Your own user identifier, used to filter lists | | `metadata` | object | No | Your own JSON object, under 8KB | -Any other field returns `400` with the code `invalid_request` and names the field. Field names are case-sensitive. +Send exactly one of `url` or `videoId`. Both, or neither, returns `400` with the code `invalid_request`. Any other field returns `400` with the code `invalid_request` and names the field. Field names are case-sensitive. The URL must use the default port, must not contain credentials and must resolve to a public address. Redirects are followed up to five times and each hop is checked the same way. Files can be up to 2 GB, or 400 MB when the server does not send a `Content-Length`. Audio can be up to 4 hours long. @@ -666,6 +670,7 @@ curl -X POST https://cap.so/api/developer/v1/transcripts \ "status": "queued", "name": "standup", "source": { "type": "url", "url": "https://example.com/standup.mp3" }, + "videoId": null, "durationSeconds": null, "language": null, "wordCount": null, @@ -683,7 +688,7 @@ curl -X POST https://cap.so/api/developer/v1/transcripts \ Send an `Idempotency-Key` header so a retried request returns the same transcript instead of creating and billing a second one. The key is 8 to 128 characters from letters, digits, dot, dash and underscore. Any other character, such as a colon, slash or space, is refused with `400 invalid_idempotency_key`. -A repeat must send the same body. The same key with a different `url`, `contentLength`, `name`, `language`, `userId` or `metadata` is refused with `409 idempotency_conflict`, and the message names the field that differs. +A repeat must send the same body. The same key with a different `url`, `videoId`, `contentLength`, `name`, `language`, `userId` or `metadata` is refused with `409 idempotency_conflict`, and the message names the field that differs. #### Languages @@ -695,25 +700,47 @@ Supported codes: `en` English, `es` Spanish, `fr` French, `de` German, `pt` Port #### Transcribe a video you already uploaded -A video is not transcribed when it is uploaded, and it does not have to be uploaded again to get text. Once its `status` is `ready`, send its `mp4Url` as the `url`: +A video is not transcribed when it is uploaded, and it does not have to be uploaded again to get text. Once its `status` is `ready`, send its ID as `videoId` instead of a `url`: ```bash -MP4_URL=$(curl -s https://cap.so/api/developer/v1/videos/abc123def456 \ - -H "Authorization: Bearer csk_your_secret_key_here" | jq -r .data.mp4Url) - curl -X POST https://cap.so/api/developer/v1/transcripts \ -H "Authorization: Bearer csk_your_secret_key_here" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: transcript-abc123def456" \ - -d "{\"url\": \"$MP4_URL\", \"userId\": \"user_42\", \"metadata\": {\"videoId\": \"abc123def456\"}}" + -d '{"videoId": "abc123def456", "userId": "user_42"}' +``` + +```json +{ + "data": { + "id": "8kq2m4x7n1p3v5w", + "status": "queued", + "name": "Product walkthrough", + "source": { "type": "video", "url": null }, + "videoId": "abc123def456", + "durationSeconds": null, + "language": null, + "wordCount": null, + "costMicroCredits": 0, + "costDollars": 0, + "error": null, + "userId": "user_42", + "metadata": null, + "createdAt": "2026-11-11T10:30:00.000Z", + "updatedAt": "2026-11-11T10:30:00.000Z", + "completedAt": null + } +} ``` -- `mp4Url` is a stable link that needs no key. Cap follows its redirect to the file when the transcript runs, so the link does not expire between your request and the download. -- It costs the same as any other transcript, $0.10 per audio hour. Reading the stored video is not charged. -- `mp4Url` is `null` until the video is `ready`, and the MP4 must be within the 2 GB limit for URL transcripts. A larger MP4 ends with the error `source_too_large`. -- The transcript is its own object. It is not linked to the video, the video's `transcriptionStatus` does not change, and deleting the video leaves the transcript. Keep the video ID in `metadata`, as above, and delete the transcript yourself when you delete the video. -- Delete the video first and a transcript that has not started downloading ends with `source_unreachable`. -- On a self-hosted Cap, the storage the link redirects to must be on a public address and the default port, like any other URL. Otherwise the transcript ends with `source_blocked`. +- The video must belong to the app that owns the key, not be deleted and be `ready`. An unknown or deleted video returns `404 video_not_found`. A video that is still `uploading` or `processing` returns `409 video_not_ready`: wait for `ready` and send the request again. A video that failed to process has no file to transcribe and returns the same code. +- Cap reads the video's stored MP4 directly. Nothing is downloaded, so the 2 GB limit and the 15 minute download limit for URL transcripts do not apply. Audio can still be up to 4 hours long. +- It costs the transcript price only, $0.10 per audio hour. Reading the stored video is not charged, and the video's storage charge does not change. +- `name`, `language`, `userId`, `metadata` and the `Idempotency-Key` header work as they do for a URL. The name defaults to the video's name. +- The transcript carries the video's ID as `videoId`, and `source.type` is `video`. `videoId` is `null` on every other transcript. +- Captions for the hosted player are generated from the newest completed transcript of a video in each language. +- The transcript is its own object. The video's `transcriptionStatus` does not change, and deleting the video leaves the transcript and its text. A transcript whose video is deleted before it runs ends with `source_missing` and is not charged. +- Sending the video's `mp4Url` as `url` still works as a URL transcript. It is not linked to the video: its `videoId` is `null`. #### Transcribe a file @@ -769,6 +796,7 @@ Returns the transcript. Once the status is `complete` the response includes `tex "status": "complete", "name": "standup", "source": { "type": "upload", "url": null }, + "videoId": null, "durationSeconds": 754.2, "language": "en", "wordCount": 1893, @@ -863,10 +891,10 @@ Deletes the transcript, its text and its stored files. A transcript deleted befo - Credits are taken once, when the transcript completes. A transcript that is retried internally is never charged twice. - Audio with no speech is billed for its length and returns an empty transcript, because the audio was extracted and run through speech recognition. - Never charged: a transcript that ends in `error` for any reason (a file that is not audio or video, a file with no audio track, a source that cannot be fetched or is too large, audio over 4 hours, not enough credit, a timeout, or a failure on our side), and a transcript deleted before it completes. -- Starting holds up to the cost of four hours ($0.40) until the audio is measured, then the hold drops to the real cost. The hold is placed before the file is downloaded or processed and nothing is charged for it. It is sized from the file: a file under 180 KB holds $0.01, a file of 7.2 MB or more holds the full $0.40, and a URL that does not report its size holds $0.40. The available balance is your balance minus those holds. +- Starting holds up to the cost of four hours ($0.40) until the audio is measured, then the hold drops to the real cost. The hold is placed before the file is downloaded or processed and nothing is charged for it. It is sized from the file: a file under 180 KB holds $0.01, a file of 7.2 MB or more holds the full $0.40, and a URL that does not report its size holds $0.40. A stored video is held for its known length instead, so a 10 minute video holds under $0.02. The available balance is your balance minus those holds. - If the available balance cannot cover the hold, the transcript ends with `insufficient_credits` before the file is downloaded or processed, and the error message says how much was needed. When other running transcripts hold the credit, it waits for them for up to 15 minutes first, so a batch started together runs through as holds are released. - Credit held for a running transcript is left alone by the daily storage charge and is not counted when uploads are admitted. If the balance still cannot pay for a transcript in full when it finishes, the transcript ends with `insufficient_credits`, its text is discarded and nothing is charged. Top up and submit it again. -- Cap deletes the source file once the transcript finishes. The text and timings are kept until you delete the transcript. +- Cap deletes the source file once the transcript finishes. A stored video's own file is never touched. The text and timings are kept until you delete the transcript. ### Get Usage diff --git a/apps/web/lib/developer-openapi.ts b/apps/web/lib/developer-openapi.ts index 6cf2c765a5f..48db1381cda 100644 --- a/apps/web/lib/developer-openapi.ts +++ b/apps/web/lib/developer-openapi.ts @@ -153,14 +153,24 @@ const transcriptSchema = { type: "object", required: ["type"], properties: { - type: { type: "string", enum: ["upload", "url"] }, + type: { + type: "string", + enum: ["upload", "url", "video"], + description: + "upload for a file sent to Cap, url for a public URL, video for a video the app stores.", + }, url: { type: ["string", "null"], description: - "The URL the transcript was created from, without its query string or fragment. null for an upload.", + "The URL the transcript was created from, without its query string or fragment. null for an upload and for a stored video.", }, }, }, + videoId: { + type: ["string", "null"], + description: + "The video this transcript was created from with videoId. null for every other transcript. Captions for the hosted player are generated from the newest completed transcript of a video in each language.", + }, durationSeconds: { type: ["number", "null"], description: "Measured audio length. Set when the transcript finishes.", @@ -578,7 +588,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ operationId: "createTranscript", summary: "Transcribe a URL", description: - "Creates a transcript from a public audio or video URL and starts it. The URL must use http or https on the default port and resolve to a public address. Requires an available credit balance of at least 1,000 micro-credits. Starting holds up to the cost of four hours ($0.40) until the audio is measured, then the hold drops to the real cost. A transcript whose hold the available balance cannot cover ends with insufficient_credits before the file is downloaded. The file must finish downloading within 15 minutes. Poll getTranscript every few seconds until status is complete or error. To transcribe a video the app already stores, send the video's mp4Url as url once the video is ready. A field the request does not know returns 400 with code invalid_request.", + "Creates a transcript from a public audio or video URL, or from a video the app already stores, and starts it. Send exactly one of url or videoId. The URL must use http or https on the default port and resolve to a public address, and the file must finish downloading within 15 minutes. With videoId the video must belong to this app, not be deleted and be ready: Cap reads its stored MP4 directly, so nothing is downloaded and the URL size limit does not apply. An unknown or deleted video returns 404 video_not_found, and one that is not ready returns 409 video_not_ready. A video transcript costs the transcript price only, $0.10 per audio hour. Requires an available credit balance of at least 1,000 micro-credits. Starting holds up to the cost of four hours ($0.40) until the audio is measured, then the hold drops to the real cost. A stored video is held for its known length. A transcript whose hold the available balance cannot cover ends with insufficient_credits before the file is downloaded. Poll getTranscript every few seconds until status is complete or error. A field the request does not know returns 400 with code invalid_request.", tags: ["Transcripts"], security: secretAuth, parameters: [idempotencyKeyParam], @@ -591,6 +601,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ "400": response("BadRequest"), "402": response("PaymentRequired"), "403": response("Forbidden"), + "404": response("NotFound"), "409": response("Conflict"), "415": response("UnsupportedMediaType"), "503": response("Unavailable"), @@ -1054,7 +1065,8 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ }, CreateTranscriptRequest: { type: "object", - required: ["url"], + description: "Send exactly one of url or videoId.", + oneOf: [{ required: ["url"] }, { required: ["videoId"] }], additionalProperties: false, properties: { url: { @@ -1062,7 +1074,14 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ format: "uri", maxLength: 2048, description: - "Public http or https URL of an audio or video file. The mp4Url of a ready video of this app works.", + "Public http or https URL of an audio or video file. Leave it out when you send videoId.", + }, + videoId: { + type: "string", + minLength: 1, + maxLength: 64, + description: + "ID of a ready video of this app. Cap transcribes its stored MP4 directly. Leave it out when you send url.", }, ...transcriptOptionProperties, }, From 8b7138ea3f270fb3ca6b343b31bac0973df6227d Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 19:06:53 -0500 Subject: [PATCH 04/16] feat(infra): transcribe a stored video by id and write its captions --- .../developer-transcripts-database.test.ts | 293 ++++++- .../unit/developer-transcribe-video.test.ts | 384 ++++++++++ .../unit/developer-transcript-fake-db.ts | 21 +- .../unit/developer-transcript-math.test.ts | 3 + .../unit/developer-transcripts-api.test.ts | 97 ++- .../unit/developer-video-captions.test.ts | 724 ++++++++++++++++++ .../developer/v1/[...route]/transcripts.ts | 30 +- apps/web/lib/developer-transcript-errors.ts | 22 +- apps/web/lib/developer-transcript-store.ts | 137 ++++ apps/web/lib/developer-transcripts.ts | 6 +- apps/web/lib/developer-video-captions.ts | 216 ++++++ apps/web/workflows/developer-transcribe.ts | 76 +- packages/web-domain/src/Agent.ts | 3 +- 13 files changed, 1996 insertions(+), 16 deletions(-) create mode 100644 apps/web/__tests__/unit/developer-transcribe-video.test.ts create mode 100644 apps/web/__tests__/unit/developer-video-captions.test.ts create mode 100644 apps/web/lib/developer-video-captions.ts diff --git a/apps/web/__tests__/integration/developer-transcripts-database.test.ts b/apps/web/__tests__/integration/developer-transcripts-database.test.ts index e25a3bf5659..66afe23b0dd 100644 --- a/apps/web/__tests__/integration/developer-transcripts-database.test.ts +++ b/apps/web/__tests__/integration/developer-transcripts-database.test.ts @@ -25,6 +25,8 @@ const fixture = vi.hoisted(() => ({ start: vi.fn(), objectSize: null as number | null, copies: [] as string[], + objects: new Map(), + queries: [] as { sql: string; params: unknown[] }[], })); vi.mock("@cap/database", () => ({ @@ -55,8 +57,14 @@ vi.mock("@/lib/developer-transcript-storage", () => ({ copyDeveloperObject: async (_from: string, to: string) => { fixture.copies.push(to); }, - getDeveloperObjectText: async () => null, - deleteDeveloperObjects: async () => {}, + getDeveloperObjectText: async (key: string) => + fixture.objects.get(key) ?? null, + putDeveloperObject: async (key: string, body: string) => { + fixture.objects.set(key, body); + }, + deleteDeveloperObjects: async (keys: string[]) => { + for (const key of keys) fixture.objects.delete(key); + }, })); vi.mock("@/workflows/process-developer-video", () => ({ processDeveloperVideoWorkflow: "developer-video-workflow", @@ -93,8 +101,12 @@ const { getInfraAppStanding, getInfraUploadAdmission } = await import( ); const { getAppStorageUsage } = await import("@/lib/infra-storage"); const { chargeStorageDay } = await import("@/lib/infra-storage-billing"); +const { developerVideoCaptions, syncVideoCaptions, videoCaptionsKey } = + await import("@/lib/developer-video-captions"); +const { serializeTranscript } = await import("@/lib/developer-transcripts"); const { createTranscriptFromUrl, + createTranscriptFromVideo, createTranscriptUpload, deleteTranscript, expireStaleTranscripts, @@ -261,7 +273,13 @@ describe.skipIf(!databaseUrl)("developer transcripts database", () => { ); } pool = createPool({ uri: databaseUrl, connectionLimit: 16 }); - fixture.database = drizzle(pool); + fixture.database = drizzle(pool, { + logger: { + logQuery: (sql, params) => { + fixture.queries.push({ sql, params }); + }, + }, + }); await database().select().from(developerTranscripts).limit(1); }); @@ -275,6 +293,8 @@ describe.skipIf(!databaseUrl)("developer transcripts database", () => { fixture.start.mockResolvedValue(undefined); fixture.objectSize = null; fixture.copies = []; + fixture.objects = new Map(); + fixture.queries = []; }); it("charges a transcript exactly once when settle attempts race", async () => { @@ -1159,4 +1179,271 @@ describe.skipIf(!databaseUrl)("developer transcripts database", () => { } expect([...seen].sort()).toEqual([40_000, 50_000]); }); + + const readyProcessing = () => ({ + encodeId: "encode-1", + startedAt: 1_000, + probe: { duration: 120, width: 1920, height: 1080, fps: 30 }, + hls: { state: "complete" as const, output: "out-1" }, + mp4: { state: "complete" as const, output: "out-1" }, + attempts: { hls: 1, mp4: 1 }, + unavailableChecks: 0, + poster: null, + bytes: { source: 1_000, hls: 1_000, mp4: 1_000, poster: 0 }, + }); + + const makeReadyVideo = ( + appId: string, + overrides: Partial = {}, + ) => + makeStoredVideo(appId, { + duration: 120, + playbackId: id() + id(), + processing: readyProcessing(), + ...overrides, + }); + + const makeCaptionSource = async ( + appId: string, + videoId: string | null, + text: string, + overrides: Partial = {}, + ) => { + const transcriptId = overrides.id ?? id(); + const resultKey = `developer/${appId}/transcripts/${transcriptId}/result.json`; + fixture.objects.set( + resultKey, + JSON.stringify({ + text, + language: "en", + durationSeconds: 10, + words: text.split(" ").map((word, index) => ({ + text: word, + start: index * 0.5, + end: index * 0.5 + 0.4, + })), + segments: [], + }), + ); + await makeTranscript(appId, { + id: transcriptId, + status: "complete", + sourceType: videoId ? "video" : "upload", + videoId, + language: "en", + text, + wordCount: text.split(" ").length, + resultKey, + completedAt: new Date(), + ...overrides, + }); + return transcriptId; + }; + + const explainLastQuery = async (containing: string) => { + const query = fixture.queries.findLast((entry) => + entry.sql.includes(containing), + ); + if (!query || !pool) throw new Error(`No query containing ${containing}`); + const [plan] = await pool.query(`EXPLAIN ${query.sql}`, query.params); + return plan as { key: string | null; rows: number; Extra: string | null }[]; + }; + + it("creates a video transcript once, scoped to its app, and only for a ready video", async () => { + const appId = await makeApp(50_000); + const otherAppId = await makeApp(50_000); + const videoId = await makeReadyVideo(appId); + const otherVideoId = await makeReadyVideo(appId); + const processingVideoId = await makeStoredVideo(appId, { + status: "processing", + }); + const deletedVideoId = await makeReadyVideo(appId, { + deletedAt: new Date(), + }); + const idempotencyKey = `video-key-${id()}`; + + const results = await Promise.all( + Array.from({ length: 8 }, () => + createTranscriptFromVideo(appId, { videoId, idempotencyKey }), + ), + ); + + const ids = new Set( + results.map((result) => (result.ok ? result.transcript.id : "failed")), + ); + expect(ids.size).toBe(1); + expect(ids.has("failed")).toBe(false); + expect(fixture.start).toHaveBeenCalledTimes(1); + const [stored] = await database() + .select() + .from(developerTranscripts) + .where(eq(developerTranscripts.appId, appId)); + expect(stored).toMatchObject({ + sourceType: "video", + videoId, + status: "queued", + s3Key: `developer/${appId}/${videoId}/encode-1/out-1/video.mp4`, + name: "Stored fixture", + }); + if (!stored) throw new Error("Transcript was not stored."); + expect(serializeTranscript(stored)).toMatchObject({ + videoId, + source: { type: "video", url: null }, + }); + + expect( + await createTranscriptFromVideo(appId, { + videoId: otherVideoId, + idempotencyKey, + }), + ).toMatchObject({ + ok: false, + status: 409, + message: "Idempotency key was already used with a different videoId", + }); + expect( + await createTranscriptFromVideo(otherAppId, { videoId }), + ).toMatchObject({ ok: false, status: 404, code: "video_not_found" }); + expect( + await createTranscriptFromVideo(appId, { videoId: deletedVideoId }), + ).toMatchObject({ ok: false, status: 404, code: "video_not_found" }); + expect( + await createTranscriptFromVideo(appId, { videoId: processingVideoId }), + ).toMatchObject({ ok: false, status: 409, code: "video_not_ready" }); + expect(fixture.start).toHaveBeenCalledTimes(1); + }); + + it("keeps videoId null for transcripts that are not from a video", async () => { + const appId = await makeApp(50_000); + const videoId = await makeReadyVideo(appId); + const fromUrl = await createTranscriptFromUrl( + appId, + { url: URL_A }, + guards, + ); + const fromUpload = await createTranscriptUpload(appId, { + fileName: "a.mp3", + contentLength: 1024, + }); + await makeCaptionSource(appId, null, "hello there"); + + for (const created of [fromUrl, fromUpload]) { + if (!created.ok) throw new Error("Transcript was not created."); + expect(created.transcript.videoId).toBeNull(); + expect(serializeTranscript(created.transcript).videoId).toBeNull(); + } + const listed = await listTranscripts(appId, { limit: 10 }); + expect(listed.transcripts).toHaveLength(3); + expect(listed.transcripts.every((row) => row.videoId === null)).toBe(true); + expect(await developerVideoCaptions({ id: videoId, appId })).toEqual([]); + }); + + it("returns the caption files of a video from one indexed query", async () => { + const appId = await makeApp(50_000); + const videoId = await makeReadyVideo(appId); + const filler = Array.from({ length: 3_000 }, (_, index) => ({ + id: id(), + appId, + status: "complete" as const, + sourceType: index % 3 === 0 ? ("upload" as const) : ("video" as const), + videoId: index % 3 === 0 ? null : `filler-${index % 60}`, + language: "en", + wordCount: 4, + })); + for (let offset = 0; offset < filler.length; offset += 500) { + await database() + .insert(developerTranscripts) + .values(filler.slice(offset, offset + 500)); + } + await makeCaptionSource(appId, videoId, "older english words"); + await makeCaptionSource(appId, videoId, "newer english words"); + await makeCaptionSource(appId, videoId, "deutsche worte hier", { + language: "de", + }); + await makeCaptionSource(appId, videoId, "", { + language: "fr", + wordCount: 0, + }); + await makeCaptionSource(appId, videoId, "borrado", { + language: "es", + deletedAt: new Date(), + }); + await pool?.query("ANALYZE TABLE developer_transcripts"); + + fixture.queries = []; + const captions = await developerVideoCaptions({ id: videoId, appId }); + + expect(captions).toEqual([ + { + language: "en", + label: "English", + key: `developer/${appId}/${videoId}/captions/en.vtt`, + }, + { + language: "de", + label: "German", + key: `developer/${appId}/${videoId}/captions/de.vtt`, + }, + ]); + expect(fixture.queries).toHaveLength(1); + const plan = await explainLastQuery("developer_transcripts"); + expect(plan).toHaveLength(1); + expect(plan[0]?.key).toBe("dev_transcripts_app_video_idx"); + expect(Number(plan[0]?.rows)).toBeLessThan(50); + + fixture.queries = []; + await syncVideoCaptions({ appId, videoId, language: "en" }); + const newest = await explainLastQuery("order by"); + expect(newest[0]?.key).toBe("dev_transcripts_app_video_idx"); + expect(newest[0]?.Extra ?? "").not.toContain("filesort"); + }); + + it("writes the newest transcript's captions, replaces them and removes them on delete", async () => { + const appId = await makeApp(50_000); + const videoId = await makeReadyVideo(appId); + const key = videoCaptionsKey({ id: videoId, appId }, "en"); + const older = await makeCaptionSource(appId, videoId, "older words", { + createdAt: new Date("2026-11-12T10:00:00.000Z"), + }); + expect(await syncVideoCaptions({ appId, videoId, language: "en" })).toEqual( + { state: "written", transcriptId: older }, + ); + expect(fixture.objects.get(key)).toContain("older words"); + + const newer = await makeCaptionSource(appId, videoId, "newer words", { + createdAt: new Date("2026-11-12T10:00:05.000Z"), + }); + expect(await syncVideoCaptions({ appId, videoId, language: "en" })).toEqual( + { state: "written", transcriptId: newer }, + ); + expect(fixture.objects.get(key)).toContain("newer words"); + expect(fixture.objects.get(key)).toMatch(/^WEBVTT\n\n00:00:00\.000 --> /); + + expect(await deleteTranscript(appId, newer)).toEqual({ ok: true }); + expect(fixture.objects.get(key)).toContain("older words"); + expect(await deleteTranscript(appId, older)).toEqual({ ok: true }); + expect(fixture.objects.has(key)).toBe(false); + expect(await developerVideoCaptions({ id: videoId, appId })).toEqual([]); + }); + + it("orders two transcripts created in the same second the same way every time", async () => { + const appId = await makeApp(50_000); + const videoId = await makeReadyVideo(appId); + const createdAt = new Date("2026-11-12T10:00:00.000Z"); + const first = await makeCaptionSource(appId, videoId, "first words", { + id: `a${id().slice(1)}`, + createdAt, + }); + const second = await makeCaptionSource(appId, videoId, "second words", { + id: `z${id().slice(1)}`, + createdAt, + }); + + for (let round = 0; round < 5; round++) { + expect( + await syncVideoCaptions({ appId, videoId, language: "en" }), + ).toEqual({ state: "written", transcriptId: second }); + } + expect(first).not.toBe(second); + }); }); diff --git a/apps/web/__tests__/unit/developer-transcribe-video.test.ts b/apps/web/__tests__/unit/developer-transcribe-video.test.ts new file mode 100644 index 00000000000..43a8bf6a448 --- /dev/null +++ b/apps/web/__tests__/unit/developer-transcribe-video.test.ts @@ -0,0 +1,384 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const context = vi.hoisted(() => ({ + fake: null as null | { + db: unknown; + rows: (table: object) => Record[]; + seed: ( + table: object, + row: Record, + ) => Record; + reset: () => void; + }, + objects: new Map(), + ledgerIds: 0, + transcribe: vi.fn(), + fetched: [] as string[], + convertBodies: [] as string[], +})); + +vi.mock("@cap/database", async () => { + const schema = await import("@cap/database/schema"); + const { createFakeDatabase } = await import("./developer-transcript-fake-db"); + const fake = createFakeDatabase( + new Map Record>([ + [ + schema.developerTranscripts, + () => ({ + status: "queued", + sourceType: "video", + sourceUrl: null, + videoId: null, + s3Key: null, + requestedLanguage: null, + durationSeconds: null, + language: null, + text: null, + wordCount: null, + resultKey: null, + reservedMicroCredits: 0, + billedMicroCredits: 0, + errorCode: null, + errorMessage: null, + deletedAt: null, + }), + ], + [ + schema.developerVideos, + () => ({ + name: "Untitled", + duration: 30, + status: "ready", + processing: null, + deletedAt: null, + filesDeletedAt: null, + }), + ], + ]), + ); + context.fake = fake; + return { db: () => fake.db }; +}); + +vi.mock("@cap/database/helpers", () => ({ + nanoId: () => { + context.ledgerIds += 1; + return `ledger-${context.ledgerIds}`; + }, + nanoIdLength: 15, +})); + +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ + MEDIA_SERVER_URL: "http://media.test", + MEDIA_SERVER_WEBHOOK_SECRET: "secret", + }), + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, + NODE_ENV: "test", +})); + +vi.mock("@/lib/developer-transcript-storage", () => ({ + developerObjectSize: async (key: string) => + context.objects.get(key)?.length ?? null, + developerObjectUrl: async (key: string) => `http://storage.test/${key}`, + getDeveloperObjectText: async (key: string) => { + const bytes = context.objects.get(key); + return bytes ? new TextDecoder().decode(bytes) : null; + }, + putDeveloperObject: async (key: string, body: string | Uint8Array) => { + context.objects.set( + key, + typeof body === "string" ? new TextEncoder().encode(body) : body, + ); + }, + putDeveloperObjectFile: async () => { + throw new Error("A video transcript must not copy its source"); + }, + putDeveloperObjectStream: async () => { + throw new Error("A video transcript must not copy its source"); + }, + deleteDeveloperObjects: async (keys: string[]) => { + for (const key of keys) context.objects.delete(key); + }, +})); + +vi.mock("@/lib/developer-transcript-speech", () => ({ + transcribeSpeech: context.transcribe, + parseTranscriptLanguage: () => "auto", +})); + +import { + developerCreditAccounts, + developerCreditTransactions, + developerTranscripts, + developerVideos, +} from "@cap/database/schema"; +import { transcriptionMicroCredits } from "@/lib/developer-pricing"; +import { developerVideoCaptions } from "@/lib/developer-video-captions"; +import { developerTranscribeWorkflow } from "@/workflows/developer-transcribe"; +import { processingState } from "../fixtures/developer-video"; + +const MP4_KEY = "developer/app-1/video-1/encode-1/out-1/video.mp4"; +const UPLOAD_KEY = "developer/_uploads/app-1/t-tr-1"; +const RESULT_KEY = "developer/app-1/transcripts/tr-1/result.json"; +const CAPTIONS_KEY = "developer/app-1/video-1/captions/en.vtt"; +const FRAME_SECONDS = 1152 / 44_100; + +function mp3Frames(count: number) { + const length = Math.floor((144_000 * 128) / 44_100); + const out = new Uint8Array(length * count); + for (let index = 0; index < count; index++) { + out.set([0xff, 0xfb, 9 << 4, 0x00], index * length); + } + return out; +} + +const mp4Bytes = (size = 4_000_000) => { + const bytes = new Uint8Array(size); + bytes.set(new TextEncoder().encode("ftypisom"), 4); + return bytes; +}; + +const fake = () => { + if (!context.fake) throw new Error("Fake database is not ready"); + return context.fake; +}; +const transcript = () => fake().rows(developerTranscripts)[0]; +const video = () => fake().rows(developerVideos)[0]; +const balance = () => + fake().rows(developerCreditAccounts)[0]?.balanceMicroCredits; +const ledger = () => fake().rows(developerCreditTransactions); +const run = () => + developerTranscribeWorkflow({ transcriptId: "tr-1", runId: "run-1" }); + +const speech = (text: string) => ({ + state: "ok", + text, + words: text.split(" ").map((word, index) => ({ + text: word, + start: index * 500, + end: index * 500 + 400, + confidence: 0.9, + })), + language: "en", + durationSeconds: 30, +}); + +beforeEach(() => { + fake().reset(); + context.objects = new Map([[MP4_KEY, mp4Bytes()]]); + context.ledgerIds = 0; + context.fetched = []; + context.convertBodies = []; + context.transcribe.mockReset(); + context.transcribe.mockResolvedValue(speech("hello from the video")); + fake().seed(developerCreditAccounts, { + id: "account-1", + appId: "app-1", + balanceMicroCredits: 100_000, + }); + fake().seed(developerVideos, { + id: "video-1", + appId: "app-1", + playbackId: "playback-id-0123456789ab", + processing: processingState({ + hls: { state: "complete", output: "out-1" }, + mp4: { state: "complete", output: "out-1" }, + }), + }); + fake().seed(developerTranscripts, { + id: "tr-1", + appId: "app-1", + runId: "run-1", + videoId: "video-1", + s3Key: MP4_KEY, + }); + const audio = mp3Frames(Math.round(30 / FRAME_SECONDS)); + vi.stubGlobal( + "fetch", + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + context.fetched.push(url); + if (url.startsWith("http://storage.test/")) { + const bytes = context.objects.get( + url.slice("http://storage.test/".length), + ); + return bytes + ? new Response(bytes.slice(0, 1_000_000), { status: 200 }) + : new Response(null, { status: 404 }); + } + if (url === "http://media.test/video/probe") { + return new Response("{}", { status: 500 }); + } + if (url === "http://media.test/audio/check") { + return Response.json({ hasAudio: true }); + } + if (url === "http://media.test/audio/convert") { + context.convertBodies.push(String(init?.body ?? "")); + return new Response(audio.slice(), { status: 200 }); + } + throw new Error(`Unexpected fetch ${url}`); + }, + ); +}); + +afterEach(() => vi.unstubAllGlobals()); + +describe("transcribing a stored video", () => { + it("reads the stored MP4, charges the transcript price and writes the caption file", async () => { + const result = await run(); + + const measured = Math.round(30 / FRAME_SECONDS) * FRAME_SECONDS; + const cost = transcriptionMicroCredits(measured); + expect(result.success).toBe(true); + expect(transcript()).toMatchObject({ + status: "complete", + text: "hello from the video", + wordCount: 4, + language: "en", + billedMicroCredits: cost, + reservedMicroCredits: 0, + sourceBytes: 4_000_000, + }); + expect(balance()).toBe(100_000 - cost); + expect(ledger()).toHaveLength(1); + expect(ledger()[0]).toMatchObject({ type: "transcription" }); + + expect(context.convertBodies).toHaveLength(1); + expect(context.convertBodies[0]).toContain( + `http://storage.test/${MP4_KEY}`, + ); + expect( + context.fetched.every( + (url) => + url.startsWith("http://storage.test/") || + url.startsWith("http://media.test/"), + ), + ).toBe(true); + expect(context.objects.has(UPLOAD_KEY)).toBe(false); + + expect([...context.objects.keys()].sort()).toEqual( + [MP4_KEY, RESULT_KEY, CAPTIONS_KEY].sort(), + ); + const captions = new TextDecoder().decode( + context.objects.get(CAPTIONS_KEY), + ); + expect(captions).toBe( + "WEBVTT\n\n00:00:00.000 --> 00:00:01.900\nhello from the video\n", + ); + expect( + await developerVideoCaptions({ id: "video-1", appId: "app-1" }), + ).toEqual([{ language: "en", label: "English", key: CAPTIONS_KEY }]); + }); + + it("holds for the video's known length, not for its file size", async () => { + let held = 0; + context.transcribe.mockImplementation(async () => { + held = Number(transcript()?.reservedMicroCredits); + return speech("hello"); + }); + const row = video(); + if (row) row.duration = 3600; + context.objects.set(MP4_KEY, mp4Bytes(8_000_000)); + + await run(); + + expect(transcript()?.status).toBe("complete"); + expect(held).toBeGreaterThan(0); + expect(held).toBeLessThanOrEqual(transcriptionMicroCredits(3602)); + }); + + it("writes no caption file when the video has no speech and still bills it", async () => { + context.transcribe.mockResolvedValue({ state: "no_speech" }); + + const result = await run(); + + expect(result.success).toBe(true); + expect(transcript()).toMatchObject({ + status: "complete", + text: "", + wordCount: 0, + }); + expect(Number(transcript()?.billedMicroCredits)).toBeGreaterThan(0); + expect(context.objects.has(CAPTIONS_KEY)).toBe(false); + expect( + await developerVideoCaptions({ id: "video-1", appId: "app-1" }), + ).toEqual([]); + }); + + it("fails without a charge when the video was deleted before the transcript ran", async () => { + const row = video(); + if (row) row.deletedAt = new Date("2026-11-12T09:00:00.000Z"); + + const result = await run(); + + expect(result.success).toBe(false); + expect(transcript()).toMatchObject({ + status: "error", + errorCode: "source_missing", + errorMessage: "The video was deleted before it could be transcribed", + billedMicroCredits: 0, + reservedMicroCredits: 0, + }); + expect(ledger()).toHaveLength(0); + expect(context.transcribe).not.toHaveBeenCalled(); + expect(context.objects.has(MP4_KEY)).toBe(true); + expect(context.objects.has(CAPTIONS_KEY)).toBe(false); + }); + + it("fails without a charge when the stored file is gone", async () => { + context.objects.delete(MP4_KEY); + + await run(); + + expect(transcript()).toMatchObject({ + status: "error", + errorCode: "source_missing", + billedMicroCredits: 0, + }); + expect(context.convertBodies).toHaveLength(0); + }); + + it("never deletes the video's file when the transcript fails", async () => { + context.transcribe.mockRejectedValue(new Error("speech is down")); + + await expect(run()).rejects.toThrow("speech is down"); + + expect(transcript()).toMatchObject({ + status: "error", + errorCode: "transcription_failed", + }); + expect(context.objects.has(MP4_KEY)).toBe(true); + expect(context.objects.has(CAPTIONS_KEY)).toBe(false); + }); + + it("fails with insufficient credits when the hold cannot be covered, and writes nothing", async () => { + const account = fake().rows(developerCreditAccounts)[0]; + if (account) account.balanceMicroCredits = 0; + + await run(); + + expect(transcript()).toMatchObject({ + status: "error", + errorCode: "insufficient_credits", + }); + expect(context.convertBodies).toHaveLength(0); + expect(context.objects.has(CAPTIONS_KEY)).toBe(false); + }); + + it("leaves a transcript that is not from a video without a caption file", async () => { + const row = transcript(); + if (row) { + row.sourceType = "upload"; + row.videoId = null; + } + context.objects.set(UPLOAD_KEY, mp4Bytes(64_000)); + + await run(); + + expect(transcript()?.status).toBe("complete"); + expect( + [...context.objects.keys()].some((key) => key.includes("/captions/")), + ).toBe(false); + }); +}); diff --git a/apps/web/__tests__/unit/developer-transcript-fake-db.ts b/apps/web/__tests__/unit/developer-transcript-fake-db.ts index b08bda693f5..9aaf3901cb9 100644 --- a/apps/web/__tests__/unit/developer-transcript-fake-db.ts +++ b/apps/web/__tests__/unit/developer-transcript-fake-db.ts @@ -228,7 +228,7 @@ export function createFakeDatabase(defaults: Map Row>) { return new Date(clock); }; - const select = (fields?: Record) => ({ + const select = (fields?: Record, distinct = false) => ({ from: (table: Table) => { const state = { condition: undefined as SQL | undefined, @@ -261,10 +261,19 @@ export function createFakeDatabase(defaults: Map Row>) { Object.entries(fields).filter(([alias]) => !(alias in scalars)), ) : fields; - const projected = project(rows, selected).map((row) => ({ + const all = project(rows, selected).map((row) => ({ ...row, ...scalars, })); + const seen = new Set(); + const projected = distinct + ? all.filter((row) => { + const key = JSON.stringify(row); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + : all; return projected.slice( state.offset, state.limit === undefined ? undefined : state.offset + state.limit, @@ -359,7 +368,13 @@ export function createFakeDatabase(defaults: Map Row>) { }, }); - const client = { select, insert, update, delete: remove }; + const client = { + select: (fields?: Record) => select(fields), + selectDistinct: (fields: Record) => select(fields, true), + insert, + update, + delete: remove, + }; const transaction = (callback: (tx: typeof client) => Promise) => { const run = async () => { diff --git a/apps/web/__tests__/unit/developer-transcript-math.test.ts b/apps/web/__tests__/unit/developer-transcript-math.test.ts index 29550eb6064..11e9a4e0dce 100644 --- a/apps/web/__tests__/unit/developer-transcript-math.test.ts +++ b/apps/web/__tests__/unit/developer-transcript-math.test.ts @@ -35,6 +35,7 @@ const row = ( status: "complete", sourceType: "upload", sourceUrl: null, + videoId: null, s3Key: "developer/app-1/transcripts/tr-1/source", resultKey: "developer/app-1/transcripts/tr-1/result.json", sourceBytes: 1_000, @@ -264,6 +265,7 @@ describe("transcript serialization", () => { "text", "updatedAt", "userId", + "videoId", "wordCount", ]); expect(Object.keys(agent).sort()).toEqual([ @@ -282,6 +284,7 @@ describe("transcript serialization", () => { "sourceUrl", "status", "updatedAt", + "videoId", "wordCount", ]); for (const serialized of [JSON.stringify(rest), JSON.stringify(agent)]) { diff --git a/apps/web/__tests__/unit/developer-transcripts-api.test.ts b/apps/web/__tests__/unit/developer-transcripts-api.test.ts index 5600e4da9b0..98d50aed7b3 100644 --- a/apps/web/__tests__/unit/developer-transcripts-api.test.ts +++ b/apps/web/__tests__/unit/developer-transcripts-api.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ keyRows: [] as { appId: string }[], store: { createTranscriptFromUrl: vi.fn(), + createTranscriptFromVideo: vi.fn(), createTranscriptUpload: vi.fn(), startTranscriptUpload: vi.fn(), getTranscript: vi.fn(), @@ -62,6 +63,7 @@ const transcript = (overrides: Record = {}) => ({ status: "queued", sourceType: "url", sourceUrl: "https://media.example.com/a.mp3", + videoId: null, s3Key: "developer/app-1/transcripts/tr-1/source", resultKey: null, sourceBytes: null, @@ -317,7 +319,7 @@ describe("developer transcripts API", () => { expect(body).toMatchObject({ code: "invalid_request", error: `Invalid request: Unrecognized key: "${field}"`, - hint: "This request accepts url, name, language, userId, metadata. Field names are case-sensitive.", + hint: "This request accepts url, videoId, name, language, userId, metadata. Field names are case-sensitive.", }); expect(body.issues).toEqual([ expect.objectContaining({ code: "unrecognized_keys", keys: [field] }), @@ -349,6 +351,99 @@ describe("developer transcripts API", () => { }, ); + it("passes a video id, options and idempotency key to the store", async () => { + mocks.store.createTranscriptFromVideo.mockResolvedValue({ + ok: true, + transcript: transcript({ + sourceType: "video", + sourceUrl: null, + videoId: "video-1", + s3Key: "developer/app-1/video-1/encode-1/out-1/video.mp4", + }), + }); + const response = await post( + "/", + { videoId: "video-1", language: "en", metadata: { a: 1 } }, + { ...SECRET, "Idempotency-Key": "retry-key-1" }, + ); + expect(response.status).toBe(200); + expect(mocks.store.createTranscriptFromVideo).toHaveBeenCalledWith( + "app-1", + { + videoId: "video-1", + language: "en", + metadata: { a: 1 }, + idempotencyKey: "retry-key-1", + }, + ); + expect(mocks.store.createTranscriptFromUrl).not.toHaveBeenCalled(); + const body = await response.json(); + expect(body.data).toMatchObject({ + id: "tr-1", + videoId: "video-1", + source: { type: "video", url: null }, + }); + expect(JSON.stringify(body)).not.toContain("video.mp4"); + }); + + it("returns a null video id for a transcript that is not from a video", async () => { + mocks.store.createTranscriptFromUrl.mockResolvedValue({ + ok: true, + transcript: transcript(), + }); + const response = await post( + "/", + { url: "https://media.example.com/a.mp3" }, + SECRET, + ); + expect((await response.json()).data).toMatchObject({ + videoId: null, + source: { type: "url" }, + }); + }); + + it.each([ + ["both url and videoId", { url: "https://a.example/a", videoId: "v1" }], + ["neither url nor videoId", { name: "Standup" }], + ["an empty body", {}], + ])("refuses %s", async (_label, body) => { + const response = await post("/", body, SECRET); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + code: "invalid_request", + error: "Invalid request: Send exactly one of url or videoId", + }); + expect(mocks.store.createTranscriptFromUrl).not.toHaveBeenCalled(); + expect(mocks.store.createTranscriptFromVideo).not.toHaveBeenCalled(); + }); + + it.each([ + [{ videoId: "" }], + [{ videoId: 42 }], + [{ videoId: "v".repeat(65) }], + [{ videoId: "video-1", video_id: "video-1" }], + ])("rejects the invalid video body %j", async (body) => { + const response = await post("/", body, SECRET); + expect(response.status).toBe(400); + expect(mocks.store.createTranscriptFromVideo).not.toHaveBeenCalled(); + }); + + it.each([ + [404, "video_not_found"], + [409, "video_not_ready"], + ] as const)("answers %i %s from the store", async (status, code) => { + mocks.store.createTranscriptFromVideo.mockResolvedValue({ + ok: false, + status, + code, + message: "Video problem", + hint: "Fix it", + }); + const response = await post("/", { videoId: "video-1" }, SECRET); + expect(response.status).toBe(status); + expect(await response.json()).toMatchObject({ code }); + }); + it("rejects a malformed idempotency key", async () => { const response = await post( "/", diff --git a/apps/web/__tests__/unit/developer-video-captions.test.ts b/apps/web/__tests__/unit/developer-video-captions.test.ts new file mode 100644 index 00000000000..690ab571b54 --- /dev/null +++ b/apps/web/__tests__/unit/developer-video-captions.test.ts @@ -0,0 +1,724 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const context = vi.hoisted(() => ({ + fake: null as null | { + db: unknown; + rows: (table: object) => Record[]; + seed: ( + table: object, + row: Record, + ) => Record; + reset: () => void; + }, + ids: 0, + start: vi.fn(), + objects: new Map(), + puts: [] as { key: string; contentType: string }[], + afterPut: null as null | (() => void), +})); + +vi.mock("@cap/database", async () => { + const schema = await import("@cap/database/schema"); + const { createFakeDatabase } = await import("./developer-transcript-fake-db"); + const fake = createFakeDatabase( + new Map Record>([ + [ + schema.developerTranscripts, + () => ({ + externalUserId: null, + name: "Untitled", + sourceUrl: null, + videoId: null, + resultKey: null, + sourceBytes: null, + durationSeconds: null, + requestedLanguage: null, + language: null, + text: null, + wordCount: null, + reservedMicroCredits: 0, + billedMicroCredits: 0, + errorCode: null, + errorMessage: null, + metadata: null, + runId: null, + queuedAt: null, + startedAt: null, + completedAt: null, + deletedAt: null, + }), + ], + [ + schema.developerVideos, + () => ({ + name: "Untitled", + duration: 12, + status: "ready", + processing: null, + deletedAt: null, + filesDeletedAt: null, + }), + ], + ]), + ); + context.fake = fake; + return { db: () => fake.db }; +}); + +vi.mock("@cap/database/helpers", () => ({ + nanoId: () => { + context.ids += 1; + return `generated-${context.ids}`; + }, + nanoIdLength: 15, +})); + +vi.mock("@cap/env", () => ({ + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, + NODE_ENV: "production", +})); + +vi.mock("@/lib/infra-access", () => ({ canUseInfra: () => true })); + +vi.mock("workflow/api", () => ({ start: context.start })); + +vi.mock("@/workflows/developer-transcribe", () => ({ + developerTranscribeWorkflow: "developer-transcribe-workflow", +})); + +vi.mock("@/lib/developer-transcript-speech", () => ({ + isSpeechConfigured: () => true, + parseTranscriptLanguage: (value: string | null | undefined) => { + if (!value || value === "auto") return "auto"; + return ["en", "de"].includes(value) ? value : null; + }, +})); + +vi.mock("@/lib/developer-transcript-storage", () => ({ + presignDeveloperUpload: async () => "https://storage.example/upload", + developerObjectSize: async (key: string) => + context.objects.get(key)?.length ?? null, + getDeveloperObjectText: async (key: string) => + context.objects.get(key) ?? null, + putDeveloperObject: async ( + key: string, + body: string, + contentType: string, + ) => { + context.objects.set(key, body); + context.puts.push({ key, contentType }); + const hook = context.afterPut; + context.afterPut = null; + hook?.(); + }, + deleteDeveloperObjects: async (keys: string[]) => { + for (const key of keys) context.objects.delete(key); + }, +})); + +import { + developerApps, + developerCreditAccounts, + developerTranscripts, + developerVideos, + users, +} from "@cap/database/schema"; +import { + createTranscriptFromUrl, + createTranscriptFromVideo, + deleteTranscript, + listTranscripts, +} from "@/lib/developer-transcript-store"; +import { + serializeTranscript, + toAgentDeveloperTranscript, +} from "@/lib/developer-transcripts"; +import { + captionLanguage, + captionLanguageLabel, + developerVideoCaptions, + findVideoForTranscript, + syncVideoCaptions, + videoCaptionsKey, +} from "@/lib/developer-video-captions"; +import { processingState } from "../fixtures/developer-video"; + +const VIDEO = { id: "video-1", appId: "app-1" }; +const MP4_KEY = "developer/app-1/video-1/encode-1/out-1/video.mp4"; +const EN_KEY = "developer/app-1/video-1/captions/en.vtt"; +const DE_KEY = "developer/app-1/video-1/captions/de.vtt"; + +const fake = () => { + if (!context.fake) throw new Error("Fake database is not ready"); + return context.fake; +}; + +const transcripts = () => fake().rows(developerTranscripts); + +const readyProcessing = () => + processingState({ + hls: { state: "complete", output: "out-1" }, + mp4: { state: "complete", output: "out-1" }, + }); + +const seedVideo = (row: Record = {}) => + fake().seed(developerVideos, { + id: "video-1", + appId: "app-1", + name: "Launch walkthrough", + playbackId: "playback-id-0123456789ab", + processing: readyProcessing(), + ...row, + }); + +const resultFor = (text: string) => + JSON.stringify({ + text, + language: "en", + durationSeconds: 10, + words: text.split(" ").map((word, index) => ({ + text: word, + start: index * 0.5, + end: index * 0.5 + 0.4, + })), + segments: [], + }); + +const seedCompleted = ( + id: string, + text: string, + row: Record = {}, +) => { + const resultKey = `developer/app-1/transcripts/${id}/result.json`; + context.objects.set(resultKey, resultFor(text)); + return fake().seed(developerTranscripts, { + id, + appId: "app-1", + status: "complete", + sourceType: "video", + videoId: "video-1", + language: "en", + text, + wordCount: text.split(" ").length, + resultKey, + completedAt: new Date("2026-11-12T10:00:00.000Z"), + ...row, + }); +}; + +beforeEach(() => { + fake().reset(); + context.ids = 0; + context.start.mockReset(); + context.start.mockResolvedValue(undefined); + context.objects = new Map(); + context.puts = []; + context.afterPut = null; + fake().seed(users, { id: "owner-1", email: "owner@example.com" }); + for (const id of ["app-1", "app-2"]) { + fake().seed(developerApps, { id, ownerId: "owner-1", deletedAt: null }); + fake().seed(developerCreditAccounts, { + id: `account-${id}`, + appId: id, + balanceMicroCredits: 100_000, + }); + } +}); + +describe("creating a transcript from a stored video", () => { + it("queues a transcript that reads the video's stored MP4 and starts it once", async () => { + seedVideo(); + + const result = await createTranscriptFromVideo("app-1", { + videoId: "video-1", + }); + + expect(result.ok).toBe(true); + expect(transcripts()).toHaveLength(1); + expect(transcripts()[0]).toMatchObject({ + id: "generated-1", + appId: "app-1", + status: "queued", + sourceType: "video", + videoId: "video-1", + sourceUrl: null, + s3Key: MP4_KEY, + name: "Launch walkthrough", + }); + expect(context.start).toHaveBeenCalledTimes(1); + if (!result.ok) throw new Error("create failed"); + expect(serializeTranscript(result.transcript)).toMatchObject({ + source: { type: "video", url: null }, + videoId: "video-1", + }); + expect(toAgentDeveloperTranscript(result.transcript)).toMatchObject({ + sourceType: "video", + videoId: "video-1", + }); + }); + + it("keeps the same options as a URL transcript", async () => { + seedVideo(); + + const result = await createTranscriptFromVideo("app-1", { + videoId: "video-1", + name: "Captions run", + language: "de", + userId: "user-7", + metadata: { ticket: 42 }, + }); + + expect(result.ok).toBe(true); + expect(transcripts()[0]).toMatchObject({ + name: "Captions run", + requestedLanguage: "de", + externalUserId: "user-7", + metadata: { ticket: 42 }, + }); + expect( + await createTranscriptFromVideo("app-1", { + videoId: "video-1", + language: "xx", + }), + ).toMatchObject({ ok: false, status: 400, code: "invalid_language" }); + }); + + it.each([ + ["uploading", null, "The video is not ready yet"], + ["processing", processingState(), "The video is not ready yet"], + [ + "errored", + processingState({ mp4: { state: "failed", reason: "bad file" } }), + "The video failed to process and has no file to transcribe", + ], + [ + "ready", + processingState({ + hls: { state: "complete", output: "out-1" }, + mp4: { state: "failed", reason: "bad file" }, + }), + "The video has no stored file to transcribe", + ], + ])( + "answers 409 for a video that is %s without storing or starting anything", + async (status, processing, message) => { + seedVideo({ status, processing }); + + const result = await createTranscriptFromVideo("app-1", { + videoId: "video-1", + }); + + expect(result).toMatchObject({ + ok: false, + status: 409, + code: "video_not_ready", + message, + }); + expect(transcripts()).toHaveLength(0); + expect(context.start).not.toHaveBeenCalled(); + }, + ); + + it("answers 404 for another app's video", async () => { + seedVideo({ appId: "app-2" }); + + expect( + await createTranscriptFromVideo("app-1", { videoId: "video-1" }), + ).toMatchObject({ ok: false, status: 404, code: "video_not_found" }); + expect(transcripts()).toHaveLength(0); + expect(context.start).not.toHaveBeenCalled(); + }); + + it("answers 404 for a deleted video and for one that never existed", async () => { + seedVideo({ deletedAt: new Date("2026-11-12T09:00:00.000Z") }); + + for (const videoId of ["video-1", "video-unknown"]) { + expect( + await createTranscriptFromVideo("app-1", { videoId }), + ).toMatchObject({ ok: false, status: 404, code: "video_not_found" }); + } + expect(transcripts()).toHaveLength(0); + }); + + it("answers 409 for a video whose files were removed", async () => { + seedVideo({ filesDeletedAt: new Date("2026-11-12T09:00:00.000Z") }); + + expect( + await createTranscriptFromVideo("app-1", { videoId: "video-1" }), + ).toMatchObject({ ok: false, status: 409, code: "video_not_ready" }); + }); + + it("refuses an empty or oversized video id", async () => { + for (const videoId of ["", " ", "v".repeat(65)]) { + expect( + await createTranscriptFromVideo("app-1", { videoId }), + ).toMatchObject({ ok: false, status: 400, code: "invalid_video_id" }); + } + }); + + it("answers 402 below the minimum balance", async () => { + seedVideo(); + const account = fake().rows(developerCreditAccounts)[0]; + if (account) account.balanceMicroCredits = 999; + + expect( + await createTranscriptFromVideo("app-1", { videoId: "video-1" }), + ).toMatchObject({ ok: false, status: 402, code: "insufficient_credits" }); + expect(transcripts()).toHaveLength(0); + }); + + it("lists a video transcript with its video id", async () => { + seedVideo(); + await createTranscriptFromVideo("app-1", { videoId: "video-1" }); + + const listed = await listTranscripts("app-1", { limit: 10 }); + + expect(listed.transcripts.map((row) => serializeTranscript(row))).toEqual([ + expect.objectContaining({ + videoId: "video-1", + source: { type: "video", url: null }, + }), + ]); + }); +}); + +describe("repeating a video transcript with an Idempotency-Key", () => { + const KEY = "captions-key-1"; + + it("returns the same transcript and starts it once", async () => { + seedVideo(); + const first = await createTranscriptFromVideo("app-1", { + videoId: "video-1", + idempotencyKey: KEY, + }); + const second = await createTranscriptFromVideo("app-1", { + videoId: "video-1", + idempotencyKey: KEY, + }); + + expect(first.ok && second.ok).toBe(true); + if (!first.ok || !second.ok) throw new Error("create failed"); + expect(second.transcript.id).toBe(first.transcript.id); + expect(transcripts()).toHaveLength(1); + expect(context.start).toHaveBeenCalledTimes(1); + }); + + it("refuses the key for a different video and names the field", async () => { + seedVideo(); + seedVideo({ id: "video-2" }); + await createTranscriptFromVideo("app-1", { + videoId: "video-1", + idempotencyKey: KEY, + }); + + expect( + await createTranscriptFromVideo("app-1", { + videoId: "video-2", + idempotencyKey: KEY, + }), + ).toMatchObject({ + ok: false, + status: 409, + code: "idempotency_conflict", + message: "Idempotency key was already used with a different videoId", + }); + expect(transcripts()).toHaveLength(1); + }); + + it.each([ + ["name", { name: "Other" }], + ["language", { language: "de" }], + ["userId", { userId: "user-9" }], + ["metadata", { metadata: { a: 1 } }], + ])("refuses the key with a different %s", async (field, change) => { + seedVideo(); + await createTranscriptFromVideo("app-1", { + videoId: "video-1", + idempotencyKey: KEY, + }); + + expect( + await createTranscriptFromVideo("app-1", { + videoId: "video-1", + idempotencyKey: KEY, + ...change, + }), + ).toMatchObject({ + ok: false, + status: 409, + message: `Idempotency key was already used with a different ${field}`, + }); + }); + + it("refuses a key that was used for a URL transcript, and the reverse", async () => { + seedVideo(); + const guards = { isBlocked: async () => false }; + await createTranscriptFromVideo("app-1", { + videoId: "video-1", + idempotencyKey: KEY, + }); + await createTranscriptFromUrl( + "app-1", + { url: "https://media.example.com/a.mp3", idempotencyKey: "url-key-01" }, + guards, + ); + + expect( + await createTranscriptFromUrl( + "app-1", + { url: "https://media.example.com/a.mp3", idempotencyKey: KEY }, + guards, + ), + ).toMatchObject({ ok: false, status: 409, code: "idempotency_conflict" }); + expect( + await createTranscriptFromVideo("app-1", { + videoId: "video-1", + idempotencyKey: "url-key-01", + }), + ).toMatchObject({ ok: false, status: 409, code: "idempotency_conflict" }); + expect(transcripts()).toHaveLength(2); + }); + + it("still returns the transcript after the video was renamed or deleted", async () => { + const video = seedVideo(); + const first = await createTranscriptFromVideo("app-1", { + videoId: "video-1", + idempotencyKey: KEY, + }); + video.name = "Renamed"; + video.deletedAt = new Date("2026-11-12T09:00:00.000Z"); + + const second = await createTranscriptFromVideo("app-1", { + videoId: "video-1", + idempotencyKey: KEY, + }); + + if (!first.ok || !second.ok) throw new Error("create failed"); + expect(second.transcript.id).toBe(first.transcript.id); + }); +}); + +describe("finding the stored file of a video", () => { + it("returns the MP4 of the finished encode and the known duration", async () => { + seedVideo({ duration: 95.5 }); + + expect(await findVideoForTranscript("app-1", "video-1")).toEqual({ + state: "ok", + name: "Launch walkthrough", + sourceKey: MP4_KEY, + durationSeconds: 95.5, + }); + }); + + it("reports no duration when the video has none", async () => { + seedVideo({ duration: null }); + + expect(await findVideoForTranscript("app-1", "video-1")).toMatchObject({ + state: "ok", + durationSeconds: null, + }); + }); +}); + +describe("caption files for the playback side", () => { + it("returns nothing for a video with no transcripts", async () => { + expect(await developerVideoCaptions(VIDEO)).toEqual([]); + }); + + it("returns one caption file for one completed transcript", async () => { + seedCompleted("tr-1", "hello there"); + + expect(await developerVideoCaptions(VIDEO)).toEqual([ + { language: "en", label: "English", key: EN_KEY }, + ]); + }); + + it("returns one file per language, ordered by label", async () => { + seedCompleted("tr-1", "hello there"); + seedCompleted("tr-2", "hallo zusammen", { language: "de" }); + + expect(await developerVideoCaptions(VIDEO)).toEqual([ + { language: "en", label: "English", key: EN_KEY }, + { language: "de", label: "German", key: DE_KEY }, + ]); + }); + + it("returns one entry for a language however many transcripts it has", async () => { + seedCompleted("tr-old", "older words"); + seedCompleted("tr-new", "newer words"); + seedCompleted("tr-newest", "newest words"); + + expect(await developerVideoCaptions(VIDEO)).toEqual([ + { language: "en", label: "English", key: EN_KEY }, + ]); + }); + + it("leaves out transcripts that are not finished, failed, deleted, silent or for another video or app", async () => { + seedCompleted("tr-queued", "a b", { status: "queued", language: "de" }); + seedCompleted("tr-failed", "a b", { status: "error", language: "fr" }); + seedCompleted("tr-deleted", "a b", { + language: "es", + deletedAt: new Date("2026-11-12T11:00:00.000Z"), + }); + seedCompleted("tr-silent", "", { language: "it", wordCount: 0 }); + seedCompleted("tr-other-video", "a b", { + language: "nl", + videoId: "video-2", + }); + seedCompleted("tr-other-app", "a b", { language: "pl", appId: "app-2" }); + seedCompleted("tr-not-video", "a b", { language: "ja", videoId: null }); + + expect(await developerVideoCaptions(VIDEO)).toEqual([]); + }); + + it("labels a language it has no name for and one that was not reported", async () => { + expect(captionLanguageLabel("sv")).toBe("Swedish"); + expect(captionLanguageLabel("und")).toBe("Captions"); + expect(captionLanguage(null)).toBe("und"); + expect(captionLanguage("../../etc")).toBe("und"); + expect(captionLanguage("EN")).toBe("en"); + expect(videoCaptionsKey(VIDEO, "../x")).toBe( + "developer/app-1/video-1/captions/und.vtt", + ); + seedCompleted("tr-1", "hello there", { language: null }); + expect(await developerVideoCaptions(VIDEO)).toEqual([ + { + language: "und", + label: "Captions", + key: "developer/app-1/video-1/captions/und.vtt", + }, + ]); + }); +}); + +describe("the caption file of a video", () => { + const sync = (language: string | null = "en") => + syncVideoCaptions({ appId: "app-1", videoId: "video-1", language }); + + it("is written as WebVTT from the transcript's word timings", async () => { + seedVideo(); + seedCompleted("tr-1", "hello there"); + + expect(await sync()).toEqual({ state: "written", transcriptId: "tr-1" }); + expect(context.objects.get(EN_KEY)).toBe( + "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nhello there\n", + ); + expect(context.puts).toEqual([ + { key: EN_KEY, contentType: "text/vtt; charset=utf-8" }, + ]); + }); + + it("is replaced by a newer transcript for the same video and language", async () => { + seedVideo(); + seedCompleted("tr-old", "older words"); + await sync(); + expect(context.objects.get(EN_KEY)).toContain("older words"); + + seedCompleted("tr-new", "newer words"); + expect(await sync()).toEqual({ state: "written", transcriptId: "tr-new" }); + expect(context.objects.get(EN_KEY)).toContain("newer words"); + expect(context.objects.get(EN_KEY)).not.toContain("older words"); + }); + + it("is not replaced by an older transcript that finishes late", async () => { + seedVideo(); + const old = seedCompleted("tr-old", "older words", { + status: "processing", + }); + seedCompleted("tr-new", "newer words"); + await sync(); + old.status = "complete"; + + expect(await sync()).toEqual({ state: "written", transcriptId: "tr-new" }); + expect(context.objects.get(EN_KEY)).toContain("newer words"); + }); + + it("leaves another language's file alone", async () => { + seedVideo(); + seedCompleted("tr-en", "hello there"); + seedCompleted("tr-de", "hallo zusammen", { language: "de" }); + await sync("en"); + await sync("de"); + + expect(context.objects.get(EN_KEY)).toContain("hello there"); + expect(context.objects.get(DE_KEY)).toContain("hallo zusammen"); + }); + + it("ends with the newest transcript when another one completes during the write", async () => { + seedVideo(); + seedCompleted("tr-old", "older words"); + context.afterPut = () => { + seedCompleted("tr-new", "newer words"); + }; + + expect(await sync()).toEqual({ state: "written", transcriptId: "tr-new" }); + expect(context.objects.get(EN_KEY)).toContain("newer words"); + }); + + it("is not written for a transcript with no speech", async () => { + seedVideo(); + seedCompleted("tr-1", "", { wordCount: 0 }); + + expect(await sync()).toEqual({ state: "removed" }); + expect(context.objects.has(EN_KEY)).toBe(false); + expect(context.puts).toHaveLength(0); + }); + + it("is not written once the video is deleted", async () => { + seedVideo({ deletedAt: new Date("2026-11-12T09:00:00.000Z") }); + seedCompleted("tr-1", "hello there"); + + expect(await sync()).toEqual({ state: "removed" }); + expect(context.objects.has(EN_KEY)).toBe(false); + }); + + it("is removed when its transcript is deleted", async () => { + seedVideo(); + seedCompleted("tr-1", "hello there"); + await sync(); + expect(context.objects.has(EN_KEY)).toBe(true); + + expect(await deleteTranscript("app-1", "tr-1")).toEqual({ ok: true }); + + expect(context.objects.has(EN_KEY)).toBe(false); + expect(await developerVideoCaptions(VIDEO)).toEqual([]); + }); + + it("falls back to the transcript before it when the newest one is deleted", async () => { + seedVideo(); + seedCompleted("tr-old", "older words"); + seedCompleted("tr-new", "newer words"); + await sync(); + + await deleteTranscript("app-1", "tr-new"); + + expect(context.objects.get(EN_KEY)).toContain("older words"); + expect(await developerVideoCaptions(VIDEO)).toEqual([ + { language: "en", label: "English", key: EN_KEY }, + ]); + }); + + it("keeps the file when an older transcript of the same language is deleted", async () => { + seedVideo(); + seedCompleted("tr-old", "older words"); + seedCompleted("tr-new", "newer words"); + await sync(); + + await deleteTranscript("app-1", "tr-old"); + + expect(context.objects.get(EN_KEY)).toContain("newer words"); + }); + + it("never deletes the video's own file when a video transcript is deleted", async () => { + seedVideo(); + context.objects.set(MP4_KEY, "mp4-bytes"); + const created = await createTranscriptFromVideo("app-1", { + videoId: "video-1", + }); + if (!created.ok) throw new Error("create failed"); + + await deleteTranscript("app-1", created.transcript.id); + + expect(context.objects.get(MP4_KEY)).toBe("mp4-bytes"); + }); +}); diff --git a/apps/web/app/api/developer/v1/[...route]/transcripts.ts b/apps/web/app/api/developer/v1/[...route]/transcripts.ts index 4f11cc062d9..479f5c15289 100644 --- a/apps/web/app/api/developer/v1/[...route]/transcripts.ts +++ b/apps/web/app/api/developer/v1/[...route]/transcripts.ts @@ -4,6 +4,7 @@ import { apiErrorBody } from "@/lib/api-errors"; import { parseDeveloperListQuery } from "@/lib/developer-pagination"; import { createTranscriptFromUrl, + createTranscriptFromVideo, createTranscriptUpload, deleteTranscript, getTranscript, @@ -58,16 +59,35 @@ app.post( "/", developerZValidator( "json", - z.strictObject({ url: z.string().min(1), ...optionsSchema }), + z + .strictObject({ + url: z.string().min(1).optional(), + videoId: z.string().min(1).max(64).optional(), + ...optionsSchema, + }) + .refine( + (body) => (body.url === undefined) !== (body.videoId === undefined), + { message: "Send exactly one of url or videoId" }, + ), ), async (c) => { const idempotency = readIdempotencyKey(c.req.header("idempotency-key")); if (!idempotency.ok) return c.json(invalidIdempotencyKey, 400); - const result = await createTranscriptFromUrl(c.get("developerAppId"), { - ...c.req.valid("json"), - idempotencyKey: idempotency.key, - }); + const { url, videoId, ...options } = c.req.valid("json"); + const appId = c.get("developerAppId"); + const result = + videoId !== undefined + ? await createTranscriptFromVideo(appId, { + ...options, + videoId, + idempotencyKey: idempotency.key, + }) + : await createTranscriptFromUrl(appId, { + ...options, + url: url ?? "", + idempotencyKey: idempotency.key, + }); if (!result.ok) return c.json(failureBody(result), result.status); return c.json({ data: serializeTranscript(result.transcript) }); }, diff --git a/apps/web/lib/developer-transcript-errors.ts b/apps/web/lib/developer-transcript-errors.ts index 8662965cbb1..ba80ffd9f57 100644 --- a/apps/web/lib/developer-transcript-errors.ts +++ b/apps/web/lib/developer-transcript-errors.ts @@ -18,6 +18,11 @@ export const TRANSCRIPT_REQUEST_ERRORS = [ code: "url_not_public", meaning: "The URL points to a private or local address", }, + { + status: 400, + code: "invalid_video_id", + meaning: "videoId is empty or longer than 64 characters", + }, { status: 400, code: "invalid_name", @@ -93,6 +98,17 @@ export const TRANSCRIPT_REQUEST_ERRORS = [ code: "transcript_not_found", meaning: "No transcript with this ID belongs to the app", }, + { + status: 404, + code: "video_not_found", + meaning: "No video with this ID belongs to the app, or it was deleted", + }, + { + status: 409, + code: "video_not_ready", + meaning: + "The video is not ready yet, or it failed to process and has no file to transcribe. The message says which", + }, { status: 409, code: "idempotency_conflict", @@ -129,7 +145,11 @@ export const TRANSCRIPT_FAILURE_CODES = [ "The URL, or a redirect from it, did not point to a public address", }, { code: "source_too_large", meaning: "The file is larger than 2 GB" }, - { code: "source_missing", meaning: "The uploaded file was not found" }, + { + code: "source_missing", + meaning: + "The uploaded file was not found, or the video was deleted before the transcript ran", + }, { code: "no_audio_track", meaning: "The file has no audio" }, { code: "unsupported_format", diff --git a/apps/web/lib/developer-transcript-store.ts b/apps/web/lib/developer-transcript-store.ts index 6bbb3e8c45b..4d8576b444e 100644 --- a/apps/web/lib/developer-transcript-store.ts +++ b/apps/web/lib/developer-transcript-store.ts @@ -32,6 +32,10 @@ import { transcriptObjectKeys, transcriptUploadExpiry, } from "@/lib/developer-transcripts"; +import { + findVideoForTranscript, + syncVideoCaptions, +} from "@/lib/developer-video-captions"; import { canUseInfra } from "@/lib/infra-access"; import { assertPublicMediaUrl, @@ -463,6 +467,127 @@ export async function createTranscriptFromUrl( return { ok: true, transcript }; } +const VIDEO_NOT_FOUND = fail( + 404, + "video_not_found", + "Video not found", + "Check the video ID and that it belongs to this app.", +); + +const TRANSCRIPT_MAX_VIDEO_ID_LENGTH = 64; + +export async function createTranscriptFromVideo( + appId: string, + input: TranscriptOptionsInput & { videoId: string; idempotencyKey?: string }, +): Promise<{ ok: true; transcript: TranscriptRow } | TranscriptFailure> { + const options = validateTranscriptOptions(input); + if (!options.ok) return options; + + const videoId = typeof input.videoId === "string" ? input.videoId.trim() : ""; + if (videoId.length === 0 || videoId.length > TRANSCRIPT_MAX_VIDEO_ID_LENGTH) { + return fail( + 400, + "invalid_video_id", + "The video ID is not valid", + "Send the id of a video in this app.", + ); + } + + const id = input.idempotencyKey + ? transcriptIdForIdempotencyKey(appId, input.idempotencyKey) + : nanoId(); + + const differsFromRequest = (existing: TranscriptRow) => { + if (existing.sourceType !== "video") return IDEMPOTENCY_CONFLICT; + if (existing.videoId !== videoId) return idempotencyMismatch("videoId"); + const different = replayedWithDifferent(existing, { + ...options, + name: options.name ?? existing.name, + }); + if (different) return idempotencyMismatch(different); + return existing.deletedAt ? IDEMPOTENCY_KEY_RETIRED : null; + }; + + if (input.idempotencyKey) { + const existing = await findTranscriptAnyState(appId, id); + if (existing) { + const conflict = differsFromRequest(existing); + if (conflict) return conflict; + if (existing.status === "queued") { + await dispatchTranscript(id, "queued"); + } + return { + ok: true, + transcript: (await findTranscript(appId, id)) ?? existing, + }; + } + } + + const video = await findVideoForTranscript(appId, videoId); + if (video.state === "not_found") return VIDEO_NOT_FOUND; + if (video.state === "not_ready") { + return video.status === "ready" || video.status === "errored" + ? fail( + 409, + "video_not_ready", + video.status === "errored" + ? "The video failed to process and has no file to transcribe" + : "The video has no stored file to transcribe", + "Upload the video again, then transcribe the new video.", + ) + : fail( + 409, + "video_not_ready", + "The video is not ready yet", + "Wait until the video status is ready, then send the request again.", + ); + } + + const gate = await startGate(appId); + if (gate) return gate; + + await db() + .insert(developerTranscripts) + .values({ + id, + appId, + externalUserId: options.externalUserId, + name: options.name ?? video.name, + status: "queued", + queuedAt: new Date(), + sourceType: "video", + videoId, + s3Key: video.sourceKey, + requestedLanguage: options.requestedLanguage, + metadata: options.metadata, + }) + .onDuplicateKeyUpdate({ set: { id } }); + + const stored = await findTranscriptAnyState(appId, id); + if (!stored) return NOT_FOUND; + const conflict = differsFromRequest(stored); + if (conflict) return conflict; + + const dispatched = await dispatchTranscript(id, "queued"); + if (dispatched === "failed") { + await db() + .delete(developerTranscripts) + .where( + and( + eq(developerTranscripts.id, id), + eq(developerTranscripts.appId, appId), + eq(developerTranscripts.status, "queued"), + isNull(developerTranscripts.startedAt), + ), + ); + return SPEECH_UNAVAILABLE; + } + + const transcript = await findTranscript(appId, id); + if (!transcript) return NOT_FOUND; + return { ok: true, transcript }; +} + export type TranscriptUploadTarget = { method: "PUT"; url: string; @@ -671,6 +796,7 @@ const summaryColumns = { status: developerTranscripts.status, sourceType: developerTranscripts.sourceType, sourceUrl: developerTranscripts.sourceUrl, + videoId: developerTranscripts.videoId, s3Key: developerTranscripts.s3Key, resultKey: developerTranscripts.resultKey, sourceBytes: developerTranscripts.sourceBytes, @@ -791,6 +917,17 @@ export async function deleteTranscript( ); await deleteTranscriptObjects(appId, transcript.id); + if ( + transcript.videoId && + transcript.status === "complete" && + (transcript.wordCount ?? 0) > 0 + ) { + await syncVideoCaptions({ + appId, + videoId: transcript.videoId, + language: transcript.language, + }); + } return { ok: true }; } diff --git a/apps/web/lib/developer-transcripts.ts b/apps/web/lib/developer-transcripts.ts index 60452aab3e5..0adc5bcf77b 100644 --- a/apps/web/lib/developer-transcripts.ts +++ b/apps/web/lib/developer-transcripts.ts @@ -165,7 +165,10 @@ export function serializeTranscript( source: row.sourceType === "url" ? { type: "url" as const, url: publicSourceUrl(row.sourceUrl) } - : { type: "upload" as const, url: null }, + : row.sourceType === "video" + ? { type: "video" as const, url: null } + : { type: "upload" as const, url: null }, + videoId: row.videoId ?? null, durationSeconds: row.durationSeconds, language: row.language, wordCount: row.wordCount, @@ -199,6 +202,7 @@ export function toAgentDeveloperTranscript(row: TranscriptSummaryRow) { name: row.name, sourceType: row.sourceType, sourceUrl: publicSourceUrl(row.sourceUrl), + videoId: row.videoId ?? null, durationSeconds: row.durationSeconds, language: row.language, wordCount: row.wordCount, diff --git a/apps/web/lib/developer-video-captions.ts b/apps/web/lib/developer-video-captions.ts new file mode 100644 index 00000000000..d5fc5fd0f9e --- /dev/null +++ b/apps/web/lib/developer-video-captions.ts @@ -0,0 +1,216 @@ +import { db } from "@cap/database"; +import { developerTranscripts, developerVideos } from "@cap/database/schema"; +import type { DeveloperVideoStatus } from "@cap/database/types"; +import { isLanguageCode, SUPPORTED_LANGUAGES } from "@cap/web-domain"; +import { and, desc, eq, gt, isNull } from "drizzle-orm"; +import { + deleteDeveloperObjects, + getDeveloperObjectText, + putDeveloperObject, +} from "@/lib/developer-transcript-storage"; +import { buildWebVtt } from "@/lib/developer-transcript-vtt"; +import type { TranscriptResultObject } from "@/lib/developer-transcripts"; +import { developerVideoKeys } from "@/lib/developer-video-plan"; + +export const CAPTIONS_CONTENT_TYPE = "text/vtt; charset=utf-8"; +export const CAPTIONS_UNKNOWN_LANGUAGE = "und"; + +const SYNC_ATTEMPTS = 3; +const UNKNOWN_LANGUAGE_SCAN = 50; + +export type DeveloperVideoCaption = { + language: string; + label: string; + key: string; +}; + +type VideoRef = { id: string; appId: string }; + +export function captionLanguage(language: string | null | undefined) { + const value = (language ?? "").trim().toLowerCase(); + return /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/.test(value) + ? value + : CAPTIONS_UNKNOWN_LANGUAGE; +} + +export function captionLanguageLabel(language: string) { + if (isLanguageCode(language)) return SUPPORTED_LANGUAGES[language]; + if (language === CAPTIONS_UNKNOWN_LANGUAGE) return "Captions"; + try { + return ( + new Intl.DisplayNames(["en"], { type: "language" }).of(language) ?? + language + ); + } catch { + return language; + } +} + +export function videoCaptionsKey(video: VideoRef, language: string) { + return `developer/${video.appId}/${video.id}/captions/${captionLanguage(language)}.vtt`; +} + +const captionSources = (video: VideoRef) => + and( + eq(developerTranscripts.appId, video.appId), + eq(developerTranscripts.videoId, video.id), + eq(developerTranscripts.status, "complete"), + isNull(developerTranscripts.deletedAt), + gt(developerTranscripts.wordCount, 0), + ); + +export async function developerVideoCaptions( + video: VideoRef, +): Promise { + const rows = await db() + .selectDistinct({ language: developerTranscripts.language }) + .from(developerTranscripts) + .where(captionSources(video)); + const languages = [ + ...new Set(rows.map((row) => captionLanguage(row.language))), + ]; + return languages + .map((language) => ({ + language, + label: captionLanguageLabel(language), + key: videoCaptionsKey(video, language), + })) + .sort((a, b) => + a.label === b.label + ? a.language.localeCompare(b.language) + : a.label.localeCompare(b.label), + ); +} + +async function newestCaptionSource(video: VideoRef, language: string) { + const rows = await db() + .select({ + id: developerTranscripts.id, + language: developerTranscripts.language, + resultKey: developerTranscripts.resultKey, + }) + .from(developerTranscripts) + .where( + and( + captionSources(video), + language === CAPTIONS_UNKNOWN_LANGUAGE + ? undefined + : eq(developerTranscripts.language, language), + ), + ) + .orderBy( + desc(developerTranscripts.createdAt), + desc(developerTranscripts.id), + ) + .limit(language === CAPTIONS_UNKNOWN_LANGUAGE ? UNKNOWN_LANGUAGE_SCAN : 1); + return rows.find((row) => captionLanguage(row.language) === language) ?? null; +} + +async function videoIsStored(video: VideoRef) { + const [row] = await db() + .select({ deletedAt: developerVideos.deletedAt }) + .from(developerVideos) + .where( + and( + eq(developerVideos.id, video.id), + eq(developerVideos.appId, video.appId), + ), + ) + .limit(1); + return row !== undefined && !row.deletedAt; +} + +async function captionsFor(resultKey: string | null) { + if (!resultKey) return null; + const stored = await getDeveloperObjectText(resultKey); + if (stored === null) return null; + try { + const result = JSON.parse(stored) as TranscriptResultObject; + return buildWebVtt(result.words, result.durationSeconds); + } catch { + return null; + } +} + +export async function syncVideoCaptions(input: { + appId: string; + videoId: string; + language: string | null; +}): Promise<{ state: "written"; transcriptId: string } | { state: "removed" }> { + const video = { id: input.videoId, appId: input.appId }; + const language = captionLanguage(input.language); + const key = videoCaptionsKey(video, language); + const stored = await videoIsStored(video); + + let source = stored ? await newestCaptionSource(video, language) : null; + for (let attempt = 0; source && attempt < SYNC_ATTEMPTS; attempt++) { + const captions = await captionsFor(source.resultKey); + if (captions === null) break; + await putDeveloperObject(key, captions, CAPTIONS_CONTENT_TYPE); + const latest = await newestCaptionSource(video, language); + if (latest?.id === source.id) { + return { state: "written", transcriptId: source.id }; + } + source = latest; + } + + await deleteDeveloperObjects([key]); + return { state: "removed" }; +} + +export type VideoForTranscript = + | { + state: "ok"; + name: string; + sourceKey: string; + durationSeconds: number | null; + } + | { state: "not_found" } + | { state: "not_ready"; status: DeveloperVideoStatus; hasFile: boolean }; + +export async function findVideoForTranscript( + appId: string, + videoId: string, +): Promise { + const [video] = await db() + .select({ + id: developerVideos.id, + appId: developerVideos.appId, + name: developerVideos.name, + status: developerVideos.status, + duration: developerVideos.duration, + processing: developerVideos.processing, + deletedAt: developerVideos.deletedAt, + filesDeletedAt: developerVideos.filesDeletedAt, + }) + .from(developerVideos) + .where( + and(eq(developerVideos.id, videoId), eq(developerVideos.appId, appId)), + ) + .limit(1); + if (!video || video.deletedAt) return { state: "not_found" }; + + const { processing } = video; + const mp4 = processing?.mp4; + if ( + video.status !== "ready" || + !processing || + mp4?.state !== "complete" || + video.filesDeletedAt + ) { + return { + state: "not_ready", + status: video.status, + hasFile: mp4?.state === "complete" && !video.filesDeletedAt, + }; + } + return { + state: "ok", + name: video.name, + sourceKey: developerVideoKeys(video, processing.encodeId, mp4.output).mp4, + durationSeconds: + typeof video.duration === "number" && video.duration > 0 + ? video.duration + : null, + }; +} diff --git a/apps/web/workflows/developer-transcribe.ts b/apps/web/workflows/developer-transcribe.ts index 2a0dfccfe18..deb24dcf5c9 100644 --- a/apps/web/workflows/developer-transcribe.ts +++ b/apps/web/workflows/developer-transcribe.ts @@ -56,6 +56,10 @@ import { transcriptObjectKeys, transcriptWordCount, } from "@/lib/developer-transcripts"; +import { + findVideoForTranscript, + syncVideoCaptions, +} from "@/lib/developer-video-captions"; import { urlIsSelfContainedMedia } from "@/lib/media-container"; import { measureMp3DurationSeconds } from "@/lib/mp3-duration"; import { @@ -163,6 +167,7 @@ const asFailure = (result: StepFailure | HoldWait | HoldRaise): StepFailure => { const FAILURE_MESSAGES = { source_missing: "The source file was not found", + video_missing: "The video was deleted before it could be transcribed", source_too_large: "The source file is larger than the 2 GB limit", source_unreachable: "The file at the URL could not be downloaded", source_incomplete: "The file at the URL ended before its declared length", @@ -247,6 +252,7 @@ export async function developerTranscribeWorkflow( const settlement = await settleTranscriptStep(transcriptId, transcription); await cleanupTranscriptObjects(transcriptId, settlement !== "complete"); + if (settlement === "complete") await writeVideoCaptions(transcriptId); return { success: settlement === "complete", message: @@ -273,6 +279,8 @@ async function loadTranscript(transcriptId: string) { status: developerTranscripts.status, sourceType: developerTranscripts.sourceType, sourceUrl: developerTranscripts.sourceUrl, + videoId: developerTranscripts.videoId, + s3Key: developerTranscripts.s3Key, requestedLanguage: developerTranscripts.requestedLanguage, durationSeconds: developerTranscripts.durationSeconds, reservedMicroCredits: developerTranscripts.reservedMicroCredits, @@ -321,6 +329,36 @@ async function holdForSource( return hold.state === "held" ? null : notHeld(hold, known); } +async function prepareVideoSource(transcript: { + id: string; + appId: string; + videoId: string | null; +}): Promise<{ ok: true } | StepFailure | HoldWait> { + const video = transcript.videoId + ? await findVideoForTranscript(transcript.appId, transcript.videoId) + : null; + if (video?.state !== "ok") { + return failure("source_missing", FAILURE_MESSAGES.video_missing); + } + const bytes = await developerObjectSize(video.sourceKey); + if (bytes === null || bytes === 0) { + return failure("source_missing", FAILURE_MESSAGES.video_missing); + } + const hold = await holdTranscriptCredits({ + transcriptId: transcript.id, + microCredits: + video.durationSeconds === null + ? transcriptHoldForSourceBytes(bytes) + : transcriptHoldForAudioSeconds(video.durationSeconds), + }); + if (hold.state !== "held") return notHeld(hold, null); + await db() + .update(developerTranscripts) + .set({ sourceBytes: bytes, s3Key: video.sourceKey }) + .where(eq(developerTranscripts.id, transcript.id)); + return { ok: true }; +} + async function prepareTranscriptSource( transcriptId: string, known: KnownSource | null, @@ -331,6 +369,7 @@ async function prepareTranscriptSource( if (!transcript) { return failure("source_missing", FAILURE_MESSAGES.source_missing); } + if (transcript.sourceType === "video") return prepareVideoSource(transcript); const keys = transcriptObjectKeys(transcript.appId, transcript.id); const existingSize = await developerObjectSize(keys.source); @@ -509,7 +548,14 @@ async function extractTranscriptAudio( ) { return { ok: true }; } - const sourceUrl = await developerObjectUrl(keys.source); + if (transcript.sourceType === "video" && !transcript.s3Key) { + return failure("source_missing", FAILURE_MESSAGES.video_missing); + } + const sourceUrl = await developerObjectUrl( + transcript.sourceType === "video" && transcript.s3Key + ? transcript.s3Key + : keys.source, + ); if (!(await urlIsSelfContainedMedia(sourceUrl))) { return failure("unsupported_format", FAILURE_MESSAGES.unsupported_format); } @@ -661,6 +707,34 @@ async function settleTranscriptStep( return settlement.state; } +async function writeVideoCaptions(transcriptId: string): Promise { + "use step"; + + const [row] = await db() + .select({ + appId: developerTranscripts.appId, + videoId: developerTranscripts.videoId, + status: developerTranscripts.status, + language: developerTranscripts.language, + wordCount: developerTranscripts.wordCount, + }) + .from(developerTranscripts) + .where(eq(developerTranscripts.id, transcriptId)) + .limit(1); + if ( + !row?.videoId || + row.status !== "complete" || + (row.wordCount ?? 0) === 0 + ) { + return; + } + await syncVideoCaptions({ + appId: row.appId, + videoId: row.videoId, + language: row.language, + }); +} + async function transcriptStorage(transcriptId: string) { const [row] = await db() .select({ diff --git a/packages/web-domain/src/Agent.ts b/packages/web-domain/src/Agent.ts index 5880bd03bd6..9d6cdd103f0 100644 --- a/packages/web-domain/src/Agent.ts +++ b/packages/web-domain/src/Agent.ts @@ -1168,8 +1168,9 @@ export const AgentDeveloperTranscript = Schema.Struct({ "error", ), name: Schema.String, - sourceType: Schema.Literal("upload", "url"), + sourceType: Schema.Literal("upload", "url", "video"), sourceUrl: Schema.NullOr(Schema.String), + videoId: Schema.NullOr(Schema.String), durationSeconds: Schema.NullOr(Schema.Number), language: Schema.NullOr(Schema.String), wordCount: Schema.NullOr(Schema.Number), From 7e6acbf4a04210d80672044ce0346c3cb0429654 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 19:09:53 -0500 Subject: [PATCH 05/16] test(infra): cover caption files in the video folder purge --- .../developer-video-captions-purge.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 apps/web/__tests__/unit/developer-video-captions-purge.test.ts diff --git a/apps/web/__tests__/unit/developer-video-captions-purge.test.ts b/apps/web/__tests__/unit/developer-video-captions-purge.test.ts new file mode 100644 index 00000000000..3798ec540d2 --- /dev/null +++ b/apps/web/__tests__/unit/developer-video-captions-purge.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const store = vi.hoisted(() => ({ keys: new Set() })); + +vi.mock("@cap/web-backend/src/S3Buckets/index", async () => { + const { Effect, Layer, Context, Option } = await import("effect"); + class S3Buckets extends Context.Tag("S3Buckets")() { + static Default = Layer.succeed(S3Buckets, {}); + static getBucketAccess = () => + Effect.succeed([ + { + listObjects: ({ prefix }: { prefix: string }) => + Effect.succeed({ + Contents: [...store.keys] + .filter((key) => key.startsWith(prefix)) + .map((Key) => ({ Key })), + }), + deleteObjects: (objects: { Key: string }[]) => { + for (const object of objects) store.keys.delete(object.Key); + return Effect.succeed(Option.some({})); + }, + }, + ]); + } + return { S3Buckets }; +}); +vi.mock("@cap/database", () => ({ db: () => ({}) })); + +const { deleteDeveloperVideoObjects, developerVideoPrefix } = await import( + "@/lib/developer-video-storage" +); +const { videoCaptionsKey } = await import("@/lib/developer-video-captions"); + +const VIDEO = { id: "video-1", appId: "app-1" }; +const OTHER = { id: "video-2", appId: "app-1" }; +const ENCODE = "developer/app-1/video-1/encode-1/"; + +beforeEach(() => { + store.keys = new Set([ + "developer/app-1/video-1/video", + `${ENCODE}out-1/video.mp4`, + `${ENCODE}out-1/hls/master.m3u8`, + `${ENCODE}out-0/hls/master.m3u8`, + videoCaptionsKey(VIDEO, "en"), + videoCaptionsKey(VIDEO, "de"), + videoCaptionsKey(OTHER, "en"), + "developer/app-1/transcripts/tr-1/result.json", + ]); +}); + +describe("caption files and the video file purge", () => { + it("keeps caption files inside the folder the purge deletes", () => { + expect(videoCaptionsKey(VIDEO, "en")).toBe( + `${developerVideoPrefix(VIDEO)}captions/en.vtt`, + ); + }); + + it("removes a deleted video's caption files with the rest of its folder", async () => { + const removed = await deleteDeveloperVideoObjects( + developerVideoPrefix(VIDEO), + ); + + expect(removed).toBe(6); + expect([...store.keys].sort()).toEqual([ + "developer/app-1/transcripts/tr-1/result.json", + videoCaptionsKey(OTHER, "en"), + ]); + }); + + it("leaves caption files alone when an encode's unused outputs are cleaned up", async () => { + await deleteDeveloperVideoObjects(ENCODE, (key) => + key.startsWith(`${ENCODE}out-1/`), + ); + + expect(store.keys.has(`${ENCODE}out-0/hls/master.m3u8`)).toBe(false); + expect(store.keys.has(videoCaptionsKey(VIDEO, "en"))).toBe(true); + expect(store.keys.has(videoCaptionsKey(VIDEO, "de"))).toBe(true); + }); +}); From 7eff65a852df883fa4254941fdd918ca62cf9171 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 19:22:11 -0500 Subject: [PATCH 06/16] fix(infra): treat a language code with a region as one caption language --- .../unit/developer-video-captions.test.ts | 36 +++++++++++++++++++ apps/web/lib/developer-video-captions.ts | 32 +++++++++++------ 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/apps/web/__tests__/unit/developer-video-captions.test.ts b/apps/web/__tests__/unit/developer-video-captions.test.ts index 690ab571b54..f331ae13f24 100644 --- a/apps/web/__tests__/unit/developer-video-captions.test.ts +++ b/apps/web/__tests__/unit/developer-video-captions.test.ts @@ -633,6 +633,42 @@ describe("the caption file of a video", () => { expect(context.objects.get(EN_KEY)).toContain("newer words"); }); + it("treats a stored code with a region as one language in the list and the file", async () => { + seedVideo(); + seedCompleted("tr-old", "older words", { language: "en_us" }); + seedCompleted("tr-new", "newer words", { language: "en-US" }); + const key = "developer/app-1/video-1/captions/en-us.vtt"; + + expect(await sync("en_us")).toEqual({ + state: "written", + transcriptId: "tr-new", + }); + expect(context.objects.get(key)).toContain("newer words"); + expect(await developerVideoCaptions(VIDEO)).toEqual([ + { language: "en-us", label: "American English", key }, + ]); + + await deleteTranscript("app-1", "tr-new"); + expect(context.objects.get(key)).toContain("older words"); + await deleteTranscript("app-1", "tr-old"); + expect(context.objects.has(key)).toBe(false); + }); + + it("writes a file for a transcript whose language was not reported", async () => { + seedVideo(); + seedCompleted("tr-1", "hello there", { language: null }); + seedCompleted("tr-en", "english words"); + + expect(await sync(null)).toEqual({ + state: "written", + transcriptId: "tr-1", + }); + expect( + context.objects.get("developer/app-1/video-1/captions/und.vtt"), + ).toContain("hello there"); + expect(context.objects.has(EN_KEY)).toBe(false); + }); + it("leaves another language's file alone", async () => { seedVideo(); seedCompleted("tr-en", "hello there"); diff --git a/apps/web/lib/developer-video-captions.ts b/apps/web/lib/developer-video-captions.ts index d5fc5fd0f9e..1926cd951c7 100644 --- a/apps/web/lib/developer-video-captions.ts +++ b/apps/web/lib/developer-video-captions.ts @@ -2,7 +2,7 @@ import { db } from "@cap/database"; import { developerTranscripts, developerVideos } from "@cap/database/schema"; import type { DeveloperVideoStatus } from "@cap/database/types"; import { isLanguageCode, SUPPORTED_LANGUAGES } from "@cap/web-domain"; -import { and, desc, eq, gt, isNull } from "drizzle-orm"; +import { and, desc, eq, gt, inArray, isNull, or } from "drizzle-orm"; import { deleteDeveloperObjects, getDeveloperObjectText, @@ -16,7 +16,6 @@ export const CAPTIONS_CONTENT_TYPE = "text/vtt; charset=utf-8"; export const CAPTIONS_UNKNOWN_LANGUAGE = "und"; const SYNC_ATTEMPTS = 3; -const UNKNOWN_LANGUAGE_SCAN = 50; export type DeveloperVideoCaption = { language: string; @@ -27,7 +26,7 @@ export type DeveloperVideoCaption = { type VideoRef = { id: string; appId: string }; export function captionLanguage(language: string | null | undefined) { - const value = (language ?? "").trim().toLowerCase(); + const value = (language ?? "").trim().toLowerCase().replaceAll("_", "-"); return /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/.test(value) ? value : CAPTIONS_UNKNOWN_LANGUAGE; @@ -83,27 +82,40 @@ export async function developerVideoCaptions( } async function newestCaptionSource(video: VideoRef, language: string) { - const rows = await db() + const stored = await db() + .selectDistinct({ language: developerTranscripts.language }) + .from(developerTranscripts) + .where(captionSources(video)); + const matching = stored + .map((row) => row.language) + .filter((value) => captionLanguage(value) === language); + if (matching.length === 0) return null; + const named = matching.filter((value): value is string => value !== null); + const [row] = await db() .select({ id: developerTranscripts.id, - language: developerTranscripts.language, resultKey: developerTranscripts.resultKey, }) .from(developerTranscripts) .where( and( captionSources(video), - language === CAPTIONS_UNKNOWN_LANGUAGE - ? undefined - : eq(developerTranscripts.language, language), + or( + named.length > 0 + ? inArray(developerTranscripts.language, named) + : undefined, + named.length < matching.length + ? isNull(developerTranscripts.language) + : undefined, + ), ), ) .orderBy( desc(developerTranscripts.createdAt), desc(developerTranscripts.id), ) - .limit(language === CAPTIONS_UNKNOWN_LANGUAGE ? UNKNOWN_LANGUAGE_SCAN : 1); - return rows.find((row) => captionLanguage(row.language) === language) ?? null; + .limit(1); + return row ?? null; } async function videoIsStored(video: VideoRef) { From 9cbcb30bef5a88b219f9f35463b5a4aab854c0b4 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 20:47:05 -0500 Subject: [PATCH 07/16] fix(infra): keep caption syncs converging when a removal races a write --- apps/web/lib/developer-transcript-storage.ts | 6 +++ apps/web/lib/developer-video-captions.ts | 40 ++++++++++++-------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/apps/web/lib/developer-transcript-storage.ts b/apps/web/lib/developer-transcript-storage.ts index cc9192e6a92..6757bd91f9e 100644 --- a/apps/web/lib/developer-transcript-storage.ts +++ b/apps/web/lib/developer-transcript-storage.ts @@ -147,6 +147,12 @@ export async function deleteDeveloperObjects(keys: readonly string[]) { ); } +export async function removeDeveloperObject(key: string) { + await run( + defaultBucket.pipe(Effect.flatMap((bucket) => bucket.deleteObject(key))), + ); +} + export function copyDeveloperObject(fromKey: string, toKey: string) { return run( defaultBucket.pipe( diff --git a/apps/web/lib/developer-video-captions.ts b/apps/web/lib/developer-video-captions.ts index 1926cd951c7..b714c79d5ca 100644 --- a/apps/web/lib/developer-video-captions.ts +++ b/apps/web/lib/developer-video-captions.ts @@ -4,9 +4,9 @@ import type { DeveloperVideoStatus } from "@cap/database/types"; import { isLanguageCode, SUPPORTED_LANGUAGES } from "@cap/web-domain"; import { and, desc, eq, gt, inArray, isNull, or } from "drizzle-orm"; import { - deleteDeveloperObjects, getDeveloperObjectText, putDeveloperObject, + removeDeveloperObject, } from "@/lib/developer-transcript-storage"; import { buildWebVtt } from "@/lib/developer-transcript-vtt"; import type { TranscriptResultObject } from "@/lib/developer-transcripts"; @@ -15,7 +15,7 @@ import { developerVideoKeys } from "@/lib/developer-video-plan"; export const CAPTIONS_CONTENT_TYPE = "text/vtt; charset=utf-8"; export const CAPTIONS_UNKNOWN_LANGUAGE = "und"; -const SYNC_ATTEMPTS = 3; +const SYNC_ATTEMPTS = 5; export type DeveloperVideoCaption = { language: string; @@ -152,22 +152,30 @@ export async function syncVideoCaptions(input: { const video = { id: input.videoId, appId: input.appId }; const language = captionLanguage(input.language); const key = videoCaptionsKey(video, language); - const stored = await videoIsStored(video); - - let source = stored ? await newestCaptionSource(video, language) : null; - for (let attempt = 0; source && attempt < SYNC_ATTEMPTS; attempt++) { - const captions = await captionsFor(source.resultKey); - if (captions === null) break; - await putDeveloperObject(key, captions, CAPTIONS_CONTENT_TYPE); - const latest = await newestCaptionSource(video, language); - if (latest?.id === source.id) { - return { state: "written", transcriptId: source.id }; + + const currentSource = async () => + (await videoIsStored(video)) + ? await newestCaptionSource(video, language) + : null; + + for (let attempt = 0; attempt < SYNC_ATTEMPTS; attempt++) { + const source = await currentSource(); + const captions = source ? await captionsFor(source.resultKey) : null; + if (source && captions !== null) { + await putDeveloperObject(key, captions, CAPTIONS_CONTENT_TYPE); + } else { + await removeDeveloperObject(key); + } + const latest = await currentSource(); + if ((latest?.id ?? null) === (source?.id ?? null)) { + return source && captions !== null + ? { state: "written", transcriptId: source.id } + : { state: "removed" }; } - source = latest; } - - await deleteDeveloperObjects([key]); - return { state: "removed" }; + throw new Error( + `Captions for video ${video.id} kept changing while they were written`, + ); } export type VideoForTranscript = From 9c6e1a3a90064e4bd5631b930b1353c0b832f9d4 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 20:47:05 -0500 Subject: [PATCH 08/16] fix(infra): update captions from every transcript delete path and on a retried delete --- apps/web/lib/developer-transcript-store.ts | 94 +++++++++++++++++++--- 1 file changed, 83 insertions(+), 11 deletions(-) diff --git a/apps/web/lib/developer-transcript-store.ts b/apps/web/lib/developer-transcript-store.ts index 76c2cd5e80f..b0e73551fa8 100644 --- a/apps/web/lib/developer-transcript-store.ts +++ b/apps/web/lib/developer-transcript-store.ts @@ -7,7 +7,19 @@ import { users, } from "@cap/database/schema"; import { AI_GENERATION_LANGUAGE_CODES } from "@cap/web-domain"; -import { and, count, desc, eq, gte, isNull, lt, or, sql } from "drizzle-orm"; +import { + and, + count, + desc, + eq, + gt, + gte, + isNotNull, + isNull, + lt, + or, + sql, +} from "drizzle-orm"; import { start } from "workflow/api"; import { developerIdFromKey } from "@/lib/developer-ids"; import { transcriptStartRefusal } from "@/lib/developer-transcript-billing"; @@ -901,7 +913,10 @@ export async function deleteTranscript( transcriptId: string, ): Promise<{ ok: true } | TranscriptFailure> { const transcript = await findTranscript(appId, transcriptId); - if (!transcript) return NOT_FOUND; + if (!transcript) { + await syncDeletedTranscriptCaptions(appId, transcriptId); + return NOT_FOUND; + } await db() .update(developerTranscripts) @@ -914,18 +929,27 @@ export async function deleteTranscript( ); await deleteTranscriptObjects(appId, transcript.id); + return { ok: true }; +} + +async function syncDeletedTranscriptCaptions( + appId: string, + transcriptId: string, +) { + const transcript = await findTranscriptAnyState(appId, transcriptId); if ( - transcript.videoId && - transcript.status === "complete" && - (transcript.wordCount ?? 0) > 0 + !transcript?.deletedAt || + !transcript.videoId || + transcript.status !== "complete" || + (transcript.wordCount ?? 0) === 0 ) { - await syncVideoCaptions({ - appId, - videoId: transcript.videoId, - language: transcript.language, - }); + return; } - return { ok: true }; + await syncVideoCaptions({ + appId, + videoId: transcript.videoId, + language: transcript.language, + }); } export async function deleteTranscriptObjects( @@ -934,6 +958,54 @@ export async function deleteTranscriptObjects( ) { const keys = transcriptObjectKeys(appId, transcriptId); await deleteDeveloperObjects([keys.source, keys.audio, keys.result]); + await syncDeletedTranscriptCaptions(appId, transcriptId); +} + +const CAPTION_RECONCILE_WINDOW_MS = 3 * 60 * 60 * 1000; +const CAPTION_RECONCILE_LIMIT = 200; + +export async function reconcileDeletedVideoCaptions(now = new Date()) { + const rows = await db() + .selectDistinct({ + appId: developerTranscripts.appId, + videoId: developerTranscripts.videoId, + language: developerTranscripts.language, + }) + .from(developerTranscripts) + .where( + and( + eq(developerTranscripts.status, "complete"), + gte( + developerTranscripts.updatedAt, + new Date(now.getTime() - CAPTION_RECONCILE_WINDOW_MS), + ), + isNotNull(developerTranscripts.deletedAt), + isNotNull(developerTranscripts.videoId), + gt(developerTranscripts.wordCount, 0), + ), + ) + .limit(CAPTION_RECONCILE_LIMIT); + + let synced = 0; + let failed = 0; + for (const row of rows) { + if (!row.videoId) continue; + try { + await syncVideoCaptions({ + appId: row.appId, + videoId: row.videoId, + language: row.language, + }); + synced++; + } catch (error) { + failed++; + console.error("[developer-transcripts] Caption reconcile failed", { + videoId: row.videoId, + error, + }); + } + } + return { synced, failed }; } export async function expireStaleTranscripts(now = new Date()) { From cd8281b8b101b178ae29048371df1b718f692ab2 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 20:47:05 -0500 Subject: [PATCH 09/16] fix(infra): repair captions of recently deleted video transcripts hourly --- .../cron/developer-transcripts-cleanup/route.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/web/app/api/cron/developer-transcripts-cleanup/route.ts b/apps/web/app/api/cron/developer-transcripts-cleanup/route.ts index 1ce2e680486..5b57063d154 100644 --- a/apps/web/app/api/cron/developer-transcripts-cleanup/route.ts +++ b/apps/web/app/api/cron/developer-transcripts-cleanup/route.ts @@ -9,7 +9,10 @@ import { } from "@effect/platform"; import { Effect, Layer, Schema } from "effect"; import { sweepStaleDeveloperUploads } from "@/lib/developer-transcript-storage"; -import { expireStaleTranscripts } from "@/lib/developer-transcript-store"; +import { + expireStaleTranscripts, + reconcileDeletedVideoCaptions, +} from "@/lib/developer-transcript-store"; import { apiToHandler } from "@/lib/server"; class Api extends HttpApi.make("DeveloperTranscriptsCleanupApi").add( @@ -20,6 +23,8 @@ class Api extends HttpApi.make("DeveloperTranscriptsCleanupApi").add( expired: Schema.Number, redispatched: Schema.Number, swept: Schema.Number, + captionsSynced: Schema.Number, + captionsFailed: Schema.Number, }), ) .addError(HttpApiError.Unauthorized) @@ -46,7 +51,14 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( return yield* Effect.tryPromise(async () => { const { expired, redispatched } = await expireStaleTranscripts(); const { deleted } = await sweepStaleDeveloperUploads(new Date()); - return { expired, redispatched, swept: deleted }; + const captions = await reconcileDeletedVideoCaptions(); + return { + expired, + redispatched, + swept: deleted, + captionsSynced: captions.synced, + captionsFailed: captions.failed, + }; }).pipe( Effect.tapError((error) => Effect.sync(() => From 9b62355bad82407a6a8bb7a94eee0abf92438bd7 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 20:47:05 -0500 Subject: [PATCH 10/16] test(infra): cover caption races, delete paths and the hourly repair --- .../developer-transcripts-database.test.ts | 31 +++ .../unit/developer-transcribe-video.test.ts | 3 + .../unit/developer-video-captions.test.ts | 208 +++++++++++++++++- 3 files changed, 241 insertions(+), 1 deletion(-) diff --git a/apps/web/__tests__/integration/developer-transcripts-database.test.ts b/apps/web/__tests__/integration/developer-transcripts-database.test.ts index 66afe23b0dd..32b989dce3c 100644 --- a/apps/web/__tests__/integration/developer-transcripts-database.test.ts +++ b/apps/web/__tests__/integration/developer-transcripts-database.test.ts @@ -62,6 +62,9 @@ vi.mock("@/lib/developer-transcript-storage", () => ({ putDeveloperObject: async (key: string, body: string) => { fixture.objects.set(key, body); }, + removeDeveloperObject: async (key: string) => { + fixture.objects.delete(key); + }, deleteDeveloperObjects: async (keys: string[]) => { for (const key of keys) fixture.objects.delete(key); }, @@ -105,6 +108,7 @@ const { developerVideoCaptions, syncVideoCaptions, videoCaptionsKey } = await import("@/lib/developer-video-captions"); const { serializeTranscript } = await import("@/lib/developer-transcripts"); const { + reconcileDeletedVideoCaptions, createTranscriptFromUrl, createTranscriptFromVideo, createTranscriptUpload, @@ -1446,4 +1450,31 @@ describe.skipIf(!databaseUrl)("developer transcripts database", () => { } expect(first).not.toBe(second); }); + + it("repairs captions left by a failed delete from the hourly cleanup, reading through the status index", async () => { + const appId = await makeApp(50_000); + const videoId = await makeReadyVideo(appId); + const key = videoCaptionsKey({ id: videoId, appId }, "en"); + await makeCaptionSource(appId, videoId, "older words", { + createdAt: new Date("2026-11-12T10:00:00.000Z"), + }); + const newer = await makeCaptionSource(appId, videoId, "newer words", { + createdAt: new Date("2026-11-12T10:00:05.000Z"), + }); + await syncVideoCaptions({ appId, videoId, language: "en" }); + await database() + .update(developerTranscripts) + .set({ deletedAt: new Date() }) + .where(eq(developerTranscripts.id, newer)); + expect(fixture.objects.get(key)).toContain("newer words"); + + fixture.queries = []; + const result = await reconcileDeletedVideoCaptions(); + + expect(result.failed).toBe(0); + expect(result.synced).toBeGreaterThanOrEqual(1); + expect(fixture.objects.get(key)).toContain("older words"); + const plan = await explainLastQuery("`videoId` is not null"); + expect(plan[0]?.key).toBe("dev_transcripts_status_updated_idx"); + }); }); diff --git a/apps/web/__tests__/unit/developer-transcribe-video.test.ts b/apps/web/__tests__/unit/developer-transcribe-video.test.ts index 43a8bf6a448..b65db219cad 100644 --- a/apps/web/__tests__/unit/developer-transcribe-video.test.ts +++ b/apps/web/__tests__/unit/developer-transcribe-video.test.ts @@ -97,6 +97,9 @@ vi.mock("@/lib/developer-transcript-storage", () => ({ putDeveloperObjectStream: async () => { throw new Error("A video transcript must not copy its source"); }, + removeDeveloperObject: async (key: string) => { + context.objects.delete(key); + }, deleteDeveloperObjects: async (keys: string[]) => { for (const key of keys) context.objects.delete(key); }, diff --git a/apps/web/__tests__/unit/developer-video-captions.test.ts b/apps/web/__tests__/unit/developer-video-captions.test.ts index f331ae13f24..f3798bb9cfe 100644 --- a/apps/web/__tests__/unit/developer-video-captions.test.ts +++ b/apps/web/__tests__/unit/developer-video-captions.test.ts @@ -15,6 +15,9 @@ const context = vi.hoisted(() => ({ objects: new Map(), puts: [] as { key: string; contentType: string }[], afterPut: null as null | (() => void), + afterDelete: null as null | (() => void), + failPuts: 0, + stuckKeys: new Set(), })); vi.mock("@cap/database", async () => { @@ -105,6 +108,10 @@ vi.mock("@/lib/developer-transcript-storage", () => ({ body: string, contentType: string, ) => { + if (context.failPuts > 0) { + context.failPuts -= 1; + throw new Error("storage is unavailable"); + } context.objects.set(key, body); context.puts.push({ key, contentType }); const hook = context.afterPut; @@ -112,7 +119,17 @@ vi.mock("@/lib/developer-transcript-storage", () => ({ hook?.(); }, deleteDeveloperObjects: async (keys: string[]) => { - for (const key of keys) context.objects.delete(key); + for (const key of keys) { + if (!context.stuckKeys.has(key)) context.objects.delete(key); + } + }, + removeDeveloperObject: async (key: string) => { + if (context.stuckKeys.has(key)) + throw new Error("storage refused the delete"); + context.objects.delete(key); + const hook = context.afterDelete; + context.afterDelete = null; + hook?.(); }, })); @@ -127,7 +144,9 @@ import { createTranscriptFromUrl, createTranscriptFromVideo, deleteTranscript, + deleteTranscriptObjects, listTranscripts, + reconcileDeletedVideoCaptions, } from "@/lib/developer-transcript-store"; import { serializeTranscript, @@ -214,6 +233,9 @@ beforeEach(() => { context.objects = new Map(); context.puts = []; context.afterPut = null; + context.afterDelete = null; + context.failPuts = 0; + context.stuckKeys = new Set(); fake().seed(users, { id: "owner-1", email: "owner@example.com" }); for (const id of ["app-1", "app-2"]) { fake().seed(developerApps, { id, ownerId: "owner-1", deletedAt: null }); @@ -757,4 +779,188 @@ describe("the caption file of a video", () => { expect(context.objects.get(MP4_KEY)).toBe("mp4-bytes"); }); + + it("keeps a file another transcript wrote while the last one was being removed", async () => { + seedVideo(); + seedCompleted("tr-old", "older words"); + await sync(); + const old = transcripts().find((row) => row.id === "tr-old"); + if (old) old.deletedAt = new Date("2026-11-12T11:00:00.000Z"); + context.afterDelete = () => { + seedCompleted("tr-new", "newer words"); + context.objects.set(EN_KEY, "WEBVTT\n\nwritten by the other run\n"); + }; + + expect(await sync()).toEqual({ state: "written", transcriptId: "tr-new" }); + expect(context.objects.get(EN_KEY)).toContain("newer words"); + }); + + it("fails when the file cannot be removed, so deleted words are never left behind silently", async () => { + seedVideo(); + seedCompleted("tr-1", "hello there"); + await sync(); + context.stuckKeys.add(EN_KEY); + + await expect(deleteTranscript("app-1", "tr-1")).rejects.toThrow( + "storage refused the delete", + ); + context.stuckKeys.delete(EN_KEY); + expect(await deleteTranscript("app-1", "tr-1")).toMatchObject({ + status: 404, + }); + expect(context.objects.has(EN_KEY)).toBe(false); + }); + + it("finishes the caption cleanup when a failed delete is sent again", async () => { + seedVideo(); + seedCompleted("tr-old", "older words"); + seedCompleted("tr-new", "newer words"); + await sync(); + context.failPuts = 1; + + await expect(deleteTranscript("app-1", "tr-new")).rejects.toThrow( + "storage is unavailable", + ); + expect(context.objects.get(EN_KEY)).toContain("newer words"); + + expect(await deleteTranscript("app-1", "tr-new")).toMatchObject({ + ok: false, + status: 404, + }); + expect(context.objects.get(EN_KEY)).toContain("older words"); + expect(context.objects.get(EN_KEY)).not.toContain("newer words"); + }); + + it("answers not found without touching captions for a transcript that never existed", async () => { + seedVideo(); + seedCompleted("tr-1", "hello there"); + await sync(); + context.puts = []; + + expect(await deleteTranscript("app-1", "tr-unknown")).toMatchObject({ + ok: false, + status: 404, + }); + expect(context.puts).toHaveLength(0); + expect(context.objects.get(EN_KEY)).toContain("hello there"); + }); + + it("updates the captions when a transcript is deleted outside the store, as the agent API does", async () => { + seedVideo(); + seedCompleted("tr-old", "older words"); + const newest = seedCompleted("tr-new", "newer words"); + await sync(); + + newest.deletedAt = new Date("2026-11-12T11:00:00.000Z"); + newest.text = null; + await deleteTranscriptObjects("app-1", "tr-new"); + + expect(context.objects.get(EN_KEY)).toContain("older words"); + + const old = transcripts().find((row) => row.id === "tr-old"); + if (old) old.deletedAt = new Date("2026-11-12T11:05:00.000Z"); + await deleteTranscriptObjects("app-1", "tr-old"); + + expect(context.objects.has(EN_KEY)).toBe(false); + }); + + it("removes the captions of a transcript that completed between the delete's read and its update", async () => { + seedVideo(); + const row = seedCompleted("tr-1", "hello there", { + status: "processing", + language: null, + wordCount: null, + text: null, + }); + const database = fake().db as { + update: (table: object) => unknown; + }; + const update = database.update; + let completed = false; + database.update = (table: object) => { + if (!completed) { + completed = true; + row.status = "complete"; + row.language = "en"; + row.wordCount = 2; + context.objects.set(EN_KEY, "WEBVTT\n\nwritten at completion\n"); + } + return update(table); + }; + + try { + expect(await deleteTranscript("app-1", "tr-1")).toEqual({ ok: true }); + } finally { + database.update = update; + } + + expect(completed).toBe(true); + expect(context.objects.has(EN_KEY)).toBe(false); + expect(await developerVideoCaptions(VIDEO)).toEqual([]); + }); + + it("is repaired by the hourly cleanup when a delete failed and was never sent again", async () => { + seedVideo(); + seedCompleted("tr-old", "older words"); + seedCompleted("tr-new", "newer words"); + await sync(); + context.failPuts = 1; + await expect(deleteTranscript("app-1", "tr-new")).rejects.toThrow(); + expect(context.objects.get(EN_KEY)).toContain("newer words"); + + const deleted = transcripts().find((row) => row.id === "tr-new"); + expect( + await reconcileDeletedVideoCaptions(deleted?.updatedAt as Date), + ).toEqual({ synced: 1, failed: 0 }); + expect(context.objects.get(EN_KEY)).toContain("older words"); + }); + + it("only revisits video transcripts deleted in the last three hours", async () => { + seedVideo(); + const now = new Date("2026-11-12T12:00:00.000Z"); + seedCompleted("tr-recent", "recent words", { + deletedAt: new Date("2026-11-12T11:00:00.000Z"), + updatedAt: new Date("2026-11-12T11:00:00.000Z"), + }); + seedCompleted("tr-old", "old words", { + language: "de", + deletedAt: new Date("2026-11-12T08:00:00.000Z"), + updatedAt: new Date("2026-11-12T08:00:00.000Z"), + }); + seedCompleted("tr-live", "live words", { + language: "fr", + updatedAt: new Date("2026-11-12T11:30:00.000Z"), + }); + seedCompleted("tr-not-video", "other words", { + language: "es", + videoId: null, + deletedAt: new Date("2026-11-12T11:00:00.000Z"), + updatedAt: new Date("2026-11-12T11:00:00.000Z"), + }); + context.objects.set(EN_KEY, "WEBVTT\n\nstale\n"); + context.objects.set(DE_KEY, "WEBVTT\n\nstale\n"); + + expect(await reconcileDeletedVideoCaptions(now)).toEqual({ + synced: 1, + failed: 0, + }); + expect(context.objects.has(EN_KEY)).toBe(false); + expect(context.objects.has(DE_KEY)).toBe(true); + }); + + it("keeps going when one video cannot be repaired", async () => { + seedVideo(); + seedVideo({ id: "video-2" }); + seedCompleted("tr-1", "hello there", { deletedAt: new Date() }); + seedCompleted("tr-2", "other video", { + videoId: "video-2", + deletedAt: new Date(), + }); + context.stuckKeys.add(EN_KEY); + const latest = transcripts().find((row) => row.id === "tr-2"); + + expect( + await reconcileDeletedVideoCaptions(latest?.updatedAt as Date), + ).toEqual({ synced: 1, failed: 1 }); + }); }); From 148f046ba730f559b7d56d0eddf0511bda727c58 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 21:05:13 -0500 Subject: [PATCH 11/16] feat(infra): record when a video transcript was requested, to the millisecond --- .../migrations/0056_developer_transcript_video.sql | 3 ++- packages/database/migrations/meta/0056_snapshot.json | 11 +++++++++-- packages/database/migrations/meta/_journal.json | 2 +- packages/database/schema.ts | 3 ++- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/database/migrations/0056_developer_transcript_video.sql b/packages/database/migrations/0056_developer_transcript_video.sql index 6821d00c15c..72fa9c2abd0 100644 --- a/packages/database/migrations/0056_developer_transcript_video.sql +++ b/packages/database/migrations/0056_developer_transcript_video.sql @@ -1,2 +1,3 @@ ALTER TABLE `developer_transcripts` ADD `videoId` varchar(15);--> statement-breakpoint -CREATE INDEX `dev_transcripts_app_video_idx` ON `developer_transcripts` (`appId`,`videoId`,`createdAt`,`id`); \ No newline at end of file +ALTER TABLE `developer_transcripts` ADD `requestedAt` timestamp(3);--> statement-breakpoint +CREATE INDEX `dev_transcripts_app_video_idx` ON `developer_transcripts` (`appId`,`videoId`,`requestedAt`,`id`); \ No newline at end of file diff --git a/packages/database/migrations/meta/0056_snapshot.json b/packages/database/migrations/meta/0056_snapshot.json index a0d39ce1535..6eecf9722cb 100644 --- a/packages/database/migrations/meta/0056_snapshot.json +++ b/packages/database/migrations/meta/0056_snapshot.json @@ -1,7 +1,7 @@ { "version": "5", "dialect": "mysql", - "id": "e34c4bbb-bbe7-4786-ab07-0a9653f6fa17", + "id": "c0df94ec-9eca-475a-8bc2-e4b6fac32aea", "prevId": "2773f4a4-e037-4a2a-9537-3e419186570c", "tables": { "accounts": { @@ -1345,6 +1345,13 @@ "notNull": false, "autoincrement": false }, + "requestedAt": { + "name": "requestedAt", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, "s3Key": { "name": "s3Key", "type": "varchar(512)", @@ -1514,7 +1521,7 @@ }, "dev_transcripts_app_video_idx": { "name": "dev_transcripts_app_video_idx", - "columns": ["appId", "videoId", "createdAt", "id"], + "columns": ["appId", "videoId", "requestedAt", "id"], "isUnique": false } }, diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index f9aea0c37ca..b3ef124b4da 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -397,7 +397,7 @@ { "idx": 56, "version": "5", - "when": 1794526625514, + "when": 1794535179734, "tag": "0056_developer_transcript_video", "breakpoints": true } diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 849461a2aeb..d5ed3978a92 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -1951,6 +1951,7 @@ export const developerTranscripts = mysqlTable( .$type(), sourceUrl: varchar("sourceUrl", { length: 2048 }), videoId: nanoIdNullable("videoId"), + requestedAt: timestamp("requestedAt", { fsp: 3 }), s3Key: varchar("s3Key", { length: 512 }), resultKey: varchar("resultKey", { length: 512 }), sourceBytes: bigint("sourceBytes", { mode: "number", unsigned: true }), @@ -2006,7 +2007,7 @@ export const developerTranscripts = mysqlTable( index("dev_transcripts_app_video_idx").on( table.appId, table.videoId, - table.createdAt, + table.requestedAt, table.id, ), ], From 2e0dff21e7e9717b8db4cc23a706d574c7ef1715 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 21:05:13 -0500 Subject: [PATCH 12/16] fix(infra): pick the newest caption source by request time --- apps/web/lib/developer-video-captions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/lib/developer-video-captions.ts b/apps/web/lib/developer-video-captions.ts index b714c79d5ca..6118a6b5e9f 100644 --- a/apps/web/lib/developer-video-captions.ts +++ b/apps/web/lib/developer-video-captions.ts @@ -111,7 +111,7 @@ async function newestCaptionSource(video: VideoRef, language: string) { ), ) .orderBy( - desc(developerTranscripts.createdAt), + desc(developerTranscripts.requestedAt), desc(developerTranscripts.id), ) .limit(1); From d5a8066e5dac1c0496072fae4102b53eb5d9be27 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 21:05:13 -0500 Subject: [PATCH 13/16] fix(infra): page the caption repair through every recent delete --- apps/web/lib/developer-transcript-store.ts | 113 ++++++++++++++------- 1 file changed, 74 insertions(+), 39 deletions(-) diff --git a/apps/web/lib/developer-transcript-store.ts b/apps/web/lib/developer-transcript-store.ts index b0e73551fa8..887b0f22a3e 100644 --- a/apps/web/lib/developer-transcript-store.ts +++ b/apps/web/lib/developer-transcript-store.ts @@ -9,6 +9,7 @@ import { import { AI_GENERATION_LANGUAGE_CODES } from "@cap/web-domain"; import { and, + asc, count, desc, eq, @@ -45,6 +46,7 @@ import { transcriptUploadExpiry, } from "@/lib/developer-transcripts"; import { + captionLanguage, findVideoForTranscript, syncVideoCaptions, } from "@/lib/developer-video-captions"; @@ -566,6 +568,7 @@ export async function createTranscriptFromVideo( queuedAt: new Date(), sourceType: "video", videoId, + requestedAt: new Date(), s3Key: video.sourceKey, requestedLanguage: options.requestedLanguage, metadata: options.metadata, @@ -962,50 +965,82 @@ export async function deleteTranscriptObjects( } const CAPTION_RECONCILE_WINDOW_MS = 3 * 60 * 60 * 1000; -const CAPTION_RECONCILE_LIMIT = 200; - -export async function reconcileDeletedVideoCaptions(now = new Date()) { - const rows = await db() - .selectDistinct({ - appId: developerTranscripts.appId, - videoId: developerTranscripts.videoId, - language: developerTranscripts.language, - }) - .from(developerTranscripts) - .where( - and( - eq(developerTranscripts.status, "complete"), - gte( - developerTranscripts.updatedAt, - new Date(now.getTime() - CAPTION_RECONCILE_WINDOW_MS), - ), - isNotNull(developerTranscripts.deletedAt), - isNotNull(developerTranscripts.videoId), - gt(developerTranscripts.wordCount, 0), - ), - ) - .limit(CAPTION_RECONCILE_LIMIT); +const CAPTION_RECONCILE_PAGE = 200; +const CAPTION_RECONCILE_BUDGET_MS = 2 * 60 * 1000; +export async function reconcileDeletedVideoCaptions( + now = new Date(), + budgetMs = CAPTION_RECONCILE_BUDGET_MS, +) { + const stopAt = Date.now() + budgetMs; + const since = new Date(now.getTime() - CAPTION_RECONCILE_WINDOW_MS); + const seen = new Set(); + let after: { updatedAt: Date; id: string } | null = null; let synced = 0; let failed = 0; - for (const row of rows) { - if (!row.videoId) continue; - try { - await syncVideoCaptions({ - appId: row.appId, - videoId: row.videoId, - language: row.language, - }); - synced++; - } catch (error) { - failed++; - console.error("[developer-transcripts] Caption reconcile failed", { - videoId: row.videoId, - error, - }); + let finished = false; + + while (Date.now() < stopAt) { + const rows = await db() + .select({ + id: developerTranscripts.id, + updatedAt: developerTranscripts.updatedAt, + appId: developerTranscripts.appId, + videoId: developerTranscripts.videoId, + language: developerTranscripts.language, + }) + .from(developerTranscripts) + .where( + and( + eq(developerTranscripts.status, "complete"), + gte(developerTranscripts.updatedAt, since), + isNotNull(developerTranscripts.deletedAt), + isNotNull(developerTranscripts.videoId), + gt(developerTranscripts.wordCount, 0), + after + ? or( + gt(developerTranscripts.updatedAt, after.updatedAt), + and( + eq(developerTranscripts.updatedAt, after.updatedAt), + gt(developerTranscripts.id, after.id), + ), + ) + : undefined, + ), + ) + .orderBy( + asc(developerTranscripts.updatedAt), + asc(developerTranscripts.id), + ) + .limit(CAPTION_RECONCILE_PAGE); + + for (const row of rows) { + after = { updatedAt: row.updatedAt, id: row.id }; + if (!row.videoId) continue; + const group = `${row.appId}/${row.videoId}/${captionLanguage(row.language)}`; + if (seen.has(group)) continue; + seen.add(group); + try { + await syncVideoCaptions({ + appId: row.appId, + videoId: row.videoId, + language: row.language, + }); + synced++; + } catch (error) { + failed++; + console.error("[developer-transcripts] Caption reconcile failed", { + videoId: row.videoId, + error, + }); + } + } + if (rows.length < CAPTION_RECONCILE_PAGE) { + finished = true; + break; } } - return { synced, failed }; + return { synced, failed, finished }; } export async function expireStaleTranscripts(now = new Date()) { From 096a62c28043ecc970f5841fed378cb813133206 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 21:05:13 -0500 Subject: [PATCH 14/16] test(infra): cover same-second caption sources and the paged repair --- .../developer-transcripts-database.test.ts | 38 ++++++- .../unit/developer-transcript-fake-db.ts | 24 +++-- .../unit/developer-video-captions.test.ts | 98 ++++++++++++++++++- 3 files changed, 149 insertions(+), 11 deletions(-) diff --git a/apps/web/__tests__/integration/developer-transcripts-database.test.ts b/apps/web/__tests__/integration/developer-transcripts-database.test.ts index 32b989dce3c..024b8b952ab 100644 --- a/apps/web/__tests__/integration/developer-transcripts-database.test.ts +++ b/apps/web/__tests__/integration/developer-transcripts-database.test.ts @@ -1207,6 +1207,15 @@ describe.skipIf(!databaseUrl)("developer transcripts database", () => { ...overrides, }); + const transcriptRow = async (transcriptId: string) => { + const [row] = await database() + .select() + .from(developerTranscripts) + .where(eq(developerTranscripts.id, transcriptId)); + if (!row) throw new Error("Transcript fixture is missing."); + return row; + }; + const makeCaptionSource = async ( appId: string, videoId: string | null, @@ -1239,6 +1248,7 @@ describe.skipIf(!databaseUrl)("developer transcripts database", () => { wordCount: text.split(" ").length, resultKey, completedAt: new Date(), + requestedAt: overrides.createdAt ?? new Date(), ...overrides, }); return transcriptId; @@ -1430,17 +1440,19 @@ describe.skipIf(!databaseUrl)("developer transcripts database", () => { expect(await developerVideoCaptions({ id: videoId, appId })).toEqual([]); }); - it("orders two transcripts created in the same second the same way every time", async () => { + it("uses the transcript requested last when two were created in the same second", async () => { const appId = await makeApp(50_000); const videoId = await makeReadyVideo(appId); const createdAt = new Date("2026-11-12T10:00:00.000Z"); const first = await makeCaptionSource(appId, videoId, "first words", { - id: `a${id().slice(1)}`, + id: `z${id().slice(1)}`, createdAt, + requestedAt: new Date("2026-11-12T10:00:00.120Z"), }); const second = await makeCaptionSource(appId, videoId, "second words", { - id: `z${id().slice(1)}`, + id: `a${id().slice(1)}`, createdAt, + requestedAt: new Date("2026-11-12T10:00:00.870Z"), }); for (let round = 0; round < 5; round++) { @@ -1448,9 +1460,28 @@ describe.skipIf(!databaseUrl)("developer transcripts database", () => { await syncVideoCaptions({ appId, videoId, language: "en" }), ).toEqual({ state: "written", transcriptId: second }); } + expect((await transcriptRow(second)).requestedAt?.toISOString()).toBe( + "2026-11-12T10:00:00.870Z", + ); expect(first).not.toBe(second); }); + it("orders video transcripts created through the API within one second by when they were requested", async () => { + const appId = await makeApp(50_000); + const videoId = await makeReadyVideo(appId); + const created: string[] = []; + for (let index = 0; index < 3; index++) { + const result = await createTranscriptFromVideo(appId, { videoId }); + if (!result.ok) throw new Error("Video transcript was not created."); + created.push(result.transcript.id); + } + const rows = await Promise.all(created.map(transcriptRow)); + const times = rows.map((row) => row.requestedAt?.getTime() ?? 0); + expect(times.every((time) => time > 0)).toBe(true); + expect([...times].sort((a, b) => a - b)).toEqual(times); + expect(new Set(times).size).toBe(3); + }); + it("repairs captions left by a failed delete from the hourly cleanup, reading through the status index", async () => { const appId = await makeApp(50_000); const videoId = await makeReadyVideo(appId); @@ -1472,6 +1503,7 @@ describe.skipIf(!databaseUrl)("developer transcripts database", () => { const result = await reconcileDeletedVideoCaptions(); expect(result.failed).toBe(0); + expect(result.finished).toBe(true); expect(result.synced).toBeGreaterThanOrEqual(1); expect(fixture.objects.get(key)).toContain("older words"); const plan = await explainLastQuery("`videoId` is not null"); diff --git a/apps/web/__tests__/unit/developer-transcript-fake-db.ts b/apps/web/__tests__/unit/developer-transcript-fake-db.ts index 9aaf3901cb9..0f476900d0e 100644 --- a/apps/web/__tests__/unit/developer-transcript-fake-db.ts +++ b/apps/web/__tests__/unit/developer-transcript-fake-db.ts @@ -233,18 +233,25 @@ export function createFakeDatabase(defaults: Map Row>) { const state = { condition: undefined as SQL | undefined, ordered: false, + orderColumn: "createdAt", + ascending: false, limit: undefined as number | undefined, offset: 0, }; const run = async () => { let rows = rowsOf(table).filter(compile(state.condition)); if (state.ordered) { + const time = (row: Row) => { + const value = row[state.orderColumn]; + return value instanceof Date ? value.getTime() : 0; + }; + const direction = state.ascending ? -1 : 1; rows = [...rows].sort((a, b) => { - const byDate = - (b.createdAt as Date).getTime() - (a.createdAt as Date).getTime(); - return byDate !== 0 - ? byDate - : String(b.id).localeCompare(String(a.id)); + const byDate = time(b) - time(a); + return ( + direction * + (byDate !== 0 ? byDate : String(b.id).localeCompare(String(a.id))) + ); }); } const scalars: Row = {}; @@ -284,8 +291,13 @@ export function createFakeDatabase(defaults: Map Row>) { state.condition = condition; return chain; }, - orderBy() { + orderBy(first?: SQL) { state.ordered = true; + const named = first + ? dialect.sqlToQuery(first).sql.match(/`(\w+)`(?: (desc|asc))?$/i) + : null; + state.orderColumn = named?.[1] ?? "createdAt"; + state.ascending = named?.[2]?.toLowerCase() === "asc"; return chain; }, limit(count: number) { diff --git a/apps/web/__tests__/unit/developer-video-captions.test.ts b/apps/web/__tests__/unit/developer-video-captions.test.ts index f3798bb9cfe..1772286daf2 100644 --- a/apps/web/__tests__/unit/developer-video-captions.test.ts +++ b/apps/web/__tests__/unit/developer-video-captions.test.ts @@ -203,11 +203,14 @@ const resultFor = (text: string) => segments: [], }); +let requests = 0; + const seedCompleted = ( id: string, text: string, row: Record = {}, ) => { + requests += 1; const resultKey = `developer/app-1/transcripts/${id}/result.json`; context.objects.set(resultKey, resultFor(text)); return fake().seed(developerTranscripts, { @@ -220,6 +223,7 @@ const seedCompleted = ( text, wordCount: text.split(" ").length, resultKey, + requestedAt: new Date(Date.parse("2026-11-12T10:00:00.000Z") + requests), completedAt: new Date("2026-11-12T10:00:00.000Z"), ...row, }); @@ -911,7 +915,7 @@ describe("the caption file of a video", () => { const deleted = transcripts().find((row) => row.id === "tr-new"); expect( await reconcileDeletedVideoCaptions(deleted?.updatedAt as Date), - ).toEqual({ synced: 1, failed: 0 }); + ).toEqual({ synced: 1, failed: 0, finished: true }); expect(context.objects.get(EN_KEY)).toContain("older words"); }); @@ -943,6 +947,7 @@ describe("the caption file of a video", () => { expect(await reconcileDeletedVideoCaptions(now)).toEqual({ synced: 1, failed: 0, + finished: true, }); expect(context.objects.has(EN_KEY)).toBe(false); expect(context.objects.has(DE_KEY)).toBe(true); @@ -961,6 +966,95 @@ describe("the caption file of a video", () => { expect( await reconcileDeletedVideoCaptions(latest?.updatedAt as Date), - ).toEqual({ synced: 1, failed: 1 }); + ).toEqual({ synced: 1, failed: 1, finished: true }); + }); + + it("uses the transcript requested last, even when both were created in the same second", async () => { + seedVideo(); + const second = new Date("2026-11-12T10:00:00.000Z"); + seedCompleted("tr-z-first", "first words", { + createdAt: second, + requestedAt: new Date("2026-11-12T10:00:00.100Z"), + }); + seedCompleted("tr-a-second", "second words", { + createdAt: second, + requestedAt: new Date("2026-11-12T10:00:00.900Z"), + }); + + expect(await sync()).toEqual({ + state: "written", + transcriptId: "tr-a-second", + }); + }); + + it("records when a video transcript was requested, to the millisecond", async () => { + seedVideo(); + const before = Date.now(); + const created = await createTranscriptFromVideo("app-1", { + videoId: "video-1", + }); + if (!created.ok) throw new Error("create failed"); + const requestedAt = transcripts().find( + (row) => row.id === created.transcript.id, + )?.requestedAt as Date; + + expect(requestedAt).toBeInstanceOf(Date); + expect(requestedAt.getTime()).toBeGreaterThanOrEqual(before); + expect(requestedAt.getTime()).toBeLessThanOrEqual(Date.now()); + }); + + it("walks past the first page and repairs every group deleted in the window", async () => { + const base = Date.parse("2026-11-12T11:00:00.000Z"); + for (let index = 0; index < 450; index++) { + seedVideo({ id: `video-${index}` }); + const resultKey = `developer/app-1/transcripts/tr-${index}/result.json`; + context.objects.set(resultKey, resultFor("gone words")); + fake().seed(developerTranscripts, { + id: `tr-${index}`, + appId: "app-1", + status: "complete", + sourceType: "video", + videoId: `video-${index}`, + language: "en", + wordCount: 2, + resultKey, + deletedAt: new Date(base), + updatedAt: new Date(base + Math.floor(index / 3) * 1000), + }); + context.objects.set( + `developer/app-1/video-${index}/captions/en.vtt`, + "WEBVTT\n\nstale\n", + ); + } + + expect( + await reconcileDeletedVideoCaptions(new Date(base + 600_000)), + ).toEqual({ synced: 450, failed: 0, finished: true }); + expect( + [...context.objects.keys()].filter((key) => key.endsWith(".vtt")), + ).toEqual([]); + }); + + it("repairs each video and language once per run however many of its transcripts were deleted", async () => { + seedVideo(); + for (const id of ["tr-1", "tr-2", "tr-3"]) { + seedCompleted(id, "gone words", { deletedAt: new Date() }); + } + context.puts = []; + const latest = transcripts().find((row) => row.id === "tr-3"); + + expect( + await reconcileDeletedVideoCaptions(latest?.updatedAt as Date), + ).toEqual({ synced: 1, failed: 0, finished: true }); + }); + + it("stops at its time budget and says it did not finish", async () => { + seedVideo(); + seedCompleted("tr-1", "gone words", { deletedAt: new Date() }); + const latest = transcripts().find((row) => row.id === "tr-1"); + + expect( + await reconcileDeletedVideoCaptions(latest?.updatedAt as Date, 0), + ).toEqual({ synced: 0, failed: 0, finished: false }); }); }); From c821549d2b2ff54b5db727b6e5ae882111866cc1 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 21:06:27 -0500 Subject: [PATCH 15/16] fix(infra): include requestedAt in listed transcript rows --- apps/web/__tests__/unit/developer-transcript-math.test.ts | 1 + apps/web/lib/developer-transcript-store.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/web/__tests__/unit/developer-transcript-math.test.ts b/apps/web/__tests__/unit/developer-transcript-math.test.ts index 11e9a4e0dce..ce55d250c7b 100644 --- a/apps/web/__tests__/unit/developer-transcript-math.test.ts +++ b/apps/web/__tests__/unit/developer-transcript-math.test.ts @@ -36,6 +36,7 @@ const row = ( sourceType: "upload", sourceUrl: null, videoId: null, + requestedAt: null, s3Key: "developer/app-1/transcripts/tr-1/source", resultKey: "developer/app-1/transcripts/tr-1/result.json", sourceBytes: 1_000, diff --git a/apps/web/lib/developer-transcript-store.ts b/apps/web/lib/developer-transcript-store.ts index 887b0f22a3e..0466ae18142 100644 --- a/apps/web/lib/developer-transcript-store.ts +++ b/apps/web/lib/developer-transcript-store.ts @@ -809,6 +809,7 @@ const summaryColumns = { sourceType: developerTranscripts.sourceType, sourceUrl: developerTranscripts.sourceUrl, videoId: developerTranscripts.videoId, + requestedAt: developerTranscripts.requestedAt, s3Key: developerTranscripts.s3Key, resultKey: developerTranscripts.resultKey, sourceBytes: developerTranscripts.sourceBytes, From 9c09985389b8961fbd604e46293e6c4685c35ed1 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 12 Nov 2026 21:59:10 -0500 Subject: [PATCH 16/16] docs(infra): say a video transcript writes a captions file, without a player claim --- .../__tests__/unit/developer-transcript-docs.test.ts | 10 +++++++--- apps/web/content/docs/api/rest-api.mdx | 2 +- apps/web/lib/developer-openapi.ts | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/__tests__/unit/developer-transcript-docs.test.ts b/apps/web/__tests__/unit/developer-transcript-docs.test.ts index d6433f5de63..fe9c58d7bb5 100644 --- a/apps/web/__tests__/unit/developer-transcript-docs.test.ts +++ b/apps/web/__tests__/unit/developer-transcript-docs.test.ts @@ -178,13 +178,17 @@ describe("transcribing a stored video", () => { "It costs the transcript price only, $0.10 per audio hour.", ); expect(transcriptDocs).toContain( - "Captions for the hosted player are generated from the newest completed transcript of a video in each language.", + "When a transcript of a video completes with speech, Cap writes a WebVTT captions file for the video in that language, from the newest completed transcript of the video in that language.", ); expect(transcriptDocs).toContain("`videoId`, `contentLength`, `name`"); }); - it("leaves the captions file and the player to the playback docs", () => { - expect(transcriptDocs).not.toMatch(/\.vtt|WebVTT|captions\//i); + it("says only what ships: no player captions and no captions URL", () => { + for (const text of [transcriptDocs, specText]) { + expect(text).not.toMatch(/captions for the hosted player/i); + expect(text).not.toMatch(/\.vtt|captions\//i); + } + expect(transcriptDocs).not.toMatch(/player/i); }); }); diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index 4c8289d2ed4..0eafc8a5711 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -740,7 +740,7 @@ curl -X POST https://cap.so/api/developer/v1/transcripts \ - It costs the transcript price only, $0.10 per audio hour. Reading the stored video is not charged, and the video's storage charge does not change. - `name`, `language`, `userId`, `metadata` and the `Idempotency-Key` header work as they do for a URL. The name defaults to the video's name. - The transcript carries the video's ID as `videoId`, and `source.type` is `video`. `videoId` is `null` on every other transcript. -- Captions for the hosted player are generated from the newest completed transcript of a video in each language. +- When a transcript of a video completes with speech, Cap writes a WebVTT captions file for the video in that language, from the newest completed transcript of the video in that language. - The transcript is its own object. The video's `transcriptionStatus` does not change, and deleting the video leaves the transcript and its text. A transcript whose video is deleted before it runs ends with `source_missing` and is not charged. - Sending the video's `mp4Url` as `url` still works as a URL transcript. It is not linked to the video: its `videoId` is `null`. diff --git a/apps/web/lib/developer-openapi.ts b/apps/web/lib/developer-openapi.ts index 89fce045af1..67f13c0903d 100644 --- a/apps/web/lib/developer-openapi.ts +++ b/apps/web/lib/developer-openapi.ts @@ -169,7 +169,7 @@ const transcriptSchema = { videoId: { type: ["string", "null"], description: - "The video this transcript was created from with videoId. null for every other transcript. Captions for the hosted player are generated from the newest completed transcript of a video in each language.", + "The video this transcript was created from with videoId. null for every other transcript. When a transcript of a video completes with speech, Cap writes a WebVTT captions file for the video in that language, from the newest completed transcript of the video in that language.", }, durationSeconds: { type: ["number", "null"],