From 1f15dc31a9a12f229a335405d8a88ae6d0a813a2 Mon Sep 17 00:00:00 2001
From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com>
Date: Mon, 21 Sep 2026 11:46:11 +0100
Subject: [PATCH 01/74] feat: add Cap Pro Loom concierge migration
---
apps/web/__tests__/unit/loom-csv.test.ts | 25 +
.../unit/loom-migration-state.test.ts | 39 +
apps/web/actions/loom-concierge.tsx | 331 ++
apps/web/actions/loom.ts | 429 +-
.../dashboard/_components/Navbar/Items.tsx | 25 +
.../loom-migrations/LoomMigrationQueue.tsx | 527 ++
.../dashboard/admin/loom-migrations/page.tsx | 17 +
.../app/(org)/dashboard/import/ImportPage.tsx | 38 +-
.../dashboard/import/loom/ImportLoomPage.tsx | 61 +-
.../migrations/loom/LoomMigrationPage.tsx | 398 ++
.../(org)/dashboard/migrations/loom/page.tsx | 10 +
apps/web/content/docs/migrating-to-cap.mdx | 16 +-
apps/web/lib/loom-concierge.ts | 134 +
apps/web/lib/loom-csv.ts | 72 +
apps/web/lib/loom-migration-state.ts | 80 +
packages/database/emails/loom-migration.tsx | 70 +
.../migrations/0046_loom_concierge.sql | 25 +
.../migrations/meta/0046_snapshot.json | 4735 +++++++++++++++++
.../database/migrations/meta/_journal.json | 7 +
packages/database/schema.ts | 51 +
20 files changed, 7031 insertions(+), 59 deletions(-)
create mode 100644 apps/web/__tests__/unit/loom-csv.test.ts
create mode 100644 apps/web/__tests__/unit/loom-migration-state.test.ts
create mode 100644 apps/web/actions/loom-concierge.tsx
create mode 100644 apps/web/app/(org)/dashboard/admin/loom-migrations/LoomMigrationQueue.tsx
create mode 100644 apps/web/app/(org)/dashboard/admin/loom-migrations/page.tsx
create mode 100644 apps/web/app/(org)/dashboard/migrations/loom/LoomMigrationPage.tsx
create mode 100644 apps/web/app/(org)/dashboard/migrations/loom/page.tsx
create mode 100644 apps/web/lib/loom-concierge.ts
create mode 100644 apps/web/lib/loom-csv.ts
create mode 100644 apps/web/lib/loom-migration-state.ts
create mode 100644 packages/database/emails/loom-migration.tsx
create mode 100644 packages/database/migrations/0046_loom_concierge.sql
create mode 100644 packages/database/migrations/meta/0046_snapshot.json
diff --git a/apps/web/__tests__/unit/loom-csv.test.ts b/apps/web/__tests__/unit/loom-csv.test.ts
new file mode 100644
index 00000000000..5338285db28
--- /dev/null
+++ b/apps/web/__tests__/unit/loom-csv.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+import { parseConciergeLoomCsv } from "@/lib/loom-csv";
+
+describe("concierge Loom CSV", () => {
+ it("keeps quoted workspace names and source row numbers", () => {
+ const rows = parseConciergeLoomCsv(
+ "\uFEFFuser_email,space_name,loom_video_url\r\n" +
+ 'owner@example.com,"Sales, Europe",https://www.loom.com/share/0123456789abcdef\r\n',
+ );
+ expect(rows).toEqual([
+ {
+ rowNumber: 2,
+ loomUrl: "https://www.loom.com/share/0123456789abcdef",
+ userEmail: "owner@example.com",
+ spaceName: "Sales, Europe",
+ },
+ ]);
+ });
+
+ it("rejects a file without the mapping columns", () => {
+ expect(() => parseConciergeLoomCsv("url,email\nlink,person@x.com")).toThrow(
+ "loom_video_url and user_email",
+ );
+ });
+});
diff --git a/apps/web/__tests__/unit/loom-migration-state.test.ts b/apps/web/__tests__/unit/loom-migration-state.test.ts
new file mode 100644
index 00000000000..87b1f90a544
--- /dev/null
+++ b/apps/web/__tests__/unit/loom-migration-state.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+import { validateOperatorMigrationUpdate } from "@/lib/loom-migration-state";
+
+const base = {
+ currentStatus: "in_progress" as const,
+ nextStatus: "completed" as const,
+ message: "Migration finished",
+ verified: true,
+ expectedVideoCount: 12,
+ importedVideoCount: 12,
+};
+
+describe("Loom migration completion", () => {
+ it("requires a verified library and reconciled counts", () => {
+ expect(() =>
+ validateOperatorMigrationUpdate({ ...base, verified: false }),
+ ).toThrow("Verify the migrated library");
+ expect(() =>
+ validateOperatorMigrationUpdate({ ...base, importedVideoCount: 11 }),
+ ).toThrow("Reconcile the expected video count");
+ expect(() =>
+ validateOperatorMigrationUpdate({ ...base, expectedVideoCount: null }),
+ ).toThrow("Enter the agreed source video count");
+ });
+
+ it("requires a useful message when Cap asks for information", () => {
+ expect(() =>
+ validateOperatorMigrationUpdate({
+ ...base,
+ nextStatus: "needs_information",
+ message: " ",
+ }),
+ ).toThrow("Tell the customer");
+ });
+
+ it("accepts completion after verification", () => {
+ expect(validateOperatorMigrationUpdate(base)).toBe("Migration finished");
+ });
+});
diff --git a/apps/web/actions/loom-concierge.tsx b/apps/web/actions/loom-concierge.tsx
new file mode 100644
index 00000000000..b1a91d30f14
--- /dev/null
+++ b/apps/web/actions/loom-concierge.tsx
@@ -0,0 +1,331 @@
+"use server";
+
+import { db } from "@cap/database";
+import { sendEmail } from "@cap/database/emails/config";
+import {
+ LoomMigrationRequestEmail,
+ LoomMigrationStatusEmail,
+} from "@cap/database/emails/loom-migration";
+import { nanoId } from "@cap/database/helpers";
+import {
+ importedVideos,
+ loomMigrationRequests,
+ organizations,
+ users,
+ videoUploads,
+} from "@cap/database/schema";
+import { serverEnv } from "@cap/env";
+import type { Organisation } from "@cap/web-domain";
+import { and, eq, inArray, isNotNull } from "drizzle-orm";
+import { revalidatePath } from "next/cache";
+import {
+ getCustomerMigrationRequests,
+ getOperatorMigrationQueue,
+ requireCustomerMigrationAccess,
+ requireMigrationOperator,
+ requireProCustomerMigrationAccess,
+} from "@/lib/loom-concierge";
+import {
+ LOOM_MIGRATION_STATUS_LABELS,
+ type LoomMigrationStatus,
+ normalizeMigrationText,
+ validateOperatorMigrationUpdate,
+} from "@/lib/loom-migration-state";
+import { isOrganizationOwnerPro } from "@/lib/org-pro";
+
+const SUPPORT_EMAIL = "hello@cap.so";
+
+function affectedRows(result: unknown) {
+ if (Array.isArray(result)) {
+ return (
+ (result[0] as { affectedRows?: number } | undefined)?.affectedRows ?? 0
+ );
+ }
+ return (result as { affectedRows?: number } | undefined)?.affectedRows ?? 0;
+}
+
+function refreshMigrationPages() {
+ revalidatePath("/dashboard/migrations/loom");
+ revalidatePath("/dashboard/admin/loom-migrations");
+}
+
+async function findMigration(requestId: string) {
+ if (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{15}$/.test(requestId)) {
+ throw new Error("Invalid migration request.");
+ }
+ const [request] = await db()
+ .select()
+ .from(loomMigrationRequests)
+ .where(eq(loomMigrationRequests.id, requestId))
+ .limit(1);
+ if (!request) throw new Error("Migration request not found.");
+ return request;
+}
+
+export async function getLoomMigrationDashboard(
+ organizationId: Organisation.OrganisationId,
+) {
+ await requireCustomerMigrationAccess(organizationId);
+ const [isPro, requests] = await Promise.all([
+ isOrganizationOwnerPro(organizationId),
+ getCustomerMigrationRequests(organizationId),
+ ]);
+ return { isPro, requests };
+}
+
+export async function requestLoomMigration({
+ organizationId,
+ workspaceName,
+ note,
+}: {
+ organizationId: Organisation.OrganisationId;
+ workspaceName: string;
+ note: string;
+}) {
+ const user = await requireProCustomerMigrationAccess(organizationId);
+ const normalizedWorkspaceName = normalizeMigrationText(
+ workspaceName,
+ "Workspace name",
+ 255,
+ );
+ const normalizedNote = normalizeMigrationText(note, "Note", 2000);
+ const [existing] = await db()
+ .select({ id: loomMigrationRequests.id })
+ .from(loomMigrationRequests)
+ .where(eq(loomMigrationRequests.activeOrganizationId, organizationId))
+ .limit(1);
+ if (existing) return { id: existing.id, alreadyRequested: true };
+
+ const id = nanoId();
+ try {
+ await db()
+ .insert(loomMigrationRequests)
+ .values({
+ id,
+ organizationId,
+ activeOrganizationId: organizationId,
+ requestedByUserId: user.id,
+ workspaceName: normalizedWorkspaceName || null,
+ customerNote: normalizedNote || null,
+ });
+ } catch (error) {
+ const [racedRequest] = await db()
+ .select({ id: loomMigrationRequests.id })
+ .from(loomMigrationRequests)
+ .where(eq(loomMigrationRequests.activeOrganizationId, organizationId))
+ .limit(1);
+ if (racedRequest) {
+ return { id: racedRequest.id, alreadyRequested: true };
+ }
+ throw error;
+ }
+
+ const [organization] = await db()
+ .select({ name: organizations.name })
+ .from(organizations)
+ .where(eq(organizations.id, organizationId))
+ .limit(1);
+ try {
+ const delivery = await sendEmail({
+ email: SUPPORT_EMAIL,
+ subject: `New Loom migration request: ${organization?.name ?? "Cap workspace"}`,
+ react: (
+
+ ),
+ idempotencyKey: `loom-migration-request-${id}`,
+ });
+ if (delivery?.error) {
+ console.error(
+ "Failed to send Loom migration request notice",
+ delivery.error,
+ );
+ }
+ } catch (error) {
+ console.error("Failed to send Loom migration request notice", error);
+ }
+
+ refreshMigrationPages();
+ return { id, alreadyRequested: false };
+}
+
+export async function confirmLoomMigrationInvite(requestId: string) {
+ const request = await findMigration(requestId);
+ await requireCustomerMigrationAccess(request.organizationId);
+ if (request.status === "completed") {
+ throw new Error("This migration is already complete.");
+ }
+ if (request.invitedAt) return;
+ const result = await db()
+ .update(loomMigrationRequests)
+ .set({ invitedAt: new Date(), updatedAt: new Date() })
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ ),
+ );
+ if (affectedRows(result) === 0) {
+ throw new Error("The migration request changed. Refresh and try again.");
+ }
+ refreshMigrationPages();
+}
+
+export async function answerLoomMigrationQuestion({
+ requestId,
+ answer,
+}: {
+ requestId: string;
+ answer: string;
+}) {
+ const request = await findMigration(requestId);
+ await requireCustomerMigrationAccess(request.organizationId);
+ const normalizedAnswer = normalizeMigrationText(answer, "Answer", 2000);
+ if (!normalizedAnswer)
+ throw new Error("Enter the information Cap asked for.");
+ const result = await db()
+ .update(loomMigrationRequests)
+ .set({
+ customerReply: normalizedAnswer,
+ status: "pending",
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ eq(loomMigrationRequests.status, "needs_information"),
+ ),
+ );
+ if (affectedRows(result) === 0) {
+ throw new Error("The migration request changed. Refresh and try again.");
+ }
+ refreshMigrationPages();
+}
+
+export async function getLoomMigrationOperatorQueue() {
+ return getOperatorMigrationQueue();
+}
+
+export async function updateLoomMigrationStatus({
+ requestId,
+ nextStatus,
+ message,
+ verified,
+ expectedVideoCount,
+ importedVideoCount,
+}: {
+ requestId: string;
+ nextStatus: LoomMigrationStatus;
+ message: string;
+ verified: boolean;
+ expectedVideoCount: number | null;
+ importedVideoCount: number;
+}) {
+ const operator = await requireMigrationOperator();
+ const request = await findMigration(requestId);
+ const normalizedMessage = validateOperatorMigrationUpdate({
+ currentStatus: request.status,
+ nextStatus,
+ message,
+ verified,
+ expectedVideoCount,
+ importedVideoCount,
+ });
+ if (
+ nextStatus === request.status &&
+ (normalizedMessage || null) === request.capMessage &&
+ expectedVideoCount === request.expectedVideoCount &&
+ importedVideoCount === request.importedVideoCount
+ ) {
+ return;
+ }
+ if (nextStatus === "completed") {
+ const [unfinishedImport] = await db()
+ .select({ videoId: videoUploads.videoId })
+ .from(importedVideos)
+ .innerJoin(videoUploads, eq(videoUploads.videoId, importedVideos.id))
+ .where(
+ and(
+ eq(importedVideos.orgId, request.organizationId),
+ eq(importedVideos.source, "loom"),
+ inArray(videoUploads.phase, [
+ "uploading",
+ "processing",
+ "generating_thumbnail",
+ "error",
+ ]),
+ ),
+ )
+ .limit(1);
+ if (unfinishedImport) {
+ throw new Error(
+ "Loom imports are still processing or have errors. Resolve them before completion.",
+ );
+ }
+ }
+ const [organization] = await db()
+ .select({ name: organizations.name })
+ .from(organizations)
+ .where(eq(organizations.id, request.organizationId))
+ .limit(1);
+ const result = await db()
+ .update(loomMigrationRequests)
+ .set({
+ status: nextStatus,
+ capMessage: normalizedMessage || null,
+ expectedVideoCount,
+ importedVideoCount,
+ activeOrganizationId:
+ nextStatus === "completed" ? null : request.organizationId,
+ completedAt: nextStatus === "completed" ? new Date() : null,
+ lastOperatorUserId: operator.id,
+ lastOperatorAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ eq(loomMigrationRequests.status, request.status),
+ ),
+ );
+ if (affectedRows(result) === 0) {
+ throw new Error("The migration request changed. Refresh and try again.");
+ }
+
+ const [requester] = await db()
+ .select({ email: users.email })
+ .from(users)
+ .where(eq(users.id, request.requestedByUserId))
+ .limit(1);
+ if (requester) {
+ try {
+ const delivery = await sendEmail({
+ email: requester.email,
+ subject: `Loom migration: ${LOOM_MIGRATION_STATUS_LABELS[nextStatus]}`,
+ react: (
+
+ ),
+ replyTo: SUPPORT_EMAIL,
+ idempotencyKey: `loom-migration-status-${requestId}-${Date.now()}`,
+ });
+ if (delivery?.error) {
+ console.error(
+ "Failed to send Loom migration status notice",
+ delivery.error,
+ );
+ }
+ } catch (error) {
+ console.error("Failed to send Loom migration status notice", error);
+ }
+ }
+ refreshMigrationPages();
+}
diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts
index b9e03dcecf9..7cb49e13fac 100644
--- a/apps/web/actions/loom.ts
+++ b/apps/web/actions/loom.ts
@@ -7,7 +7,9 @@ import { nanoId } from "@cap/database/helpers";
import {
folders,
importedVideos,
+ loomMigrationRequests,
organizationMembers,
+ organizations,
sharedVideos,
spaceMembers,
spaces,
@@ -23,11 +25,12 @@ import {
type Organisation,
Space,
SpaceMemberId,
+ type Storage as StorageDomain,
type User,
Video,
} from "@cap/web-domain";
-import { and, asc, eq, isNull } from "drizzle-orm";
-import { Option } from "effect";
+import { and, asc, eq, isNotNull, isNull, sql } from "drizzle-orm";
+import { Effect, Option, Schedule } from "effect";
import { revalidatePath } from "next/cache";
import { start } from "workflow/api";
import {
@@ -36,10 +39,14 @@ import {
requireOrganizationSettingsManager,
} from "@/actions/organization/authorization";
import { requireSpaceManager } from "@/actions/organization/space-authorization";
+import { requireMigrationOperator } from "@/lib/loom-concierge";
import type { LoomImportDestination } from "@/lib/loom-import-destination";
+import { isOrganizationOwnerPro } from "@/lib/org-pro";
import { provisionOrganizationInvitee } from "@/lib/organization-provisioning";
import { canManageOrganizationSettings } from "@/lib/permissions/roles";
import { runPromise } from "@/lib/server";
+import { startVideoProcessingWorkflow } from "@/lib/video-processing";
+import { decodeStorageVideo } from "@/lib/video-storage";
import { importLoomVideoWorkflow } from "@/workflows/import-loom-video";
interface LoomUrlResponse {
@@ -97,10 +104,22 @@ const LOOM_CSV_LIMIT_ERROR = `CSV imports are limited to ${MAX_LOOM_CSV_ROWS} ro
const LOOM_CSV_PERMISSION_ERROR =
"Only organization admins and owners can import Loom videos from a CSV.";
+function affectedRows(result: unknown) {
+ if (Array.isArray(result)) {
+ return (
+ (result[0] as { affectedRows?: number } | undefined)?.affectedRows ?? 0
+ );
+ }
+ return (result as { affectedRows?: number } | undefined)?.affectedRows ?? 0;
+}
+
function extractLoomVideoId(url: string): string | null {
try {
const parsed = new URL(url);
- if (!parsed.hostname.includes("loom.com")) {
+ if (
+ parsed.protocol !== "https:" ||
+ (parsed.hostname !== "loom.com" && !parsed.hostname.endsWith(".loom.com"))
+ ) {
return null;
}
@@ -770,6 +789,18 @@ export async function importFromLoomCsv({
};
}
+ return processLoomCsvRows({ rows, orgId, actorId: user.id });
+}
+
+async function processLoomCsvRows({
+ rows,
+ orgId,
+ actorId,
+}: {
+ rows: LoomCsvImportRow[];
+ orgId: Organisation.OrganisationId;
+ actorId: User.UserId;
+}): Promise {
const inputRows = Array.isArray(rows) ? rows : [];
const normalizedRows = inputRows
.map((row, index) => ({
@@ -854,7 +885,7 @@ export async function importFromLoomCsv({
const provisionedMember = await provisionOrganizationInvitee({
organizationId: orgId,
email: row.userEmail,
- invitedByUserId: user.id,
+ invitedByUserId: actorId,
role: "member",
});
member = {
@@ -886,14 +917,14 @@ export async function importFromLoomCsv({
try {
const space = await getOrCreateImportSpace({
orgId,
- createdById: user.id,
+ createdById: actorId,
name: row.spaceName,
spaceCache,
});
await addImportedVideoToSpace({
videoId: result.videoId,
spaceId: space.id,
- addedById: user.id,
+ addedById: actorId,
});
await addImportOwnerToSpace({
spaceId: space.id,
@@ -944,3 +975,389 @@ export async function importFromLoomCsv({
error: importedCount > 0 ? undefined : "No Loom videos were imported.",
};
}
+
+export async function importFromLoomCsvForConcierge({
+ requestId,
+ rows,
+}: {
+ requestId: string;
+ rows: LoomCsvImportRow[];
+}): Promise {
+ const operator = await requireMigrationOperator();
+ if (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{15}$/.test(requestId)) {
+ throw new Error("Invalid migration request.");
+ }
+ const [request] = await db()
+ .select({
+ organizationId: loomMigrationRequests.organizationId,
+ status: loomMigrationRequests.status,
+ ownerId: organizations.ownerId,
+ })
+ .from(loomMigrationRequests)
+ .innerJoin(
+ organizations,
+ eq(organizations.id, loomMigrationRequests.organizationId),
+ )
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ isNull(organizations.tombstoneAt),
+ ),
+ )
+ .limit(1);
+ if (!request || request.status === "completed") {
+ throw new Error("The migration request is no longer active.");
+ }
+ if (!(await isOrganizationOwnerPro(request.organizationId))) {
+ throw new Error("The destination workspace needs Cap Pro.");
+ }
+ const claimed = await db()
+ .update(loomMigrationRequests)
+ .set({
+ status: "in_progress",
+ lastOperatorUserId: operator.id,
+ lastOperatorAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ ),
+ );
+ if (affectedRows(claimed) === 0) {
+ throw new Error("The migration request changed. Refresh and try again.");
+ }
+ const result = await processLoomCsvRows({
+ rows,
+ orgId: request.organizationId,
+ actorId: request.ownerId,
+ });
+ if (result.importedCount > 0) {
+ const updated = await db()
+ .update(loomMigrationRequests)
+ .set({
+ queuedVideoCount: sql`${loomMigrationRequests.queuedVideoCount} + ${result.importedCount}`,
+ lastOperatorUserId: operator.id,
+ lastOperatorAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ ),
+ );
+ if (affectedRows(updated) === 0) {
+ throw new Error(
+ "The migration request changed after jobs started. Review them before retrying.",
+ );
+ }
+ }
+ revalidatePath("/dashboard/migrations/loom");
+ revalidatePath("/dashboard/admin/loom-migrations");
+ return result;
+}
+
+export async function createConciergeLoomFileUpload({
+ requestId,
+ loomUrl,
+ userEmail,
+ spaceName,
+ videoTitle,
+}: {
+ requestId: string;
+ loomUrl: string;
+ userEmail: string;
+ spaceName: string;
+ videoTitle: string;
+}): Promise<{
+ videoId: Video.VideoId;
+ uploadTarget: StorageDomain.UploadTarget;
+}> {
+ const operator = await requireMigrationOperator();
+ if (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{15}$/.test(requestId)) {
+ throw new Error("Invalid migration request.");
+ }
+ const loomVideoId = extractLoomVideoId(loomUrl.trim());
+ if (!loomVideoId) throw new Error("Enter a valid Loom video URL.");
+ const normalizedEmail = normalizeImportEmail(userEmail);
+ if (!isValidImportEmail(normalizedEmail)) {
+ throw new Error("Enter a valid destination owner email.");
+ }
+ const normalizedSpaceName = normalizeImportSpaceName(spaceName);
+ if (!isValidImportSpaceName(normalizedSpaceName)) {
+ throw new Error("Space name is too long.");
+ }
+ const normalizedTitle = videoTitle.trim();
+ if (!normalizedTitle || normalizedTitle.length > 255) {
+ throw new Error("Enter a video title of 255 characters or fewer.");
+ }
+ const [request] = await db()
+ .select({
+ organizationId: loomMigrationRequests.organizationId,
+ ownerId: organizations.ownerId,
+ })
+ .from(loomMigrationRequests)
+ .innerJoin(
+ organizations,
+ eq(organizations.id, loomMigrationRequests.organizationId),
+ )
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ isNull(organizations.tombstoneAt),
+ ),
+ )
+ .limit(1);
+ if (!request) throw new Error("The migration request is no longer active.");
+ if (!(await isOrganizationOwnerPro(request.organizationId))) {
+ throw new Error("The destination workspace needs Cap Pro.");
+ }
+ const [existing] = await db()
+ .select({
+ id: importedVideos.id,
+ ownerId: videos.ownerId,
+ ownerEmail: users.email,
+ bucket: videos.bucket,
+ storageIntegrationId: videos.storageIntegrationId,
+ rawFileKey: videoUploads.rawFileKey,
+ phase: videoUploads.phase,
+ processingMessage: videoUploads.processingMessage,
+ })
+ .from(importedVideos)
+ .leftJoin(
+ videos,
+ and(
+ eq(videos.id, importedVideos.id),
+ eq(videos.orgId, importedVideos.orgId),
+ ),
+ )
+ .leftJoin(users, eq(users.id, videos.ownerId))
+ .leftJoin(videoUploads, eq(videoUploads.videoId, importedVideos.id))
+ .where(
+ and(
+ eq(importedVideos.orgId, request.organizationId),
+ eq(importedVideos.source, "loom"),
+ eq(importedVideos.sourceId, loomVideoId),
+ ),
+ )
+ .limit(1);
+ if (existing) {
+ if (
+ !existing.ownerId ||
+ !existing.ownerEmail ||
+ normalizeImportEmail(existing.ownerEmail) !== normalizedEmail ||
+ !existing.rawFileKey ||
+ (existing.phase !== "error" &&
+ (existing.phase !== "uploading" ||
+ existing.processingMessage !== "Uploading Loom video..."))
+ ) {
+ throw new Error(
+ "This Loom video is already in the Cap import inventory.",
+ );
+ }
+ const retryUpload = await Storage.createUploadTargetForUser(
+ existing.ownerId,
+ existing.rawFileKey,
+ {
+ contentType: "video/mp4",
+ videoTitle: normalizedTitle,
+ method: "put",
+ fields: { "x-amz-meta-userid": existing.ownerId },
+ },
+ request.organizationId,
+ ).pipe(runPromise);
+ if (
+ Option.getOrNull(retryUpload.bucketId) !== existing.bucket ||
+ Option.getOrNull(retryUpload.storageIntegrationId) !==
+ existing.storageIntegrationId
+ ) {
+ throw new Error(
+ "The Cap storage destination changed. Review this upload before retrying.",
+ );
+ }
+ return {
+ videoId: Video.VideoId.make(existing.id),
+ uploadTarget: retryUpload.upload,
+ };
+ }
+ let member = await getOrganizationMemberByEmail(
+ request.organizationId,
+ normalizedEmail,
+ );
+ if (!member) {
+ const provisioned = await provisionOrganizationInvitee({
+ organizationId: request.organizationId,
+ email: normalizedEmail,
+ invitedByUserId: request.ownerId,
+ role: "member",
+ });
+ member = { userId: provisioned.userId, email: normalizedEmail };
+ }
+ const space = normalizedSpaceName
+ ? await getOrCreateImportSpace({
+ orgId: request.organizationId,
+ createdById: request.ownerId,
+ name: normalizedSpaceName,
+ spaceCache: new Map(),
+ })
+ : null;
+ if (space) {
+ await addImportOwnerToSpace({ spaceId: space.id, userId: member.userId });
+ }
+ const videoId = Video.VideoId.make(nanoId());
+ const rawFileKey = `${member.userId}/${videoId}/raw-upload.mp4`;
+ const upload = await Storage.createUploadTargetForUser(
+ member.userId,
+ rawFileKey,
+ {
+ contentType: "video/mp4",
+ videoTitle: normalizedTitle,
+ method: "put",
+ fields: { "x-amz-meta-userid": member.userId },
+ },
+ request.organizationId,
+ ).pipe(runPromise);
+ await db().transaction(async (tx) => {
+ await tx.insert(videos).values({
+ id: videoId,
+ name: normalizedTitle,
+ ownerId: member.userId,
+ orgId: request.organizationId,
+ source: { type: "webMP4" as const },
+ bucket: Option.getOrNull(upload.bucketId),
+ storageIntegrationId: Option.getOrNull(upload.storageIntegrationId),
+ public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC,
+ });
+ await tx.insert(videoUploads).values({
+ videoId,
+ mode: "singlepart",
+ phase: "uploading",
+ processingProgress: 0,
+ processingMessage: "Uploading Loom video...",
+ rawFileKey,
+ });
+ await tx.insert(importedVideos).values({
+ id: videoId,
+ orgId: request.organizationId,
+ source: "loom",
+ sourceId: loomVideoId,
+ });
+ if (space) {
+ await tx.insert(spaceVideos).values({
+ id: nanoId(),
+ videoId,
+ spaceId: space.id,
+ addedById: request.ownerId,
+ });
+ }
+ });
+ await db()
+ .update(loomMigrationRequests)
+ .set({
+ status: "in_progress",
+ lastOperatorUserId: operator.id,
+ lastOperatorAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ ),
+ );
+ revalidatePath("/dashboard/admin/loom-migrations");
+ return { videoId, uploadTarget: upload.upload };
+}
+
+export async function finishConciergeLoomFileUpload({
+ requestId,
+ videoId,
+}: {
+ requestId: string;
+ videoId: Video.VideoId;
+}) {
+ const operator = await requireMigrationOperator();
+ if (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{15}$/.test(requestId)) {
+ throw new Error("Invalid migration request.");
+ }
+ const [record] = await db()
+ .select({
+ video: videos,
+ rawFileKey: videoUploads.rawFileKey,
+ phase: videoUploads.phase,
+ processingMessage: videoUploads.processingMessage,
+ })
+ .from(videos)
+ .innerJoin(
+ importedVideos,
+ and(
+ eq(importedVideos.id, videos.id),
+ eq(importedVideos.orgId, videos.orgId),
+ ),
+ )
+ .innerJoin(videoUploads, eq(videoUploads.videoId, videos.id))
+ .innerJoin(
+ loomMigrationRequests,
+ eq(loomMigrationRequests.organizationId, importedVideos.orgId),
+ )
+ .where(
+ and(
+ eq(videos.id, videoId),
+ eq(loomMigrationRequests.id, requestId),
+ eq(importedVideos.source, "loom"),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ ),
+ )
+ .limit(1);
+ if (
+ !record ||
+ !record.rawFileKey ||
+ (record.phase !== "error" &&
+ (record.phase !== "uploading" ||
+ record.processingMessage !== "Uploading Loom video..."))
+ ) {
+ throw new Error("This Loom upload is no longer available.");
+ }
+ const [bucket] = await Storage.getAccessForVideo(
+ decodeStorageVideo(record.video),
+ ).pipe(runPromise);
+ const head = await bucket
+ .headObject(record.rawFileKey)
+ .pipe(
+ Effect.retry({ times: 3, schedule: Schedule.exponential("100 millis") }),
+ runPromise,
+ );
+ if ((head.ContentLength ?? 0) <= 0)
+ throw new Error("The uploaded file is empty.");
+ const status = await startVideoProcessingWorkflow({
+ videoId,
+ userId: record.video.ownerId,
+ rawFileKey: record.rawFileKey,
+ bucketId: record.video.bucket,
+ processingMessage: "Processing Loom video...",
+ startFailureMessage: "Loom file processing could not start.",
+ });
+ if (status === "started") {
+ await db()
+ .update(loomMigrationRequests)
+ .set({
+ queuedVideoCount: sql`${loomMigrationRequests.queuedVideoCount} + 1`,
+ lastOperatorUserId: operator.id,
+ lastOperatorAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ ),
+ );
+ }
+ revalidatePath("/dashboard/migrations/loom");
+ revalidatePath("/dashboard/admin/loom-migrations");
+ return { status };
+}
diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx
index e91138d70e5..d5d0ebfe639 100644
--- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx
+++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx
@@ -46,6 +46,7 @@ import {
loomImportDestinationFromPathname,
loomImportPageHref,
} from "@/lib/loom-import-destination";
+import { MESSENGER_ADMIN_EMAIL } from "@/lib/messenger/constants";
import {
canViewOrganizationSettings,
getEffectiveOrganizationRole,
@@ -78,6 +79,9 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => {
const showDeveloperDashboard =
buildEnv.NEXT_PUBLIC_IS_CAP &&
DEVELOPER_DASHBOARD_ALLOWED_EMAILS.includes(user.email);
+ const showMigrationQueue =
+ buildEnv.NEXT_PUBLIC_IS_CAP &&
+ user.email.toLowerCase() === MESSENGER_ADMIN_EMAIL;
const manageNavigation = [
{
@@ -107,6 +111,17 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => {
icon: ,
subNav: [],
},
+ ...(buildEnv.NEXT_PUBLIC_IS_CAP
+ ? [
+ {
+ name: "Loom Migration",
+ href: "/dashboard/migrations/loom",
+ adminOnly: true,
+ icon: ,
+ subNav: [],
+ },
+ ]
+ : []),
{
name: "Organization Settings",
href: `/dashboard/settings/organization`,
@@ -127,6 +142,16 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => {
},
]
: []),
+ ...(showMigrationQueue
+ ? [
+ {
+ name: "Migration Queue",
+ href: "/dashboard/admin/loom-migrations",
+ icon: ,
+ subNav: [],
+ },
+ ]
+ : []),
];
const [dialogOpen, setDialogOpen] = useState(false);
diff --git a/apps/web/app/(org)/dashboard/admin/loom-migrations/LoomMigrationQueue.tsx b/apps/web/app/(org)/dashboard/admin/loom-migrations/LoomMigrationQueue.tsx
new file mode 100644
index 00000000000..d256a9aded3
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/admin/loom-migrations/LoomMigrationQueue.tsx
@@ -0,0 +1,527 @@
+"use client";
+
+import { Button } from "@cap/ui";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { LoaderCircle } from "lucide-react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import {
+ createConciergeLoomFileUpload,
+ finishConciergeLoomFileUpload,
+ importFromLoomCsvForConcierge,
+ type LoomCsvImportRow,
+ type LoomCsvImportRowResult,
+} from "@/actions/loom";
+import {
+ getLoomMigrationOperatorQueue,
+ updateLoomMigrationStatus,
+} from "@/actions/loom-concierge";
+import type { OperatorLoomMigrationView } from "@/lib/loom-concierge";
+import { parseConciergeLoomCsv } from "@/lib/loom-csv";
+import {
+ LOOM_MIGRATION_STATUS_LABELS,
+ type LoomMigrationStatus,
+} from "@/lib/loom-migration-state";
+import { uploadWithTarget } from "@/utils/upload-target";
+
+const BATCH_SIZE = 10;
+const BATCH_DELAY_MS = 1500;
+
+function delay(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function QueueItem({ request }: { request: OperatorLoomMigrationView }) {
+ const queryClient = useQueryClient();
+ const [nextStatus, setNextStatus] = useState(
+ request.status,
+ );
+ const [message, setMessage] = useState(request.capMessage ?? "");
+ const [expectedVideoCount, setExpectedVideoCount] = useState(
+ request.expectedVideoCount?.toString() ?? "",
+ );
+ const [importedVideoCount, setImportedVideoCount] = useState(
+ request.importedVideoCount.toString(),
+ );
+ const [verified, setVerified] = useState(false);
+ const [csvRows, setCsvRows] = useState([]);
+ const [processedRows, setProcessedRows] = useState(0);
+ const [startedRows, setStartedRows] = useState(0);
+ const [failedRows, setFailedRows] = useState([]);
+ const [fileLoomUrl, setFileLoomUrl] = useState("");
+ const [fileOwnerEmail, setFileOwnerEmail] = useState("");
+ const [fileSpaceName, setFileSpaceName] = useState("");
+ const [fileTitle, setFileTitle] = useState("");
+ const [file, setFile] = useState(null);
+ const [uploadPercent, setUploadPercent] = useState(0);
+
+ useEffect(() => {
+ setNextStatus(request.status);
+ setMessage(request.capMessage ?? "");
+ setExpectedVideoCount(request.expectedVideoCount?.toString() ?? "");
+ setImportedVideoCount(request.importedVideoCount.toString());
+ setVerified(false);
+ }, [
+ request.status,
+ request.capMessage,
+ request.expectedVideoCount,
+ request.importedVideoCount,
+ ]);
+
+ const refresh = () =>
+ queryClient.invalidateQueries({ queryKey: ["loom-migration-queue"] });
+ const statusMutation = useMutation({
+ mutationFn: () =>
+ updateLoomMigrationStatus({
+ requestId: request.id,
+ nextStatus,
+ message,
+ verified,
+ expectedVideoCount:
+ expectedVideoCount.trim() === "" ? null : Number(expectedVideoCount),
+ importedVideoCount: Number(importedVideoCount),
+ }),
+ onSuccess: async () => {
+ await refresh();
+ toast.success("Migration status updated.");
+ },
+ onError: (error) => toast.error(error.message),
+ });
+ const importMutation = useMutation({
+ mutationFn: async () => {
+ if (csvRows.length === 0) throw new Error("Choose a Loom CSV first.");
+ setProcessedRows(0);
+ setStartedRows(0);
+ setFailedRows([]);
+ let started = 0;
+ const failures: LoomCsvImportRowResult[] = [];
+ for (let index = 0; index < csvRows.length; index += BATCH_SIZE) {
+ const batch = csvRows.slice(index, index + BATCH_SIZE);
+ const result = await importFromLoomCsvForConcierge({
+ requestId: request.id,
+ rows: batch,
+ });
+ started += result.importedCount;
+ failures.push(...result.results.filter((row) => !row.success));
+ setProcessedRows(index + batch.length);
+ setStartedRows(started);
+ setFailedRows([...failures]);
+ if (index + BATCH_SIZE < csvRows.length) await delay(BATCH_DELAY_MS);
+ }
+ return { started, failures };
+ },
+ onSuccess: async (result) => {
+ await refresh();
+ toast.success(
+ `${result.started} imports started. ${result.failures.length} rows need review. Wait for processing and reconcile the library before completion.`,
+ );
+ },
+ onError: async (error) => {
+ await refresh();
+ toast.error(
+ `${error.message} Check the rows already started before retrying.`,
+ );
+ },
+ });
+ const fileMutation = useMutation({
+ mutationFn: async () => {
+ if (!file || !fileLoomUrl.trim() || !fileOwnerEmail.trim()) {
+ throw new Error("Choose an MP4 file, Loom URL, and destination owner.");
+ }
+ if (!file.name.toLowerCase().endsWith(".mp4")) {
+ throw new Error("Choose an MP4 downloaded from Loom.");
+ }
+ if (file.size === 0) throw new Error("The MP4 file is empty.");
+ const prepared = await createConciergeLoomFileUpload({
+ requestId: request.id,
+ loomUrl: fileLoomUrl,
+ userEmail: fileOwnerEmail,
+ spaceName: fileSpaceName,
+ videoTitle: fileTitle.trim() || file.name.replace(/\.mp4$/i, ""),
+ });
+ setUploadPercent(0);
+ await uploadWithTarget({
+ target: prepared.uploadTarget,
+ body: file,
+ fileName: file.name,
+ onProgress: ({ loaded, total }) => {
+ if (total > 0) setUploadPercent(Math.round((loaded / total) * 100));
+ },
+ });
+ return finishConciergeLoomFileUpload({
+ requestId: request.id,
+ videoId: prepared.videoId,
+ });
+ },
+ onSuccess: async (result) => {
+ await refresh();
+ setFile(null);
+ setUploadPercent(0);
+ toast.success(
+ result.status === "started"
+ ? "Loom file uploaded. Processing has started."
+ : "Loom file uploaded. Processing was already underway.",
+ );
+ },
+ onError: async (error) => {
+ await refresh();
+ toast.error(
+ `${error.message} The same Loom URL can be retried after checking its upload.`,
+ );
+ },
+ });
+
+ return (
+
+
+
+
+ {request.organizationName}
+
+
+ {request.requestedByEmail} · {request.organizationId} · Requested{" "}
+ {new Date(request.createdAt).toLocaleDateString()}
+
+
+
+ {LOOM_MIGRATION_STATUS_LABELS[request.status]}
+
+
+
+
Loom workspace: {request.workspaceName || "Not provided"}
+
+ Loom invite:{" "}
+ {request.invitedAt ? "Marked as sent" : "Waiting for customer"}
+
+
Jobs started: {request.queuedVideoCount}
+
Verified videos: {request.importedVideoCount}
+
+ {request.customerNote && (
+
+ Customer note: {request.customerNote}
+
+ )}
+ {request.customerReply && (
+
+ Latest customer answer: {request.customerReply}
+
+ )}
+
+
+
Import a mapped Loom CSV
+
+ Use loom_video_url,user_email,space_name. The destination is this Cap
+ organization; imports run in batches of 10. Started jobs still need
+ terminal processing checks.
+
+
+ Loom CSV
+
+
{
+ const file = event.target.files?.[0];
+ if (!file) return;
+ try {
+ setCsvRows(parseConciergeLoomCsv(await file.text()));
+ setProcessedRows(0);
+ setStartedRows(0);
+ setFailedRows([]);
+ } catch (error) {
+ setCsvRows([]);
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : "Could not read the CSV.",
+ );
+ }
+ }}
+ type="file"
+ />
+ {csvRows.length > 0 && (
+
+ {csvRows.length} rows ready. Review the source inventory and owner
+ mapping before import.
+
+ )}
+
importMutation.mutate()}
+ size="sm"
+ variant="dark"
+ >
+ {importMutation.isPending
+ ? `Importing ${processedRows}/${csvRows.length}`
+ : "Start import"}
+
+ {processedRows > 0 && (
+
+ {startedRows} jobs started; {failedRows.length} rows failed to
+ start.
+
+ )}
+ {failedRows.length > 0 && (
+
+ {failedRows.map((row) => (
+
+ Row {row.rowNumber}: {row.error || "Could not start import."}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+export function LoomMigrationQueue() {
+ const queueQuery = useQuery({
+ queryKey: ["loom-migration-queue"],
+ queryFn: getLoomMigrationOperatorQueue,
+ refetchInterval: 20_000,
+ });
+ return (
+
+
+
+ Loom migration queue
+
+
+ Process Cap Pro concierge requests, reconcile every import, and update
+ customers here.
+
+
+ {queueQuery.isPending && (
+
+ Loading requests…
+
+ )}
+ {queueQuery.isError && (
+
+ Could not load the queue. {queueQuery.error.message}
+ queueQuery.refetch()}
+ type="button"
+ >
+ Try again
+
+
+ )}
+ {queueQuery.data?.length === 0 && (
+
+ No active Loom migration requests.
+
+ )}
+ {queueQuery.data?.map((request) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/web/app/(org)/dashboard/admin/loom-migrations/page.tsx b/apps/web/app/(org)/dashboard/admin/loom-migrations/page.tsx
new file mode 100644
index 00000000000..2dc9ff503ae
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/admin/loom-migrations/page.tsx
@@ -0,0 +1,17 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { requireMigrationOperator } from "@/lib/loom-concierge";
+import { LoomMigrationQueue } from "./LoomMigrationQueue";
+
+export const metadata: Metadata = {
+ title: "Loom Migration Queue — Cap",
+};
+
+export default async function Page() {
+ try {
+ await requireMigrationOperator();
+ } catch {
+ notFound();
+ }
+ return ;
+}
diff --git a/apps/web/app/(org)/dashboard/import/ImportPage.tsx b/apps/web/app/(org)/dashboard/import/ImportPage.tsx
index 9712a3af2ec..1cf0fe84795 100644
--- a/apps/web/app/(org)/dashboard/import/ImportPage.tsx
+++ b/apps/web/app/(org)/dashboard/import/ImportPage.tsx
@@ -1,18 +1,34 @@
"use client";
-import { faUpload } from "@fortawesome/free-solid-svg-icons";
+import { buildEnv } from "@cap/env";
+import { faArrowsRotate, faUpload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Link from "next/link";
+import { useDashboardContext } from "@/app/(org)/dashboard/Contexts";
import {
type LoomImportDestination,
loomImportPageHref,
} from "@/lib/loom-import-destination";
+import {
+ canManageOrganizationSettings,
+ getEffectiveOrganizationRole,
+} from "@/lib/permissions/roles";
export const ImportPage = ({
initialDestination = {},
}: {
initialDestination?: LoomImportDestination;
}) => {
+ const { user, activeOrganization } = useDashboardContext();
+ const currentMember = activeOrganization?.members.find(
+ (member) => member.userId === user.id,
+ );
+ const currentRole = getEffectiveOrganizationRole({
+ userId: user.id,
+ ownerId: activeOrganization?.organization.ownerId,
+ memberRole: currentMember?.role,
+ });
+ const canRequestMigration = canManageOrganizationSettings(currentRole);
return (
@@ -71,6 +87,26 @@ export const ImportPage = ({
+ {buildEnv.NEXT_PUBLIC_IS_CAP && canRequestMigration && (
+
+
+
+
+ Concierge Loom Migration
+
+
+ Let Cap move your Loom workspace. Free with Cap Pro.
+
+
+
+ )}
);
diff --git a/apps/web/app/(org)/dashboard/import/loom/ImportLoomPage.tsx b/apps/web/app/(org)/dashboard/import/loom/ImportLoomPage.tsx
index ebca8079829..8e5db52a251 100644
--- a/apps/web/app/(org)/dashboard/import/loom/ImportLoomPage.tsx
+++ b/apps/web/app/(org)/dashboard/import/loom/ImportLoomPage.tsx
@@ -1,5 +1,6 @@
"use client";
+import { buildEnv } from "@cap/env";
import {
Button,
Dialog,
@@ -55,6 +56,7 @@ import {
} from "@/actions/loom";
import { useDashboardContext } from "@/app/(org)/dashboard/Contexts";
import { UpgradeModal } from "@/components/UpgradeModal";
+import { parseLoomCsvRecords } from "@/lib/loom-csv";
import {
type LoomImportDestination,
loomImportDestinationHref,
@@ -132,57 +134,8 @@ function buildCsvImportResult(
};
}
-function parseCsvRecords(text: string) {
- const records: string[][] = [];
- let field = "";
- let row: string[] = [];
- let inQuotes = false;
- const input = text.replace(/^\uFEFF/, "");
-
- for (let index = 0; index < input.length; index += 1) {
- const char = input.charAt(index);
- const next = input.charAt(index + 1);
-
- if (char === '"') {
- if (inQuotes && next === '"') {
- field += '"';
- index += 1;
- } else {
- inQuotes = !inQuotes;
- }
- continue;
- }
-
- if (char === "," && !inQuotes) {
- row.push(field.trim());
- field = "";
- continue;
- }
-
- if ((char === "\n" || char === "\r") && !inQuotes) {
- if (char === "\r" && next === "\n") index += 1;
- row.push(field.trim());
- if (row.some((cell) => cell.length > 0)) records.push(row);
- row = [];
- field = "";
- continue;
- }
-
- field += char;
- }
-
- if (inQuotes) throw new Error("CSV has an unclosed quoted field.");
-
- if (field.length > 0 || row.length > 0) {
- row.push(field.trim());
- if (row.some((cell) => cell.length > 0)) records.push(row);
- }
-
- return records;
-}
-
function parseCsv(text: string, fileName: string): CsvData {
- const records = parseCsvRecords(text);
+ const records = parseLoomCsvRecords(text);
const headers = records[0]?.map((header) => header.trim()) ?? [];
const rows = records
.slice(1)
@@ -601,6 +554,14 @@ export const ImportLoomPage = ({
? "Bring a single Loom video into Cap, or bulk import recordings for organization members and new users from a CSV."
: "Paste a Loom share link to bring it into Cap."}
+ {buildEnv.NEXT_PUBLIC_IS_CAP && canUseCsvImport && (
+
+ Want Cap to move your whole Loom workspace for you?
+
+ )}
diff --git a/apps/web/app/(org)/dashboard/migrations/loom/LoomMigrationPage.tsx b/apps/web/app/(org)/dashboard/migrations/loom/LoomMigrationPage.tsx
new file mode 100644
index 00000000000..63129848ccf
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/migrations/loom/LoomMigrationPage.tsx
@@ -0,0 +1,398 @@
+"use client";
+
+import { Button } from "@cap/ui";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { CheckCircle2, CircleHelp, Clock3, LoaderCircle } from "lucide-react";
+import Link from "next/link";
+import { useId, useState } from "react";
+import { toast } from "sonner";
+import {
+ answerLoomMigrationQuestion,
+ confirmLoomMigrationInvite,
+ getLoomMigrationDashboard,
+ requestLoomMigration,
+} from "@/actions/loom-concierge";
+import { useDashboardContext } from "@/app/(org)/dashboard/Contexts";
+import type { LoomMigrationView } from "@/lib/loom-concierge";
+import {
+ LOOM_MIGRATION_STATUS_LABELS,
+ type LoomMigrationStatus,
+} from "@/lib/loom-migration-state";
+
+const STATUS_STYLE: Record = {
+ pending: "bg-amber-3 text-amber-11",
+ in_progress: "bg-blue-3 text-blue-11",
+ needs_information: "bg-orange-3 text-orange-11",
+ completed: "bg-green-3 text-green-11",
+};
+
+function MigrationStatus({ request }: { request: LoomMigrationView }) {
+ return (
+
+
+
+
Your migration
+
+ Requested on {new Date(request.createdAt).toLocaleDateString()}
+ {request.workspaceName ? ` for ${request.workspaceName}` : ""}
+
+
+
+ {LOOM_MIGRATION_STATUS_LABELS[request.status]}
+
+
+ {request.status === "pending" && (
+
+ Your request is in Cap’s queue. We’ll review your Loom workspace and
+ confirm the migration plan.
+
+ )}
+ {request.status === "in_progress" && (
+
+ Cap is moving your videos. We’ll mark this complete after checking the
+ imported library.
+
+ )}
+ {request.status === "completed" && (
+
+
+
+
Your migration is complete.
+ {request.expectedVideoCount !== null && (
+
+ {request.importedVideoCount} of {request.expectedVideoCount}{" "}
+ videos verified in Cap.
+
+ )}
+
+ View your Caps
+
+
+
+ )}
+ {request.capMessage && (
+
+
Update from Cap
+
{request.capMessage}
+
+ )}
+ {request.customerReply && (
+
+ Your latest reply: {request.customerReply}
+
+ )}
+
+ );
+}
+
+export function LoomMigrationPage() {
+ const { activeOrganization, user } = useDashboardContext();
+ const organizationId = activeOrganization?.organization.id;
+ const queryClient = useQueryClient();
+ const [requestFormOpen, setRequestFormOpen] = useState(false);
+ const [workspaceName, setWorkspaceName] = useState("");
+ const [note, setNote] = useState("");
+ const [answer, setAnswer] = useState("");
+ const answerId = useId();
+ const workspaceNameId = useId();
+ const noteId = useId();
+
+ const migrationQuery = useQuery({
+ queryKey: ["loom-concierge", organizationId],
+ queryFn: () => {
+ if (!organizationId) throw new Error("Select a Cap workspace.");
+ return getLoomMigrationDashboard(organizationId);
+ },
+ enabled: Boolean(organizationId),
+ refetchInterval: 30_000,
+ });
+
+ const refresh = () =>
+ queryClient.invalidateQueries({
+ queryKey: ["loom-concierge", organizationId],
+ });
+ const requestMutation = useMutation({
+ mutationFn: () => {
+ if (!organizationId) throw new Error("Select a Cap workspace.");
+ return requestLoomMigration({ organizationId, workspaceName, note });
+ },
+ onSuccess: async (result) => {
+ await refresh();
+ setRequestFormOpen(false);
+ toast.success(
+ result.alreadyRequested
+ ? "Your migration request is already in the queue."
+ : "Migration requested. Invite hello@cap.so in Loom next.",
+ );
+ },
+ onError: (error) => toast.error(error.message),
+ });
+ const inviteMutation = useMutation({
+ mutationFn: confirmLoomMigrationInvite,
+ onSuccess: async () => {
+ await refresh();
+ toast.success("Thanks. Cap can now review your Loom invite.");
+ },
+ onError: (error) => toast.error(error.message),
+ });
+ const answerMutation = useMutation({
+ mutationFn: answerLoomMigrationQuestion,
+ onSuccess: async () => {
+ setAnswer("");
+ await refresh();
+ toast.success("Your answer has been sent to Cap.");
+ },
+ onError: (error) => toast.error(error.message),
+ });
+
+ const requests = migrationQuery.data?.requests ?? [];
+ const activeRequest = requests.find(
+ (request) => request.status !== "completed",
+ );
+ const completedRequest = requests.find(
+ (request) => request.status === "completed",
+ );
+ const isPro = migrationQuery.data?.isPro ?? false;
+
+ return (
+
+
+
+ Switch from Loom
+
+
+ Move your Loom library to Cap
+
+
+ Import it yourself, or let Cap handle the move. Concierge migration is
+ included with Cap Pro at no extra cost.
+
+
+
+ {!organizationId && (
+
+ Select a Cap workspace to request a Loom migration.
+
+ )}
+ {organizationId && migrationQuery.isPending && (
+
+ Loading your
+ migration…
+
+ )}
+ {migrationQuery.isError && (
+
+ Could not load your migration. {migrationQuery.error.message}
+ migrationQuery.refetch()}
+ type="button"
+ >
+ Try again
+
+
+ )}
+
+ {activeRequest &&
}
+ {!activeRequest && completedRequest && (
+
+ )}
+
+ {activeRequest && !activeRequest.invitedAt && (
+
+
+
+
+
+ One step left: invite Cap to Loom
+
+
+ In your Loom workspace settings, invite{" "}
+ hello@cap.so with video access and download
+ permission. Some private Library videos need access shared
+ separately. If we find any, we’ll ask you here. You can keep
+ using Loom until we confirm the migration is complete.
+
+
+ Loom’s workspace invite steps
+
+
inviteMutation.mutate(activeRequest.id)}
+ size="sm"
+ variant="dark"
+ >
+ I sent the Loom invite
+
+
+
+
+ )}
+ {activeRequest?.invitedAt && activeRequest.status !== "completed" && (
+
+ Loom invite marked as sent. Cap will take it from here.
+
+ )}
+ {activeRequest?.status === "needs_information" && (
+
{
+ event.preventDefault();
+ answerMutation.mutate({ requestId: activeRequest.id, answer });
+ }}
+ >
+
+ Cap needs a little
+ more information
+
+
+ Your answer
+
+ setAnswer(event.target.value)}
+ value={answer}
+ />
+
+ Send answer
+
+
+ )}
+
+
+
+
Do it yourself
+
+ Best when you already have a list of Loom links and video owners.
+
+
+
+ Prepare a CSV with loom_video_url and user_email. space_name is
+ optional.
+
+
+ Upload up to 500 videos per CSV and review the owner mapping.
+
+
+ Check the import results and your Cap library before leaving Loom.
+
+
+ {user.isPro ? (
+
+ Open CSV importer
+
+ ) : (
+
+ The self-service importer requires your own Cap Pro account.
+
+ )}
+
+
+
+
+ Cap handles it for you
+
+
+ Free with Pro
+
+
+
+ Request the migration and invite hello@cap.so to Loom. We’ll
+ inventory, move, and verify your videos. No CSV preparation needed.
+ If Loom restricts a video, we’ll ask for the specific access we
+ need.
+
+ {!migrationQuery.isPending && !migrationQuery.isError && !isPro && (
+
+ Your organization owner needs Cap Pro to request this service.
+
+ View billing
+
+
+ )}
+ {isPro && !activeRequest && !requestFormOpen && (
+
setRequestFormOpen(true)}
+ size="sm"
+ variant="dark"
+ >
+ Enable concierge migration
+
+ )}
+ {isPro && !activeRequest && requestFormOpen && (
+
{
+ event.preventDefault();
+ requestMutation.mutate();
+ }}
+ >
+
+
+ Loom workspace name{" "}
+ (optional)
+
+ setWorkspaceName(event.target.value)}
+ value={workspaceName}
+ />
+
+
+
+ Anything we should know?{" "}
+ (optional)
+
+ setNote(event.target.value)}
+ value={note}
+ />
+
+
+ Request migration
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/app/(org)/dashboard/migrations/loom/page.tsx b/apps/web/app/(org)/dashboard/migrations/loom/page.tsx
new file mode 100644
index 00000000000..bb06782ed70
--- /dev/null
+++ b/apps/web/app/(org)/dashboard/migrations/loom/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from "next";
+import { LoomMigrationPage } from "./LoomMigrationPage";
+
+export const metadata: Metadata = {
+ title: "Loom Migration — Cap",
+};
+
+export default function Page() {
+ return ;
+}
diff --git a/apps/web/content/docs/migrating-to-cap.mdx b/apps/web/content/docs/migrating-to-cap.mdx
index 50a99dedfe4..dcc74ca840e 100644
--- a/apps/web/content/docs/migrating-to-cap.mdx
+++ b/apps/web/content/docs/migrating-to-cap.mdx
@@ -14,9 +14,21 @@ A successful migration is not just a count of accepted URLs. It preserves owners
|---------|------------------|
| One Loom video | Paste the Loom URL in **Dashboard > Import > Loom** |
| Up to 500 mapped videos | Use the organization CSV importer |
-| More than 500 videos or a complex hierarchy | Run controlled batches with an agent or contact [hello@cap.so](mailto:hello@cap.so) for a managed migration |
+| A library you want Cap to move for you | Request the free Cap Pro concierge migration in **Dashboard > Loom Migration** |
+| More than 500 videos or a complex hierarchy | Request concierge migration or run controlled batches with an agent |
-Loom import is a Cap Pro feature. Bulk CSV import is available to organization owners and admins.
+Loom import is a Cap Pro feature. Bulk CSV import is available to organization owners and admins. Concierge migration is included at no extra cost when the Cap organization owner has Pro.
+
+## Request concierge migration
+
+1. Open **Dashboard > Loom Migration** and select your Cap organization.
+2. Choose **Enable concierge migration**, then **Request migration**. The request enters Cap's queue as **Pending**.
+3. Invite [hello@cap.so](mailto:hello@cap.so) to your Loom workspace with access and download permission for the videos you want moved, then mark the invite as sent in Cap.
+4. Cap inventories the source, maps owners and spaces, imports in batches, and checks the destination. The dashboard shows **In progress** while this is underway.
+5. If a video needs separate access or another detail, the dashboard shows **Information needed** with Cap's exact request. Reply there so the migration can continue.
+6. Cap marks **Migration complete** only after the agreed source count and imported library are reconciled and checked. Keep Loom available until that confirmation.
+
+Loom workspace membership may not expose private Library videos automatically. An account without Loom download rights also cannot export every video. Cap will ask for specific access when the workspace invite does not cover a source video.
## Prepare the source inventory
diff --git a/apps/web/lib/loom-concierge.ts b/apps/web/lib/loom-concierge.ts
new file mode 100644
index 00000000000..a8f50bf2845
--- /dev/null
+++ b/apps/web/lib/loom-concierge.ts
@@ -0,0 +1,134 @@
+import "server-only";
+
+import { db } from "@cap/database";
+import { getCurrentUser } from "@cap/database/auth/session";
+import {
+ loomMigrationRequests,
+ organizations,
+ users,
+} from "@cap/database/schema";
+import { buildEnv } from "@cap/env";
+import type { Organisation } from "@cap/web-domain";
+import { and, asc, desc, eq, isNull, ne } from "drizzle-orm";
+import { requireOrganizationSettingsManager } from "@/actions/organization/authorization";
+import { MESSENGER_ADMIN_EMAIL } from "@/lib/messenger/constants";
+import { isOrganizationOwnerPro } from "@/lib/org-pro";
+import type { LoomMigrationStatus } from "./loom-migration-state";
+
+type MigrationRecord = typeof loomMigrationRequests.$inferSelect;
+
+export type LoomMigrationView = {
+ id: string;
+ organizationId: Organisation.OrganisationId;
+ status: LoomMigrationStatus;
+ workspaceName: string | null;
+ customerNote: string | null;
+ customerReply: string | null;
+ invitedAt: string | null;
+ capMessage: string | null;
+ expectedVideoCount: number | null;
+ importedVideoCount: number;
+ queuedVideoCount: number;
+ completedAt: string | null;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type OperatorLoomMigrationView = LoomMigrationView & {
+ organizationName: string;
+ requestedByEmail: string;
+};
+
+export function migrationToView(record: MigrationRecord): LoomMigrationView {
+ return {
+ id: record.id,
+ organizationId: record.organizationId,
+ status: record.status,
+ workspaceName: record.workspaceName,
+ customerNote: record.customerNote,
+ customerReply: record.customerReply,
+ invitedAt: record.invitedAt?.toISOString() ?? null,
+ capMessage: record.capMessage,
+ expectedVideoCount: record.expectedVideoCount,
+ importedVideoCount: record.importedVideoCount,
+ queuedVideoCount: record.queuedVideoCount,
+ completedAt: record.completedAt?.toISOString() ?? null,
+ createdAt: record.createdAt.toISOString(),
+ updatedAt: record.updatedAt.toISOString(),
+ };
+}
+
+export async function requireCustomerMigrationAccess(
+ organizationId: Organisation.OrganisationId,
+) {
+ const user = await getCurrentUser();
+ if (!user) throw new Error("Unauthorized");
+ await requireOrganizationSettingsManager(user.id, organizationId);
+ return user;
+}
+
+export async function requireProCustomerMigrationAccess(
+ organizationId: Organisation.OrganisationId,
+) {
+ const user = await requireCustomerMigrationAccess(organizationId);
+ if (!buildEnv.NEXT_PUBLIC_IS_CAP) {
+ throw new Error("Concierge migration is available on Cap Cloud.");
+ }
+ if (!(await isOrganizationOwnerPro(organizationId))) {
+ throw new Error("Concierge Loom migration requires Cap Pro.");
+ }
+ return user;
+}
+
+export async function requireMigrationOperator() {
+ const user = await getCurrentUser();
+ if (
+ !buildEnv.NEXT_PUBLIC_IS_CAP ||
+ !user ||
+ user.email.toLowerCase() !== MESSENGER_ADMIN_EMAIL
+ ) {
+ throw new Error("Unauthorized");
+ }
+ return user;
+}
+
+export async function getCustomerMigrationRequests(
+ organizationId: Organisation.OrganisationId,
+) {
+ await requireCustomerMigrationAccess(organizationId);
+ const requests = await db()
+ .select()
+ .from(loomMigrationRequests)
+ .where(eq(loomMigrationRequests.organizationId, organizationId))
+ .orderBy(desc(loomMigrationRequests.createdAt))
+ .limit(20);
+ return requests.map(migrationToView);
+}
+
+export async function getOperatorMigrationQueue() {
+ await requireMigrationOperator();
+ const queue = await db()
+ .select({
+ request: loomMigrationRequests,
+ organizationName: organizations.name,
+ requestedByEmail: users.email,
+ })
+ .from(loomMigrationRequests)
+ .innerJoin(
+ organizations,
+ eq(organizations.id, loomMigrationRequests.organizationId),
+ )
+ .innerJoin(users, eq(users.id, loomMigrationRequests.requestedByUserId))
+ .where(
+ and(
+ ne(loomMigrationRequests.status, "completed"),
+ isNull(organizations.tombstoneAt),
+ ),
+ )
+ .orderBy(asc(loomMigrationRequests.createdAt));
+ return queue.map((item) => ({
+ ...migrationToView(item.request),
+ organizationName: item.organizationName,
+ requestedByEmail: item.requestedByEmail,
+ }));
+}
diff --git a/apps/web/lib/loom-csv.ts b/apps/web/lib/loom-csv.ts
new file mode 100644
index 00000000000..871eef779a5
--- /dev/null
+++ b/apps/web/lib/loom-csv.ts
@@ -0,0 +1,72 @@
+import type { LoomCsvImportRow } from "@/actions/loom";
+
+export function parseLoomCsvRecords(text: string) {
+ const records: string[][] = [];
+ let field = "";
+ let row: string[] = [];
+ let inQuotes = false;
+ const input = text.replace(/^\uFEFF/, "");
+
+ for (let index = 0; index < input.length; index += 1) {
+ const char = input.charAt(index);
+ const next = input.charAt(index + 1);
+
+ if (char === '"') {
+ if (inQuotes && next === '"') {
+ field += '"';
+ index += 1;
+ } else {
+ inQuotes = !inQuotes;
+ }
+ continue;
+ }
+
+ if (char === "," && !inQuotes) {
+ row.push(field.trim());
+ field = "";
+ continue;
+ }
+
+ if ((char === "\n" || char === "\r") && !inQuotes) {
+ if (char === "\r" && next === "\n") index += 1;
+ row.push(field.trim());
+ if (row.some((cell) => cell.length > 0)) records.push(row);
+ row = [];
+ field = "";
+ continue;
+ }
+
+ field += char;
+ }
+
+ if (inQuotes) throw new Error("CSV has an unclosed quoted field.");
+
+ if (field.length > 0 || row.length > 0) {
+ row.push(field.trim());
+ if (row.some((cell) => cell.length > 0)) records.push(row);
+ }
+
+ return records;
+}
+
+export function parseConciergeLoomCsv(text: string): LoomCsvImportRow[] {
+ const records = parseLoomCsvRecords(text);
+ const headers = records[0]?.map((value) => value.toLowerCase()) ?? [];
+ const loomUrlIndex = headers.indexOf("loom_video_url");
+ const userEmailIndex = headers.indexOf("user_email");
+ const spaceNameIndex = headers.indexOf("space_name");
+ if (loomUrlIndex < 0 || userEmailIndex < 0) {
+ throw new Error("CSV needs loom_video_url and user_email columns.");
+ }
+ const rows = records.slice(1).map((values, index) => ({
+ rowNumber: index + 2,
+ loomUrl: values[loomUrlIndex] ?? "",
+ userEmail: values[userEmailIndex] ?? "",
+ spaceName: spaceNameIndex < 0 ? undefined : values[spaceNameIndex],
+ }));
+ if (rows.length === 0) throw new Error("CSV has no video rows.");
+ if (rows.length > 500) {
+ throw new Error("Split the library into CSV files of up to 500 videos.");
+ }
+ return rows;
+}
diff --git a/apps/web/lib/loom-migration-state.ts b/apps/web/lib/loom-migration-state.ts
new file mode 100644
index 00000000000..1e2791df990
--- /dev/null
+++ b/apps/web/lib/loom-migration-state.ts
@@ -0,0 +1,80 @@
+export const LOOM_MIGRATION_STATUSES = [
+ "pending",
+ "in_progress",
+ "needs_information",
+ "completed",
+] as const;
+
+export type LoomMigrationStatus = (typeof LOOM_MIGRATION_STATUSES)[number];
+
+export const LOOM_MIGRATION_STATUS_LABELS: Record =
+ {
+ pending: "Pending",
+ in_progress: "In progress",
+ needs_information: "Information needed",
+ completed: "Migration complete",
+ };
+
+export function normalizeMigrationText(
+ value: string,
+ label: string,
+ maxLength: number,
+) {
+ if (typeof value !== "string") throw new Error(`${label} is invalid.`);
+ const normalized = value.trim().replace(/\s+/g, " ");
+ if (normalized.length > maxLength) {
+ throw new Error(`${label} must be ${maxLength} characters or fewer.`);
+ }
+ return normalized;
+}
+
+export function validateOperatorMigrationUpdate({
+ currentStatus,
+ nextStatus,
+ message,
+ verified,
+ expectedVideoCount,
+ importedVideoCount,
+}: {
+ currentStatus: LoomMigrationStatus;
+ nextStatus: LoomMigrationStatus;
+ message: string;
+ verified: boolean;
+ expectedVideoCount: number | null;
+ importedVideoCount: number;
+}) {
+ if (currentStatus === "completed") {
+ throw new Error("A completed migration cannot be changed.");
+ }
+ if (!LOOM_MIGRATION_STATUSES.includes(nextStatus)) {
+ throw new Error("Invalid migration status.");
+ }
+ const normalizedMessage = normalizeMigrationText(message, "Message", 2000);
+ if (nextStatus === "needs_information" && !normalizedMessage) {
+ throw new Error("Tell the customer what information you need.");
+ }
+ if (nextStatus === "completed" && !verified) {
+ throw new Error("Verify the migrated library before marking it complete.");
+ }
+ if (nextStatus === "completed" && expectedVideoCount === null) {
+ throw new Error("Enter the agreed source video count before completion.");
+ }
+ if (
+ !Number.isInteger(importedVideoCount) ||
+ importedVideoCount < 0 ||
+ (expectedVideoCount !== null &&
+ (!Number.isInteger(expectedVideoCount) ||
+ expectedVideoCount < 0 ||
+ importedVideoCount > expectedVideoCount))
+ ) {
+ throw new Error("Enter valid video counts.");
+ }
+ if (
+ nextStatus === "completed" &&
+ expectedVideoCount !== null &&
+ importedVideoCount !== expectedVideoCount
+ ) {
+ throw new Error("Reconcile the expected video count before completion.");
+ }
+ return normalizedMessage;
+}
diff --git a/packages/database/emails/loom-migration.tsx b/packages/database/emails/loom-migration.tsx
new file mode 100644
index 00000000000..f65ffdfaaff
--- /dev/null
+++ b/packages/database/emails/loom-migration.tsx
@@ -0,0 +1,70 @@
+import {
+ Body,
+ Container,
+ Head,
+ Heading,
+ Html,
+ Link,
+ Preview,
+ Text,
+} from "@react-email/components";
+
+export function LoomMigrationRequestEmail({
+ organizationName,
+ requesterEmail,
+ workspaceName,
+ queueUrl,
+}: {
+ organizationName: string;
+ requesterEmail: string;
+ workspaceName: string | null;
+ queueUrl: string;
+}) {
+ return (
+
+
+ New Loom migration request from {organizationName}
+
+
+ New Loom migration request
+
+ {organizationName} requested a Cap Pro concierge migration.
+
+ Requested by: {requesterEmail}
+ {workspaceName && Loom workspace: {workspaceName} }
+ Open the migration queue
+
+
+
+ );
+}
+
+export function LoomMigrationStatusEmail({
+ organizationName,
+ statusLabel,
+ message,
+ dashboardUrl,
+}: {
+ organizationName: string;
+ statusLabel: string;
+ message: string | null;
+ dashboardUrl: string;
+}) {
+ return (
+
+
+ Your Loom migration is {statusLabel.toLowerCase()}
+
+
+ Loom migration update
+
+ Your migration for {organizationName} is {statusLabel.toLowerCase()}
+ .
+
+ {message && {message} }
+ View your migration
+
+
+
+ );
+}
diff --git a/packages/database/migrations/0046_loom_concierge.sql b/packages/database/migrations/0046_loom_concierge.sql
new file mode 100644
index 00000000000..778f5ef9a7e
--- /dev/null
+++ b/packages/database/migrations/0046_loom_concierge.sql
@@ -0,0 +1,25 @@
+CREATE TABLE `loom_migration_requests` (
+ `id` varchar(15) NOT NULL,
+ `organizationId` varchar(15) NOT NULL,
+ `activeOrganizationId` varchar(15),
+ `requestedByUserId` varchar(15) NOT NULL,
+ `workspaceName` varchar(255),
+ `customerNote` text,
+ `customerReply` text,
+ `invitedAt` datetime,
+ `status` varchar(32) NOT NULL DEFAULT 'pending',
+ `capMessage` text,
+ `expectedVideoCount` int,
+ `importedVideoCount` int NOT NULL DEFAULT 0,
+ `queuedVideoCount` int NOT NULL DEFAULT 0,
+ `lastOperatorUserId` varchar(15),
+ `lastOperatorAt` datetime,
+ `completedAt` datetime,
+ `createdAt` datetime NOT NULL,
+ `updatedAt` datetime NOT NULL,
+ CONSTRAINT `loom_migration_requests_id` PRIMARY KEY(`id`),
+ CONSTRAINT `loom_migration_active_org_idx` UNIQUE(`activeOrganizationId`)
+);
+--> statement-breakpoint
+CREATE INDEX `loom_migration_org_created_idx` ON `loom_migration_requests` (`organizationId`,`createdAt`);--> statement-breakpoint
+CREATE INDEX `loom_migration_status_created_idx` ON `loom_migration_requests` (`status`,`createdAt`);
\ No newline at end of file
diff --git a/packages/database/migrations/meta/0046_snapshot.json b/packages/database/migrations/meta/0046_snapshot.json
new file mode 100644
index 00000000000..26dad24dff2
--- /dev/null
+++ b/packages/database/migrations/meta/0046_snapshot.json
@@ -0,0 +1,4735 @@
+{
+ "version": "5",
+ "dialect": "mysql",
+ "id": "e73cfec4-89dd-4fc2-9068-4a86b6e10171",
+ "prevId": "0d80e4e5-6d93-4d28-b235-bf1231baa7a9",
+ "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
+ }
+ },
+ "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_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_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
+ },
+ "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": {
+ "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
+ }
+ },
+ "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": {}
+ },
+ "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_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
+ },
+ "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": {}
+ },
+ "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": {}
+ },
+ "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
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "notifications_id": {
+ "name": "notifications_id",
+ "columns": ["id"]
+ }
+ },
+ "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
+ },
+ "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
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "video_uploads_video_id": {
+ "name": "video_uploads_video_id",
+ "columns": ["video_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
+ },
+ "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_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 014bc42ce7d..d8e7a8780af 100644
--- a/packages/database/migrations/meta/_journal.json
+++ b/packages/database/migrations/meta/_journal.json
@@ -323,6 +323,13 @@
"when": 1789136779085,
"tag": "0045_steep_forge",
"breakpoints": true
+ },
+ {
+ "idx": 46,
+ "version": "5",
+ "when": 1789985586222,
+ "tag": "0046_loom_concierge",
+ "breakpoints": true
}
]
}
diff --git a/packages/database/schema.ts b/packages/database/schema.ts
index 7df0825ede2..d91b21bf257 100644
--- a/packages/database/schema.ts
+++ b/packages/database/schema.ts
@@ -1548,6 +1548,57 @@ export const importedVideos = mysqlTable(
],
);
+export const loomMigrationRequests = mysqlTable(
+ "loom_migration_requests",
+ {
+ id: nanoId("id").notNull().primaryKey(),
+ organizationId: nanoId("organizationId")
+ .notNull()
+ .$type(),
+ activeOrganizationId: nanoIdNullable(
+ "activeOrganizationId",
+ ).$type(),
+ requestedByUserId: nanoId("requestedByUserId")
+ .notNull()
+ .$type(),
+ workspaceName: varchar("workspaceName", { length: 255 }),
+ customerNote: text("customerNote"),
+ customerReply: text("customerReply"),
+ invitedAt: datetime("invitedAt", { mode: "date" }),
+ status: varchar("status", {
+ length: 32,
+ enum: ["pending", "in_progress", "needs_information", "completed"],
+ })
+ .notNull()
+ .default("pending"),
+ capMessage: text("capMessage"),
+ expectedVideoCount: int("expectedVideoCount"),
+ importedVideoCount: int("importedVideoCount").notNull().default(0),
+ queuedVideoCount: int("queuedVideoCount").notNull().default(0),
+ lastOperatorUserId:
+ nanoIdNullable("lastOperatorUserId").$type(),
+ lastOperatorAt: datetime("lastOperatorAt", { mode: "date" }),
+ completedAt: datetime("completedAt", { mode: "date" }),
+ createdAt: datetime("createdAt", { mode: "date" })
+ .notNull()
+ .$defaultFn(() => new Date()),
+ updatedAt: datetime("updatedAt", { mode: "date" })
+ .notNull()
+ .$defaultFn(() => new Date()),
+ },
+ (table) => [
+ uniqueIndex("loom_migration_active_org_idx").on(table.activeOrganizationId),
+ index("loom_migration_org_created_idx").on(
+ table.organizationId,
+ table.createdAt,
+ ),
+ index("loom_migration_status_created_idx").on(
+ table.status,
+ table.createdAt,
+ ),
+ ],
+);
+
export const developerApps = mysqlTable(
"developer_apps",
{
From 8df6730293da54bdfac1ed34f653a0b8d19e7997 Mon Sep 17 00:00:00 2001
From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com>
Date: Mon, 21 Sep 2026 11:52:20 +0100
Subject: [PATCH 02/74] fix: register Loom migration emails
---
emails/CATALOG.md | 7 +++++++
emails/application.ts | 10 ++++++++++
2 files changed, 17 insertions(+)
diff --git a/emails/CATALOG.md b/emails/CATALOG.md
index ccc962d4bae..4b8bc43a5a3 100644
--- a/emails/CATALOG.md
+++ b/emails/CATALOG.md
@@ -618,6 +618,7 @@ These are repository send paths and retained templates, not confirmation of prod
| Messenger support notification | An eligible support conversation requests an email notification | Cap support; replies go to the user | [messenger-support-email](../packages/database/emails/messenger-support-email.tsx) |
| Account deletion request notification | An account deletion request is submitted | Cap support; replies go to the requester | [messenger-support-email](../packages/database/emails/messenger-support-email.tsx) |
| Mobile content report notification | A mobile content report is submitted | Cap support; replies go to the reporter | [messenger-support-email](../packages/database/emails/messenger-support-email.tsx) |
+| Concierge Loom migration | A Cap Pro organization requests migration or its status changes | Cap support for new requests; the requester for status updates | [loom-migration](../packages/database/emails/loom-migration.tsx) |
| Legacy login link template | No current send call found in this checkout | None configured | [login-link](../packages/database/emails/login-link.tsx) |
### Login verification code
@@ -692,6 +693,12 @@ Deduplicated by report ID.
Send source: [apps/web/lib/account-deletion-request.ts](../apps/web/lib/account-deletion-request.ts).
+### Concierge Loom migration
+
+The request stays in the dashboard even if email delivery fails.
+
+Send source: [apps/web/actions/loom-concierge.tsx](../apps/web/actions/loom-concierge.tsx).
+
### Legacy login link template
Retained source template; current email login uses verification codes.
diff --git a/emails/application.ts b/emails/application.ts
index fed7fbd3f43..60fe3d64287 100644
--- a/emails/application.ts
+++ b/emails/application.ts
@@ -131,6 +131,16 @@ export const applicationEmails: ApplicationEmail[] = [
template: "messenger-support-email",
sources: ["apps/web/lib/account-deletion-request.ts"],
},
+ {
+ id: "loom-migration",
+ name: "Concierge Loom migration",
+ trigger: "A Cap Pro organization requests migration or its status changes",
+ recipients:
+ "Cap support for new requests; the requester for status updates",
+ notes: "The request stays in the dashboard even if email delivery fails.",
+ template: "loom-migration",
+ sources: ["apps/web/actions/loom-concierge.tsx"],
+ },
{
id: "login-link",
name: "Legacy login link template",
From 1b6cb9dd439ffe378539b651f2c42b7c4a56bb8f Mon Sep 17 00:00:00 2001
From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com>
Date: Mon, 21 Sep 2026 12:01:09 +0100
Subject: [PATCH 03/74] fix: coordinate concierge import completion
---
apps/web/actions/loom-concierge.tsx | 20 +-
apps/web/actions/loom.ts | 545 +++++++++---------
.../loom-migrations/LoomMigrationQueue.tsx | 4 +-
apps/web/lib/loom-concierge.ts | 67 ++-
.../migrations/0046_loom_concierge.sql | 9 +
.../migrations/meta/0046_snapshot.json | 52 +-
.../database/migrations/meta/_journal.json | 2 +-
packages/database/schema.ts | 13 +
8 files changed, 434 insertions(+), 278 deletions(-)
diff --git a/apps/web/actions/loom-concierge.tsx b/apps/web/actions/loom-concierge.tsx
index b1a91d30f14..65835952a30 100644
--- a/apps/web/actions/loom-concierge.tsx
+++ b/apps/web/actions/loom-concierge.tsx
@@ -8,7 +8,7 @@ import {
} from "@cap/database/emails/loom-migration";
import { nanoId } from "@cap/database/helpers";
import {
- importedVideos,
+ loomMigrationImports,
loomMigrationRequests,
organizations,
users,
@@ -246,12 +246,14 @@ export async function updateLoomMigrationStatus({
if (nextStatus === "completed") {
const [unfinishedImport] = await db()
.select({ videoId: videoUploads.videoId })
- .from(importedVideos)
- .innerJoin(videoUploads, eq(videoUploads.videoId, importedVideos.id))
+ .from(loomMigrationImports)
+ .innerJoin(
+ videoUploads,
+ eq(videoUploads.videoId, loomMigrationImports.videoId),
+ )
.where(
and(
- eq(importedVideos.orgId, request.organizationId),
- eq(importedVideos.source, "loom"),
+ eq(loomMigrationImports.requestId, requestId),
inArray(videoUploads.phase, [
"uploading",
"processing",
@@ -290,10 +292,16 @@ export async function updateLoomMigrationStatus({
and(
eq(loomMigrationRequests.id, requestId),
eq(loomMigrationRequests.status, request.status),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ nextStatus === "completed"
+ ? eq(loomMigrationRequests.activeImportCount, 0)
+ : undefined,
),
);
if (affectedRows(result) === 0) {
- throw new Error("The migration request changed. Refresh and try again.");
+ throw new Error(
+ "The migration request changed or imports are starting. Refresh and try again.",
+ );
}
const [requester] = await db()
diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts
index 7cb49e13fac..2083d85dc0d 100644
--- a/apps/web/actions/loom.ts
+++ b/apps/web/actions/loom.ts
@@ -7,6 +7,7 @@ import { nanoId } from "@cap/database/helpers";
import {
folders,
importedVideos,
+ loomMigrationImports,
loomMigrationRequests,
organizationMembers,
organizations,
@@ -39,7 +40,11 @@ import {
requireOrganizationSettingsManager,
} from "@/actions/organization/authorization";
import { requireSpaceManager } from "@/actions/organization/space-authorization";
-import { requireMigrationOperator } from "@/lib/loom-concierge";
+import {
+ releaseConciergeImport,
+ requireMigrationOperator,
+ reserveConciergeImport,
+} from "@/lib/loom-concierge";
import type { LoomImportDestination } from "@/lib/loom-import-destination";
import { isOrganizationOwnerPro } from "@/lib/org-pro";
import { provisionOrganizationInvitee } from "@/lib/organization-provisioning";
@@ -104,15 +109,6 @@ const LOOM_CSV_LIMIT_ERROR = `CSV imports are limited to ${MAX_LOOM_CSV_ROWS} ro
const LOOM_CSV_PERMISSION_ERROR =
"Only organization admins and owners can import Loom videos from a CSV.";
-function affectedRows(result: unknown) {
- if (Array.isArray(result)) {
- return (
- (result[0] as { affectedRows?: number } | undefined)?.affectedRows ?? 0
- );
- }
- return (result as { affectedRows?: number } | undefined)?.affectedRows ?? 0;
-}
-
function extractLoomVideoId(url: string): string | null {
try {
const parsed = new URL(url);
@@ -319,11 +315,13 @@ async function importLoomVideoForOwner({
orgId,
ownerId,
destination = {},
+ migrationRequestId,
}: {
loomUrl: string;
orgId: Organisation.OrganisationId;
ownerId: User.UserId;
destination?: LoomImportDestination;
+ migrationRequestId?: string;
}): Promise {
const loomVideoId = extractLoomVideoId(loomUrl.trim());
if (!loomVideoId) {
@@ -442,6 +440,12 @@ async function importLoomVideoForOwner({
source: "loom",
sourceId: loomVideoId,
});
+ if (migrationRequestId) {
+ await tx.insert(loomMigrationImports).values({
+ videoId,
+ requestId: migrationRequestId,
+ });
+ }
if (destination.spaceId === orgId) {
await tx.insert(sharedVideos).values({
@@ -796,10 +800,12 @@ async function processLoomCsvRows({
rows,
orgId,
actorId,
+ migrationRequestId,
}: {
rows: LoomCsvImportRow[];
orgId: Organisation.OrganisationId;
actorId: User.UserId;
+ migrationRequestId?: string;
}): Promise {
const inputRows = Array.isArray(rows) ? rows : [];
const normalizedRows = inputRows
@@ -909,6 +915,7 @@ async function processLoomCsvRows({
loomUrl: row.loomUrl,
orgId,
ownerId: member.userId,
+ migrationRequestId,
});
let spaceName = row.spaceName || undefined;
@@ -1012,52 +1019,31 @@ export async function importFromLoomCsvForConcierge({
if (!(await isOrganizationOwnerPro(request.organizationId))) {
throw new Error("The destination workspace needs Cap Pro.");
}
- const claimed = await db()
- .update(loomMigrationRequests)
- .set({
- status: "in_progress",
- lastOperatorUserId: operator.id,
- lastOperatorAt: new Date(),
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(loomMigrationRequests.id, requestId),
- isNotNull(loomMigrationRequests.activeOrganizationId),
- ),
- );
- if (affectedRows(claimed) === 0) {
- throw new Error("The migration request changed. Refresh and try again.");
- }
- const result = await processLoomCsvRows({
- rows,
- orgId: request.organizationId,
- actorId: request.ownerId,
- });
- if (result.importedCount > 0) {
- const updated = await db()
- .update(loomMigrationRequests)
- .set({
- queuedVideoCount: sql`${loomMigrationRequests.queuedVideoCount} + ${result.importedCount}`,
- lastOperatorUserId: operator.id,
- lastOperatorAt: new Date(),
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(loomMigrationRequests.id, requestId),
- isNotNull(loomMigrationRequests.activeOrganizationId),
- ),
- );
- if (affectedRows(updated) === 0) {
- throw new Error(
- "The migration request changed after jobs started. Review them before retrying.",
- );
+ await reserveConciergeImport(requestId, operator.id);
+ try {
+ const result = await processLoomCsvRows({
+ rows,
+ orgId: request.organizationId,
+ actorId: request.ownerId,
+ migrationRequestId: requestId,
+ });
+ if (result.importedCount > 0) {
+ await db()
+ .update(loomMigrationRequests)
+ .set({
+ queuedVideoCount: sql`${loomMigrationRequests.queuedVideoCount} + ${result.importedCount}`,
+ lastOperatorUserId: operator.id,
+ lastOperatorAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(loomMigrationRequests.id, requestId));
}
+ revalidatePath("/dashboard/migrations/loom");
+ revalidatePath("/dashboard/admin/loom-migrations");
+ return result;
+ } finally {
+ await releaseConciergeImport(requestId);
}
- revalidatePath("/dashboard/migrations/loom");
- revalidatePath("/dashboard/admin/loom-migrations");
- return result;
}
export async function createConciergeLoomFileUpload({
@@ -1116,161 +1102,173 @@ export async function createConciergeLoomFileUpload({
if (!(await isOrganizationOwnerPro(request.organizationId))) {
throw new Error("The destination workspace needs Cap Pro.");
}
- const [existing] = await db()
- .select({
- id: importedVideos.id,
- ownerId: videos.ownerId,
- ownerEmail: users.email,
- bucket: videos.bucket,
- storageIntegrationId: videos.storageIntegrationId,
- rawFileKey: videoUploads.rawFileKey,
- phase: videoUploads.phase,
- processingMessage: videoUploads.processingMessage,
- })
- .from(importedVideos)
- .leftJoin(
- videos,
- and(
- eq(videos.id, importedVideos.id),
- eq(videos.orgId, importedVideos.orgId),
- ),
- )
- .leftJoin(users, eq(users.id, videos.ownerId))
- .leftJoin(videoUploads, eq(videoUploads.videoId, importedVideos.id))
- .where(
- and(
- eq(importedVideos.orgId, request.organizationId),
- eq(importedVideos.source, "loom"),
- eq(importedVideos.sourceId, loomVideoId),
- ),
- )
- .limit(1);
- if (existing) {
- if (
- !existing.ownerId ||
- !existing.ownerEmail ||
- normalizeImportEmail(existing.ownerEmail) !== normalizedEmail ||
- !existing.rawFileKey ||
- (existing.phase !== "error" &&
- (existing.phase !== "uploading" ||
- existing.processingMessage !== "Uploading Loom video..."))
- ) {
- throw new Error(
- "This Loom video is already in the Cap import inventory.",
- );
+ await reserveConciergeImport(requestId, operator.id);
+ try {
+ const [existing] = await db()
+ .select({
+ id: importedVideos.id,
+ ownerId: videos.ownerId,
+ ownerEmail: users.email,
+ bucket: videos.bucket,
+ storageIntegrationId: videos.storageIntegrationId,
+ rawFileKey: videoUploads.rawFileKey,
+ phase: videoUploads.phase,
+ processingMessage: videoUploads.processingMessage,
+ })
+ .from(importedVideos)
+ .leftJoin(
+ videos,
+ and(
+ eq(videos.id, importedVideos.id),
+ eq(videos.orgId, importedVideos.orgId),
+ ),
+ )
+ .leftJoin(users, eq(users.id, videos.ownerId))
+ .leftJoin(videoUploads, eq(videoUploads.videoId, importedVideos.id))
+ .where(
+ and(
+ eq(importedVideos.orgId, request.organizationId),
+ eq(importedVideos.source, "loom"),
+ eq(importedVideos.sourceId, loomVideoId),
+ ),
+ )
+ .limit(1);
+ if (existing) {
+ if (
+ !existing.ownerId ||
+ !existing.ownerEmail ||
+ normalizeImportEmail(existing.ownerEmail) !== normalizedEmail ||
+ !existing.rawFileKey ||
+ (existing.phase !== "error" &&
+ (existing.phase !== "uploading" ||
+ existing.processingMessage !== "Uploading Loom video..."))
+ ) {
+ throw new Error(
+ "This Loom video is already in the Cap import inventory.",
+ );
+ }
+ const retryUpload = await Storage.createUploadTargetForUser(
+ existing.ownerId,
+ existing.rawFileKey,
+ {
+ contentType: "video/mp4",
+ videoTitle: normalizedTitle,
+ method: "put",
+ fields: { "x-amz-meta-userid": existing.ownerId },
+ },
+ request.organizationId,
+ ).pipe(runPromise);
+ if (
+ Option.getOrNull(retryUpload.bucketId) !== existing.bucket ||
+ Option.getOrNull(retryUpload.storageIntegrationId) !==
+ existing.storageIntegrationId
+ ) {
+ throw new Error(
+ "The Cap storage destination changed. Review this upload before retrying.",
+ );
+ }
+ const [mapped] = await db()
+ .select({ requestId: loomMigrationImports.requestId })
+ .from(loomMigrationImports)
+ .where(
+ eq(loomMigrationImports.videoId, Video.VideoId.make(existing.id)),
+ )
+ .limit(1);
+ if (mapped && mapped.requestId !== requestId) {
+ throw new Error(
+ "This Loom import belongs to another migration request.",
+ );
+ }
+ if (!mapped) {
+ await db()
+ .insert(loomMigrationImports)
+ .values({
+ videoId: Video.VideoId.make(existing.id),
+ requestId,
+ });
+ }
+ return {
+ videoId: Video.VideoId.make(existing.id),
+ uploadTarget: retryUpload.upload,
+ };
+ }
+ let member = await getOrganizationMemberByEmail(
+ request.organizationId,
+ normalizedEmail,
+ );
+ if (!member) {
+ const provisioned = await provisionOrganizationInvitee({
+ organizationId: request.organizationId,
+ email: normalizedEmail,
+ invitedByUserId: request.ownerId,
+ role: "member",
+ });
+ member = { userId: provisioned.userId, email: normalizedEmail };
}
- const retryUpload = await Storage.createUploadTargetForUser(
- existing.ownerId,
- existing.rawFileKey,
+ const space = normalizedSpaceName
+ ? await getOrCreateImportSpace({
+ orgId: request.organizationId,
+ createdById: request.ownerId,
+ name: normalizedSpaceName,
+ spaceCache: new Map(),
+ })
+ : null;
+ if (space) {
+ await addImportOwnerToSpace({ spaceId: space.id, userId: member.userId });
+ }
+ const videoId = Video.VideoId.make(nanoId());
+ const rawFileKey = `${member.userId}/${videoId}/raw-upload.mp4`;
+ const upload = await Storage.createUploadTargetForUser(
+ member.userId,
+ rawFileKey,
{
contentType: "video/mp4",
videoTitle: normalizedTitle,
method: "put",
- fields: { "x-amz-meta-userid": existing.ownerId },
+ fields: { "x-amz-meta-userid": member.userId },
},
request.organizationId,
).pipe(runPromise);
- if (
- Option.getOrNull(retryUpload.bucketId) !== existing.bucket ||
- Option.getOrNull(retryUpload.storageIntegrationId) !==
- existing.storageIntegrationId
- ) {
- throw new Error(
- "The Cap storage destination changed. Review this upload before retrying.",
- );
- }
- return {
- videoId: Video.VideoId.make(existing.id),
- uploadTarget: retryUpload.upload,
- };
- }
- let member = await getOrganizationMemberByEmail(
- request.organizationId,
- normalizedEmail,
- );
- if (!member) {
- const provisioned = await provisionOrganizationInvitee({
- organizationId: request.organizationId,
- email: normalizedEmail,
- invitedByUserId: request.ownerId,
- role: "member",
- });
- member = { userId: provisioned.userId, email: normalizedEmail };
- }
- const space = normalizedSpaceName
- ? await getOrCreateImportSpace({
+ await db().transaction(async (tx) => {
+ await tx.insert(videos).values({
+ id: videoId,
+ name: normalizedTitle,
+ ownerId: member.userId,
orgId: request.organizationId,
- createdById: request.ownerId,
- name: normalizedSpaceName,
- spaceCache: new Map(),
- })
- : null;
- if (space) {
- await addImportOwnerToSpace({ spaceId: space.id, userId: member.userId });
- }
- const videoId = Video.VideoId.make(nanoId());
- const rawFileKey = `${member.userId}/${videoId}/raw-upload.mp4`;
- const upload = await Storage.createUploadTargetForUser(
- member.userId,
- rawFileKey,
- {
- contentType: "video/mp4",
- videoTitle: normalizedTitle,
- method: "put",
- fields: { "x-amz-meta-userid": member.userId },
- },
- request.organizationId,
- ).pipe(runPromise);
- await db().transaction(async (tx) => {
- await tx.insert(videos).values({
- id: videoId,
- name: normalizedTitle,
- ownerId: member.userId,
- orgId: request.organizationId,
- source: { type: "webMP4" as const },
- bucket: Option.getOrNull(upload.bucketId),
- storageIntegrationId: Option.getOrNull(upload.storageIntegrationId),
- public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC,
- });
- await tx.insert(videoUploads).values({
- videoId,
- mode: "singlepart",
- phase: "uploading",
- processingProgress: 0,
- processingMessage: "Uploading Loom video...",
- rawFileKey,
- });
- await tx.insert(importedVideos).values({
- id: videoId,
- orgId: request.organizationId,
- source: "loom",
- sourceId: loomVideoId,
- });
- if (space) {
- await tx.insert(spaceVideos).values({
- id: nanoId(),
+ source: { type: "webMP4" as const },
+ bucket: Option.getOrNull(upload.bucketId),
+ storageIntegrationId: Option.getOrNull(upload.storageIntegrationId),
+ public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC,
+ });
+ await tx.insert(videoUploads).values({
videoId,
- spaceId: space.id,
- addedById: request.ownerId,
+ mode: "singlepart",
+ phase: "uploading",
+ processingProgress: 0,
+ processingMessage: "Uploading Loom video...",
+ rawFileKey,
});
- }
- });
- await db()
- .update(loomMigrationRequests)
- .set({
- status: "in_progress",
- lastOperatorUserId: operator.id,
- lastOperatorAt: new Date(),
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(loomMigrationRequests.id, requestId),
- isNotNull(loomMigrationRequests.activeOrganizationId),
- ),
- );
- revalidatePath("/dashboard/admin/loom-migrations");
- return { videoId, uploadTarget: upload.upload };
+ await tx.insert(importedVideos).values({
+ id: videoId,
+ orgId: request.organizationId,
+ source: "loom",
+ sourceId: loomVideoId,
+ });
+ await tx.insert(loomMigrationImports).values({ videoId, requestId });
+ if (space) {
+ await tx.insert(spaceVideos).values({
+ id: nanoId(),
+ videoId,
+ spaceId: space.id,
+ addedById: request.ownerId,
+ });
+ }
+ });
+ revalidatePath("/dashboard/admin/loom-migrations");
+ return { videoId, uploadTarget: upload.upload };
+ } finally {
+ await releaseConciergeImport(requestId);
+ }
}
export async function finishConciergeLoomFileUpload({
@@ -1284,80 +1282,93 @@ export async function finishConciergeLoomFileUpload({
if (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{15}$/.test(requestId)) {
throw new Error("Invalid migration request.");
}
- const [record] = await db()
- .select({
- video: videos,
- rawFileKey: videoUploads.rawFileKey,
- phase: videoUploads.phase,
- processingMessage: videoUploads.processingMessage,
- })
- .from(videos)
- .innerJoin(
- importedVideos,
- and(
- eq(importedVideos.id, videos.id),
- eq(importedVideos.orgId, videos.orgId),
- ),
- )
- .innerJoin(videoUploads, eq(videoUploads.videoId, videos.id))
- .innerJoin(
- loomMigrationRequests,
- eq(loomMigrationRequests.organizationId, importedVideos.orgId),
- )
- .where(
- and(
- eq(videos.id, videoId),
- eq(loomMigrationRequests.id, requestId),
- eq(importedVideos.source, "loom"),
- isNotNull(loomMigrationRequests.activeOrganizationId),
- ),
- )
- .limit(1);
- if (
- !record ||
- !record.rawFileKey ||
- (record.phase !== "error" &&
- (record.phase !== "uploading" ||
- record.processingMessage !== "Uploading Loom video..."))
- ) {
- throw new Error("This Loom upload is no longer available.");
- }
- const [bucket] = await Storage.getAccessForVideo(
- decodeStorageVideo(record.video),
- ).pipe(runPromise);
- const head = await bucket
- .headObject(record.rawFileKey)
- .pipe(
- Effect.retry({ times: 3, schedule: Schedule.exponential("100 millis") }),
- runPromise,
- );
- if ((head.ContentLength ?? 0) <= 0)
- throw new Error("The uploaded file is empty.");
- const status = await startVideoProcessingWorkflow({
- videoId,
- userId: record.video.ownerId,
- rawFileKey: record.rawFileKey,
- bucketId: record.video.bucket,
- processingMessage: "Processing Loom video...",
- startFailureMessage: "Loom file processing could not start.",
- });
- if (status === "started") {
- await db()
- .update(loomMigrationRequests)
- .set({
- queuedVideoCount: sql`${loomMigrationRequests.queuedVideoCount} + 1`,
- lastOperatorUserId: operator.id,
- lastOperatorAt: new Date(),
- updatedAt: new Date(),
+ await reserveConciergeImport(requestId, operator.id);
+ try {
+ const [record] = await db()
+ .select({
+ video: videos,
+ rawFileKey: videoUploads.rawFileKey,
+ phase: videoUploads.phase,
+ processingMessage: videoUploads.processingMessage,
})
+ .from(videos)
+ .innerJoin(
+ importedVideos,
+ and(
+ eq(importedVideos.id, videos.id),
+ eq(importedVideos.orgId, videos.orgId),
+ ),
+ )
+ .innerJoin(videoUploads, eq(videoUploads.videoId, videos.id))
+ .innerJoin(
+ loomMigrationImports,
+ eq(loomMigrationImports.videoId, videos.id),
+ )
+ .innerJoin(
+ loomMigrationRequests,
+ and(
+ eq(loomMigrationRequests.id, loomMigrationImports.requestId),
+ eq(loomMigrationRequests.organizationId, importedVideos.orgId),
+ ),
+ )
.where(
and(
+ eq(videos.id, videoId),
eq(loomMigrationRequests.id, requestId),
+ eq(importedVideos.source, "loom"),
isNotNull(loomMigrationRequests.activeOrganizationId),
),
- );
+ )
+ .limit(1);
+ if (
+ !record ||
+ !record.rawFileKey ||
+ (record.phase !== "error" &&
+ (record.phase !== "uploading" ||
+ record.processingMessage !== "Uploading Loom video..."))
+ ) {
+ throw new Error("This Loom upload is no longer available.");
+ }
+ const [bucket] = await Storage.getAccessForVideo(
+ decodeStorageVideo(record.video),
+ ).pipe(runPromise);
+ const head = await bucket.headObject(record.rawFileKey).pipe(
+ Effect.retry({
+ times: 3,
+ schedule: Schedule.exponential("100 millis"),
+ }),
+ runPromise,
+ );
+ if ((head.ContentLength ?? 0) <= 0)
+ throw new Error("The uploaded file is empty.");
+ const status = await startVideoProcessingWorkflow({
+ videoId,
+ userId: record.video.ownerId,
+ rawFileKey: record.rawFileKey,
+ bucketId: record.video.bucket,
+ processingMessage: "Processing Loom video...",
+ startFailureMessage: "Loom file processing could not start.",
+ });
+ if (status === "started") {
+ await db()
+ .update(loomMigrationRequests)
+ .set({
+ queuedVideoCount: sql`${loomMigrationRequests.queuedVideoCount} + 1`,
+ lastOperatorUserId: operator.id,
+ lastOperatorAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ ),
+ );
+ }
+ revalidatePath("/dashboard/migrations/loom");
+ revalidatePath("/dashboard/admin/loom-migrations");
+ return { status };
+ } finally {
+ await releaseConciergeImport(requestId);
}
- revalidatePath("/dashboard/migrations/loom");
- revalidatePath("/dashboard/admin/loom-migrations");
- return { status };
}
diff --git a/apps/web/app/(org)/dashboard/admin/loom-migrations/LoomMigrationQueue.tsx b/apps/web/app/(org)/dashboard/admin/loom-migrations/LoomMigrationQueue.tsx
index d256a9aded3..f1cf3806b42 100644
--- a/apps/web/app/(org)/dashboard/admin/loom-migrations/LoomMigrationQueue.tsx
+++ b/apps/web/app/(org)/dashboard/admin/loom-migrations/LoomMigrationQueue.tsx
@@ -195,6 +195,7 @@ function QueueItem({ request }: { request: OperatorLoomMigrationView }) {
Jobs started: {request.queuedVideoCount}
Verified videos: {request.importedVideoCount}
+ Imports starting: {request.activeImportCount}
{request.customerNote && (
@@ -467,7 +468,8 @@ function QueueItem({ request }: { request: OperatorLoomMigrationView }) {
disabled={
statusMutation.isPending ||
importMutation.isPending ||
- fileMutation.isPending
+ fileMutation.isPending ||
+ (nextStatus === "completed" && request.activeImportCount > 0)
}
size="sm"
type="submit"
diff --git a/apps/web/lib/loom-concierge.ts b/apps/web/lib/loom-concierge.ts
index a8f50bf2845..aed27433a37 100644
--- a/apps/web/lib/loom-concierge.ts
+++ b/apps/web/lib/loom-concierge.ts
@@ -8,8 +8,18 @@ import {
users,
} from "@cap/database/schema";
import { buildEnv } from "@cap/env";
-import type { Organisation } from "@cap/web-domain";
-import { and, asc, desc, eq, isNull, ne } from "drizzle-orm";
+import type { Organisation, User } from "@cap/web-domain";
+import {
+ and,
+ asc,
+ desc,
+ eq,
+ gt,
+ isNotNull,
+ isNull,
+ ne,
+ sql,
+} from "drizzle-orm";
import { requireOrganizationSettingsManager } from "@/actions/organization/authorization";
import { MESSENGER_ADMIN_EMAIL } from "@/lib/messenger/constants";
import { isOrganizationOwnerPro } from "@/lib/org-pro";
@@ -29,6 +39,7 @@ export type LoomMigrationView = {
expectedVideoCount: number | null;
importedVideoCount: number;
queuedVideoCount: number;
+ activeImportCount: number;
completedAt: string | null;
createdAt: string;
updatedAt: string;
@@ -52,6 +63,7 @@ export function migrationToView(record: MigrationRecord): LoomMigrationView {
expectedVideoCount: record.expectedVideoCount,
importedVideoCount: record.importedVideoCount,
queuedVideoCount: record.queuedVideoCount,
+ activeImportCount: record.activeImportCount,
completedAt: record.completedAt?.toISOString() ?? null,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
@@ -92,6 +104,57 @@ export async function requireMigrationOperator() {
return user;
}
+function affectedRows(result: unknown) {
+ if (Array.isArray(result)) {
+ return (
+ (result[0] as { affectedRows?: number } | undefined)?.affectedRows ?? 0
+ );
+ }
+ return (result as { affectedRows?: number } | undefined)?.affectedRows ?? 0;
+}
+
+export async function reserveConciergeImport(
+ requestId: string,
+ operatorId: User.UserId,
+) {
+ const result = await db()
+ .update(loomMigrationRequests)
+ .set({
+ activeImportCount: sql`${loomMigrationRequests.activeImportCount} + 1`,
+ status: "in_progress",
+ lastOperatorUserId: operatorId,
+ lastOperatorAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ ),
+ );
+ if (affectedRows(result) === 0) {
+ throw new Error("The migration request changed. Refresh and try again.");
+ }
+}
+
+export async function releaseConciergeImport(requestId: string) {
+ const result = await db()
+ .update(loomMigrationRequests)
+ .set({
+ activeImportCount: sql`${loomMigrationRequests.activeImportCount} - 1`,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ gt(loomMigrationRequests.activeImportCount, 0),
+ ),
+ );
+ if (affectedRows(result) === 0) {
+ throw new Error("Could not release the migration import reservation.");
+ }
+}
+
export async function getCustomerMigrationRequests(
organizationId: Organisation.OrganisationId,
) {
diff --git a/packages/database/migrations/0046_loom_concierge.sql b/packages/database/migrations/0046_loom_concierge.sql
index 778f5ef9a7e..6e787e42967 100644
--- a/packages/database/migrations/0046_loom_concierge.sql
+++ b/packages/database/migrations/0046_loom_concierge.sql
@@ -1,3 +1,10 @@
+CREATE TABLE `loom_migration_imports` (
+ `videoId` varchar(15) NOT NULL,
+ `requestId` varchar(15) NOT NULL,
+ `createdAt` datetime NOT NULL,
+ CONSTRAINT `loom_migration_imports_videoId` PRIMARY KEY(`videoId`)
+);
+--> statement-breakpoint
CREATE TABLE `loom_migration_requests` (
`id` varchar(15) NOT NULL,
`organizationId` varchar(15) NOT NULL,
@@ -12,6 +19,7 @@ CREATE TABLE `loom_migration_requests` (
`expectedVideoCount` int,
`importedVideoCount` int NOT NULL DEFAULT 0,
`queuedVideoCount` int NOT NULL DEFAULT 0,
+ `activeImportCount` int NOT NULL DEFAULT 0,
`lastOperatorUserId` varchar(15),
`lastOperatorAt` datetime,
`completedAt` datetime,
@@ -21,5 +29,6 @@ CREATE TABLE `loom_migration_requests` (
CONSTRAINT `loom_migration_active_org_idx` UNIQUE(`activeOrganizationId`)
);
--> statement-breakpoint
+CREATE INDEX `loom_migration_imports_request_idx` ON `loom_migration_imports` (`requestId`);--> statement-breakpoint
CREATE INDEX `loom_migration_org_created_idx` ON `loom_migration_requests` (`organizationId`,`createdAt`);--> statement-breakpoint
CREATE INDEX `loom_migration_status_created_idx` ON `loom_migration_requests` (`status`,`createdAt`);
\ No newline at end of file
diff --git a/packages/database/migrations/meta/0046_snapshot.json b/packages/database/migrations/meta/0046_snapshot.json
index 26dad24dff2..20273a73004 100644
--- a/packages/database/migrations/meta/0046_snapshot.json
+++ b/packages/database/migrations/meta/0046_snapshot.json
@@ -1,7 +1,7 @@
{
"version": "5",
"dialect": "mysql",
- "id": "e73cfec4-89dd-4fc2-9068-4a86b6e10171",
+ "id": "d78f7c74-5007-4c78-96c3-50bf45012ad1",
"prevId": "0d80e4e5-6d93-4d28-b235-bf1231baa7a9",
"tables": {
"accounts": {
@@ -1694,6 +1694,48 @@
"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": {
@@ -1791,6 +1833,14 @@
"autoincrement": false,
"default": 0
},
+ "activeImportCount": {
+ "name": "activeImportCount",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
"lastOperatorUserId": {
"name": "lastOperatorUserId",
"type": "varchar(15)",
diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json
index d8e7a8780af..49b1b749d03 100644
--- a/packages/database/migrations/meta/_journal.json
+++ b/packages/database/migrations/meta/_journal.json
@@ -327,7 +327,7 @@
{
"idx": 46,
"version": "5",
- "when": 1789985586222,
+ "when": 1789988285195,
"tag": "0046_loom_concierge",
"breakpoints": true
}
diff --git a/packages/database/schema.ts b/packages/database/schema.ts
index d91b21bf257..7f6b03c5c82 100644
--- a/packages/database/schema.ts
+++ b/packages/database/schema.ts
@@ -1575,6 +1575,7 @@ export const loomMigrationRequests = mysqlTable(
expectedVideoCount: int("expectedVideoCount"),
importedVideoCount: int("importedVideoCount").notNull().default(0),
queuedVideoCount: int("queuedVideoCount").notNull().default(0),
+ activeImportCount: int("activeImportCount").notNull().default(0),
lastOperatorUserId:
nanoIdNullable("lastOperatorUserId").$type
(),
lastOperatorAt: datetime("lastOperatorAt", { mode: "date" }),
@@ -1599,6 +1600,18 @@ export const loomMigrationRequests = mysqlTable(
],
);
+export const loomMigrationImports = mysqlTable(
+ "loom_migration_imports",
+ {
+ videoId: nanoId("videoId").notNull().primaryKey().$type(),
+ requestId: nanoId("requestId").notNull(),
+ createdAt: datetime("createdAt", { mode: "date" })
+ .notNull()
+ .$defaultFn(() => new Date()),
+ },
+ (table) => [index("loom_migration_imports_request_idx").on(table.requestId)],
+);
+
export const developerApps = mysqlTable(
"developer_apps",
{
From e9fb8511a280bd928ef7391624b8fe29608ddd01 Mon Sep 17 00:00:00 2001
From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com>
Date: Mon, 21 Sep 2026 12:07:46 +0100
Subject: [PATCH 04/74] fix: cover Loom concierge link in importer fixture
---
apps/web/__tests__/unit/loom-import-ui.test.ts | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/apps/web/__tests__/unit/loom-import-ui.test.ts b/apps/web/__tests__/unit/loom-import-ui.test.ts
index d92f8e90913..d228ec466cd 100644
--- a/apps/web/__tests__/unit/loom-import-ui.test.ts
+++ b/apps/web/__tests__/unit/loom-import-ui.test.ts
@@ -96,6 +96,9 @@ vi.mock("next/link", () => ({
}: React.PropsWithChildren>) =>
React.createElement("a", props, children),
}));
+vi.mock("@cap/env", () => ({
+ buildEnv: { NEXT_PUBLIC_IS_CAP: "true" },
+}));
vi.mock("@/actions/loom", () => ({
getLoomImportFolders: mocks.folders,
importFromLoom: mocks.import,
@@ -186,6 +189,10 @@ describe("Loom importer component", () => {
it("shows the inherited subfolder and submits it then returns there", async () => {
await render({ folderId: Folder.FolderId.make("child") });
await ready();
+ expect(
+ container.querySelector('a[href="/dashboard/migrations/loom"]')
+ ?.textContent,
+ ).toContain("Want Cap to move your whole Loom workspace for you?");
expect(
getByRole(container, "combobox", { name: "Import to" }).textContent,
).toContain("My Caps / Course / Live Calls - Two");
From 52605264f5df12208a7b670e8dce4f6dc5e9360b Mon Sep 17 00:00:00 2001
From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com>
Date: Mon, 21 Sep 2026 12:16:23 +0100
Subject: [PATCH 05/74] fix: recover interrupted Loom concierge imports
---
apps/web/__tests__/unit/loom-csv.test.ts | 4 +-
.../web/__tests__/unit/loom-import-ui.test.ts | 5 +-
apps/web/actions/loom-concierge.tsx | 10 +-
apps/web/actions/loom.ts | 90 +-
apps/web/lib/loom-concierge.ts | 44 +-
apps/web/lib/loom-csv.ts | 7 +-
.../migrations/0047_loom_concierge_lease.sql | 2 +
.../migrations/meta/0047_snapshot.json | 4799 +++++++++++++++++
.../database/migrations/meta/_journal.json | 7 +
packages/database/schema.ts | 4 +
10 files changed, 4940 insertions(+), 32 deletions(-)
create mode 100644 packages/database/migrations/0047_loom_concierge_lease.sql
create mode 100644 packages/database/migrations/meta/0047_snapshot.json
diff --git a/apps/web/__tests__/unit/loom-csv.test.ts b/apps/web/__tests__/unit/loom-csv.test.ts
index 5338285db28..90d8db8f19e 100644
--- a/apps/web/__tests__/unit/loom-csv.test.ts
+++ b/apps/web/__tests__/unit/loom-csv.test.ts
@@ -5,7 +5,9 @@ describe("concierge Loom CSV", () => {
it("keeps quoted workspace names and source row numbers", () => {
const rows = parseConciergeLoomCsv(
"\uFEFFuser_email,space_name,loom_video_url\r\n" +
- 'owner@example.com,"Sales, Europe",https://www.loom.com/share/0123456789abcdef\r\n',
+ "owner@example.com," +
+ JSON.stringify("Sales, Europe") +
+ ",https://www.loom.com/share/0123456789abcdef\r\n",
);
expect(rows).toEqual([
{
diff --git a/apps/web/__tests__/unit/loom-import-ui.test.ts b/apps/web/__tests__/unit/loom-import-ui.test.ts
index d228ec466cd..07fd55309d5 100644
--- a/apps/web/__tests__/unit/loom-import-ui.test.ts
+++ b/apps/web/__tests__/unit/loom-import-ui.test.ts
@@ -190,8 +190,9 @@ describe("Loom importer component", () => {
await render({ folderId: Folder.FolderId.make("child") });
await ready();
expect(
- container.querySelector('a[href="/dashboard/migrations/loom"]')
- ?.textContent,
+ Array.from(container.querySelectorAll("a")).find(
+ (link) => link.getAttribute("href") === "/dashboard/migrations/loom",
+ )?.textContent,
).toContain("Want Cap to move your whole Loom workspace for you?");
expect(
getByRole(container, "combobox", { name: "Import to" }).textContent,
diff --git a/apps/web/actions/loom-concierge.tsx b/apps/web/actions/loom-concierge.tsx
index 65835952a30..007584ce806 100644
--- a/apps/web/actions/loom-concierge.tsx
+++ b/apps/web/actions/loom-concierge.tsx
@@ -16,7 +16,7 @@ import {
} from "@cap/database/schema";
import { serverEnv } from "@cap/env";
import type { Organisation } from "@cap/web-domain";
-import { and, eq, inArray, isNotNull } from "drizzle-orm";
+import { and, eq, inArray, isNotNull, lte, or } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import {
getCustomerMigrationRequests,
@@ -281,6 +281,9 @@ export async function updateLoomMigrationStatus({
capMessage: normalizedMessage || null,
expectedVideoCount,
importedVideoCount,
+ activeImportCount: nextStatus === "completed" ? 0 : undefined,
+ activeImportLeaseToken: nextStatus === "completed" ? null : undefined,
+ activeImportLeaseUntil: nextStatus === "completed" ? null : undefined,
activeOrganizationId:
nextStatus === "completed" ? null : request.organizationId,
completedAt: nextStatus === "completed" ? new Date() : null,
@@ -294,7 +297,10 @@ export async function updateLoomMigrationStatus({
eq(loomMigrationRequests.status, request.status),
isNotNull(loomMigrationRequests.activeOrganizationId),
nextStatus === "completed"
- ? eq(loomMigrationRequests.activeImportCount, 0)
+ ? or(
+ eq(loomMigrationRequests.activeImportCount, 0),
+ lte(loomMigrationRequests.activeImportLeaseUntil, new Date()),
+ )
: undefined,
),
);
diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts
index 2083d85dc0d..b85a1f0db00 100644
--- a/apps/web/actions/loom.ts
+++ b/apps/web/actions/loom.ts
@@ -316,12 +316,14 @@ async function importLoomVideoForOwner({
ownerId,
destination = {},
migrationRequestId,
+ migrationLeaseToken,
}: {
loomUrl: string;
orgId: Organisation.OrganisationId;
ownerId: User.UserId;
destination?: LoomImportDestination;
migrationRequestId?: string;
+ migrationLeaseToken?: string;
}): Promise {
const loomVideoId = extractLoomVideoId(loomUrl.trim());
if (!loomVideoId) {
@@ -412,6 +414,27 @@ async function importLoomVideoForOwner({
`Loom Import - ${new Date().toLocaleDateString("en-US", { day: "numeric", month: "long", year: "numeric" })}`;
await db().transaction(async (tx) => {
+ if (migrationRequestId) {
+ const [reservation] = await tx
+ .select({
+ activeOrganizationId: loomMigrationRequests.activeOrganizationId,
+ leaseToken: loomMigrationRequests.activeImportLeaseToken,
+ leaseUntil: loomMigrationRequests.activeImportLeaseUntil,
+ })
+ .from(loomMigrationRequests)
+ .where(eq(loomMigrationRequests.id, migrationRequestId))
+ .limit(1)
+ .for("update");
+ if (
+ !migrationLeaseToken ||
+ !reservation?.activeOrganizationId ||
+ reservation.leaseToken !== migrationLeaseToken ||
+ !reservation.leaseUntil ||
+ reservation.leaseUntil <= new Date()
+ ) {
+ throw new Error("The concierge import expired. Retry this video.");
+ }
+ }
await tx.insert(videos).values({
id: videoId,
name,
@@ -801,11 +824,13 @@ async function processLoomCsvRows({
orgId,
actorId,
migrationRequestId,
+ migrationLeaseToken,
}: {
rows: LoomCsvImportRow[];
orgId: Organisation.OrganisationId;
actorId: User.UserId;
migrationRequestId?: string;
+ migrationLeaseToken?: string;
}): Promise {
const inputRows = Array.isArray(rows) ? rows : [];
const normalizedRows = inputRows
@@ -916,6 +941,7 @@ async function processLoomCsvRows({
orgId,
ownerId: member.userId,
migrationRequestId,
+ migrationLeaseToken,
});
let spaceName = row.spaceName || undefined;
@@ -1019,13 +1045,14 @@ export async function importFromLoomCsvForConcierge({
if (!(await isOrganizationOwnerPro(request.organizationId))) {
throw new Error("The destination workspace needs Cap Pro.");
}
- await reserveConciergeImport(requestId, operator.id);
+ const leaseToken = await reserveConciergeImport(requestId, operator.id);
try {
const result = await processLoomCsvRows({
rows,
orgId: request.organizationId,
actorId: request.ownerId,
migrationRequestId: requestId,
+ migrationLeaseToken: leaseToken,
});
if (result.importedCount > 0) {
await db()
@@ -1036,13 +1063,19 @@ export async function importFromLoomCsvForConcierge({
lastOperatorAt: new Date(),
updatedAt: new Date(),
})
- .where(eq(loomMigrationRequests.id, requestId));
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ eq(loomMigrationRequests.activeImportLeaseToken, leaseToken),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ ),
+ );
}
revalidatePath("/dashboard/migrations/loom");
revalidatePath("/dashboard/admin/loom-migrations");
return result;
} finally {
- await releaseConciergeImport(requestId);
+ await releaseConciergeImport(requestId, leaseToken);
}
}
@@ -1102,7 +1135,7 @@ export async function createConciergeLoomFileUpload({
if (!(await isOrganizationOwnerPro(request.organizationId))) {
throw new Error("The destination workspace needs Cap Pro.");
}
- await reserveConciergeImport(requestId, operator.id);
+ const leaseToken = await reserveConciergeImport(requestId, operator.id);
try {
const [existing] = await db()
.select({
@@ -1180,12 +1213,30 @@ export async function createConciergeLoomFileUpload({
);
}
if (!mapped) {
- await db()
- .insert(loomMigrationImports)
- .values({
+ await db().transaction(async (tx) => {
+ const [reservation] = await tx
+ .select({
+ activeOrganizationId: loomMigrationRequests.activeOrganizationId,
+ leaseToken: loomMigrationRequests.activeImportLeaseToken,
+ leaseUntil: loomMigrationRequests.activeImportLeaseUntil,
+ })
+ .from(loomMigrationRequests)
+ .where(eq(loomMigrationRequests.id, requestId))
+ .limit(1)
+ .for("update");
+ if (
+ !reservation?.activeOrganizationId ||
+ reservation.leaseToken !== leaseToken ||
+ !reservation.leaseUntil ||
+ reservation.leaseUntil <= new Date()
+ ) {
+ throw new Error("The concierge import expired. Retry this video.");
+ }
+ await tx.insert(loomMigrationImports).values({
videoId: Video.VideoId.make(existing.id),
requestId,
});
+ });
}
return {
videoId: Video.VideoId.make(existing.id),
@@ -1230,6 +1281,24 @@ export async function createConciergeLoomFileUpload({
request.organizationId,
).pipe(runPromise);
await db().transaction(async (tx) => {
+ const [reservation] = await tx
+ .select({
+ activeOrganizationId: loomMigrationRequests.activeOrganizationId,
+ leaseToken: loomMigrationRequests.activeImportLeaseToken,
+ leaseUntil: loomMigrationRequests.activeImportLeaseUntil,
+ })
+ .from(loomMigrationRequests)
+ .where(eq(loomMigrationRequests.id, requestId))
+ .limit(1)
+ .for("update");
+ if (
+ !reservation?.activeOrganizationId ||
+ reservation.leaseToken !== leaseToken ||
+ !reservation.leaseUntil ||
+ reservation.leaseUntil <= new Date()
+ ) {
+ throw new Error("The concierge import expired. Retry this video.");
+ }
await tx.insert(videos).values({
id: videoId,
name: normalizedTitle,
@@ -1267,7 +1336,7 @@ export async function createConciergeLoomFileUpload({
revalidatePath("/dashboard/admin/loom-migrations");
return { videoId, uploadTarget: upload.upload };
} finally {
- await releaseConciergeImport(requestId);
+ await releaseConciergeImport(requestId, leaseToken);
}
}
@@ -1282,7 +1351,7 @@ export async function finishConciergeLoomFileUpload({
if (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{15}$/.test(requestId)) {
throw new Error("Invalid migration request.");
}
- await reserveConciergeImport(requestId, operator.id);
+ const leaseToken = await reserveConciergeImport(requestId, operator.id);
try {
const [record] = await db()
.select({
@@ -1361,6 +1430,7 @@ export async function finishConciergeLoomFileUpload({
.where(
and(
eq(loomMigrationRequests.id, requestId),
+ eq(loomMigrationRequests.activeImportLeaseToken, leaseToken),
isNotNull(loomMigrationRequests.activeOrganizationId),
),
);
@@ -1369,6 +1439,6 @@ export async function finishConciergeLoomFileUpload({
revalidatePath("/dashboard/admin/loom-migrations");
return { status };
} finally {
- await releaseConciergeImport(requestId);
+ await releaseConciergeImport(requestId, leaseToken);
}
}
diff --git a/apps/web/lib/loom-concierge.ts b/apps/web/lib/loom-concierge.ts
index aed27433a37..0a9a01e5127 100644
--- a/apps/web/lib/loom-concierge.ts
+++ b/apps/web/lib/loom-concierge.ts
@@ -1,5 +1,6 @@
import "server-only";
+import { randomUUID } from "node:crypto";
import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import {
@@ -14,11 +15,11 @@ import {
asc,
desc,
eq,
- gt,
isNotNull,
isNull,
+ lte,
ne,
- sql,
+ or,
} from "drizzle-orm";
import { requireOrganizationSettingsManager } from "@/actions/organization/authorization";
import { MESSENGER_ADMIN_EMAIL } from "@/lib/messenger/constants";
@@ -26,6 +27,7 @@ import { isOrganizationOwnerPro } from "@/lib/org-pro";
import type { LoomMigrationStatus } from "./loom-migration-state";
type MigrationRecord = typeof loomMigrationRequests.$inferSelect;
+const IMPORT_LEASE_MS = 60 * 60 * 1000;
export type LoomMigrationView = {
id: string;
@@ -63,7 +65,11 @@ export function migrationToView(record: MigrationRecord): LoomMigrationView {
expectedVideoCount: record.expectedVideoCount,
importedVideoCount: record.importedVideoCount,
queuedVideoCount: record.queuedVideoCount,
- activeImportCount: record.activeImportCount,
+ activeImportCount:
+ record.activeImportLeaseUntil &&
+ record.activeImportLeaseUntil.getTime() <= Date.now()
+ ? 0
+ : record.activeImportCount,
completedAt: record.completedAt?.toISOString() ?? null,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
@@ -117,42 +123,52 @@ export async function reserveConciergeImport(
requestId: string,
operatorId: User.UserId,
) {
+ const token = randomUUID();
+ const now = new Date();
const result = await db()
.update(loomMigrationRequests)
.set({
- activeImportCount: sql`${loomMigrationRequests.activeImportCount} + 1`,
+ activeImportCount: 1,
+ activeImportLeaseToken: token,
+ activeImportLeaseUntil: new Date(now.getTime() + IMPORT_LEASE_MS),
status: "in_progress",
lastOperatorUserId: operatorId,
- lastOperatorAt: new Date(),
- updatedAt: new Date(),
+ lastOperatorAt: now,
+ updatedAt: now,
})
.where(
and(
eq(loomMigrationRequests.id, requestId),
isNotNull(loomMigrationRequests.activeOrganizationId),
+ or(
+ eq(loomMigrationRequests.activeImportCount, 0),
+ lte(loomMigrationRequests.activeImportLeaseUntil, now),
+ ),
),
);
if (affectedRows(result) === 0) {
- throw new Error("The migration request changed. Refresh and try again.");
+ throw new Error(
+ "The migration request changed or another import is starting. Refresh and try again.",
+ );
}
+ return token;
}
-export async function releaseConciergeImport(requestId: string) {
- const result = await db()
+export async function releaseConciergeImport(requestId: string, token: string) {
+ await db()
.update(loomMigrationRequests)
.set({
- activeImportCount: sql`${loomMigrationRequests.activeImportCount} - 1`,
+ activeImportCount: 0,
+ activeImportLeaseToken: null,
+ activeImportLeaseUntil: null,
updatedAt: new Date(),
})
.where(
and(
eq(loomMigrationRequests.id, requestId),
- gt(loomMigrationRequests.activeImportCount, 0),
+ eq(loomMigrationRequests.activeImportLeaseToken, token),
),
);
- if (affectedRows(result) === 0) {
- throw new Error("Could not release the migration import reservation.");
- }
}
export async function getCustomerMigrationRequests(
diff --git a/apps/web/lib/loom-csv.ts b/apps/web/lib/loom-csv.ts
index 871eef779a5..3aaea1f1968 100644
--- a/apps/web/lib/loom-csv.ts
+++ b/apps/web/lib/loom-csv.ts
@@ -6,14 +6,15 @@ export function parseLoomCsvRecords(text: string) {
let row: string[] = [];
let inQuotes = false;
const input = text.replace(/^\uFEFF/, "");
+ const quote = "\u0022";
for (let index = 0; index < input.length; index += 1) {
const char = input.charAt(index);
const next = input.charAt(index + 1);
- if (char === '"') {
- if (inQuotes && next === '"') {
- field += '"';
+ if (char === quote) {
+ if (inQuotes && next === quote) {
+ field += quote;
index += 1;
} else {
inQuotes = !inQuotes;
diff --git a/packages/database/migrations/0047_loom_concierge_lease.sql b/packages/database/migrations/0047_loom_concierge_lease.sql
new file mode 100644
index 00000000000..982766242b9
--- /dev/null
+++ b/packages/database/migrations/0047_loom_concierge_lease.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `loom_migration_requests` ADD `activeImportLeaseToken` varchar(36);--> statement-breakpoint
+ALTER TABLE `loom_migration_requests` ADD `activeImportLeaseUntil` datetime;
\ No newline at end of file
diff --git a/packages/database/migrations/meta/0047_snapshot.json b/packages/database/migrations/meta/0047_snapshot.json
new file mode 100644
index 00000000000..2695577b69b
--- /dev/null
+++ b/packages/database/migrations/meta/0047_snapshot.json
@@ -0,0 +1,4799 @@
+{
+ "version": "5",
+ "dialect": "mysql",
+ "id": "1cd109bd-1e00-49c3-bbf3-97f0025e557c",
+ "prevId": "d78f7c74-5007-4c78-96c3-50bf45012ad1",
+ "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
+ }
+ },
+ "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_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_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
+ },
+ "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": {
+ "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
+ }
+ },
+ "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": {}
+ },
+ "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": {}
+ },
+ "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": {}
+ },
+ "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
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "notifications_id": {
+ "name": "notifications_id",
+ "columns": ["id"]
+ }
+ },
+ "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
+ },
+ "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
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "video_uploads_video_id": {
+ "name": "video_uploads_video_id",
+ "columns": ["video_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
+ },
+ "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_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 49b1b749d03..5cfeb35486d 100644
--- a/packages/database/migrations/meta/_journal.json
+++ b/packages/database/migrations/meta/_journal.json
@@ -330,6 +330,13 @@
"when": 1789988285195,
"tag": "0046_loom_concierge",
"breakpoints": true
+ },
+ {
+ "idx": 47,
+ "version": "5",
+ "when": 1789989245680,
+ "tag": "0047_loom_concierge_lease",
+ "breakpoints": true
}
]
}
diff --git a/packages/database/schema.ts b/packages/database/schema.ts
index 7f6b03c5c82..1843316f436 100644
--- a/packages/database/schema.ts
+++ b/packages/database/schema.ts
@@ -1576,6 +1576,10 @@ export const loomMigrationRequests = mysqlTable(
importedVideoCount: int("importedVideoCount").notNull().default(0),
queuedVideoCount: int("queuedVideoCount").notNull().default(0),
activeImportCount: int("activeImportCount").notNull().default(0),
+ activeImportLeaseToken: varchar("activeImportLeaseToken", { length: 36 }),
+ activeImportLeaseUntil: datetime("activeImportLeaseUntil", {
+ mode: "date",
+ }),
lastOperatorUserId:
nanoIdNullable("lastOperatorUserId").$type(),
lastOperatorAt: datetime("lastOperatorAt", { mode: "date" }),
From 0f4fe25ffc2ce34594656d745f0c23a66ebb2349 Mon Sep 17 00:00:00 2001
From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com>
Date: Mon, 21 Sep 2026 12:19:27 +0100
Subject: [PATCH 06/74] fix: validate Loom video IDs in server requests
---
apps/web/actions/loom.ts | 16 ++++++++++------
1 file changed, 10 insertions(+), 6 deletions(-)
diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts
index b85a1f0db00..120aa93490b 100644
--- a/apps/web/actions/loom.ts
+++ b/apps/web/actions/loom.ts
@@ -108,6 +108,7 @@ const MAX_LOOM_SPACE_NAME_LENGTH = 255;
const LOOM_CSV_LIMIT_ERROR = `CSV imports are limited to ${MAX_LOOM_CSV_ROWS} rows at a time. Contact support to raise this limit.`;
const LOOM_CSV_PERMISSION_ERROR =
"Only organization admins and owners can import Loom videos from a CSV.";
+const LOOM_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{10,64}$/;
function extractLoomVideoId(url: string): string | null {
try {
@@ -122,11 +123,11 @@ function extractLoomVideoId(url: string): string | null {
const pathParts = parsed.pathname.split("/").filter(Boolean);
const id = pathParts[pathParts.length - 1] ?? null;
- if (!id || id.length < 10) {
+ if (!id || !LOOM_VIDEO_ID_PATTERN.test(id)) {
return null;
}
- return id.split("?")[0] ?? null;
+ return id;
} catch {
return null;
}
@@ -134,7 +135,7 @@ function extractLoomVideoId(url: string): string | null {
async function fetchLoomEndpoint(
videoId: string,
- endpoint: string,
+ endpoint: "transcoded-url" | "raw-url",
includeBody = true,
): Promise {
try {
@@ -153,7 +154,7 @@ async function fetchLoomEndpoint(
}
const response = await fetch(
- `https://www.loom.com/api/campaigns/sessions/${videoId}/${endpoint}`,
+ `https://www.loom.com/api/campaigns/sessions/${encodeURIComponent(videoId)}/${endpoint}`,
options,
);
@@ -215,7 +216,10 @@ function isDirectMp4Url(url: string): boolean {
}
async function getLoomDownloadUrl(loomVideoId: string): Promise {
- const requestVariants: Array<{ endpoint: string; includeBody: boolean }> = [
+ const requestVariants: Array<{
+ endpoint: "transcoded-url" | "raw-url";
+ includeBody: boolean;
+ }> = [
{ endpoint: "transcoded-url", includeBody: true },
{ endpoint: "raw-url", includeBody: true },
{ endpoint: "transcoded-url", includeBody: false },
@@ -241,7 +245,7 @@ async function fetchLoomOEmbed(
): Promise<{ duration?: number; width?: number; height?: number } | null> {
try {
const response = await fetch(
- `https://www.loom.com/v1/oembed?url=https://www.loom.com/share/${loomVideoId}`,
+ `https://www.loom.com/v1/oembed?url=${encodeURIComponent(`https://www.loom.com/share/${loomVideoId}`)}`,
{ headers: { Accept: "application/json" } },
);
if (!response.ok) return null;
From 5b2bc8ad38c53514b0fcd9c73ca315dba79ad1ba Mon Sep 17 00:00:00 2001
From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com>
Date: Mon, 21 Sep 2026 12:24:56 +0100
Subject: [PATCH 07/74] fix: account for long Loom concierge imports
---
apps/web/actions/loom.ts | 65 +++++++++----------
.../loom-migrations/LoomMigrationQueue.tsx | 2 +-
apps/web/lib/loom-concierge.ts | 24 +++++++
3 files changed, 56 insertions(+), 35 deletions(-)
diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts
index 120aa93490b..a6ac424ab98 100644
--- a/apps/web/actions/loom.ts
+++ b/apps/web/actions/loom.ts
@@ -42,6 +42,7 @@ import {
import { requireSpaceManager } from "@/actions/organization/space-authorization";
import {
releaseConciergeImport,
+ renewConciergeImport,
requireMigrationOperator,
reserveConciergeImport,
} from "@/lib/loom-concierge";
@@ -472,6 +473,13 @@ async function importLoomVideoForOwner({
videoId,
requestId: migrationRequestId,
});
+ await tx
+ .update(loomMigrationRequests)
+ .set({
+ queuedVideoCount: sql`${loomMigrationRequests.queuedVideoCount} + 1`,
+ updatedAt: new Date(),
+ })
+ .where(eq(loomMigrationRequests.id, migrationRequestId));
}
if (destination.spaceId === orgId) {
@@ -880,6 +888,12 @@ async function processLoomCsvRows({
const touchedSpaceIds = new Set();
for (const row of normalizedRows) {
+ if (migrationRequestId) {
+ if (!migrationLeaseToken) {
+ throw new Error("The concierge import is missing its reservation.");
+ }
+ await renewConciergeImport(migrationRequestId, migrationLeaseToken);
+ }
if (!row.loomUrl) {
results.push({
rowNumber: row.rowNumber,
@@ -1049,6 +1063,9 @@ export async function importFromLoomCsvForConcierge({
if (!(await isOrganizationOwnerPro(request.organizationId))) {
throw new Error("The destination workspace needs Cap Pro.");
}
+ if (!Array.isArray(rows) || rows.length > 10) {
+ throw new Error("Concierge CSV imports run in batches of 10 videos.");
+ }
const leaseToken = await reserveConciergeImport(requestId, operator.id);
try {
const result = await processLoomCsvRows({
@@ -1058,23 +1075,6 @@ export async function importFromLoomCsvForConcierge({
migrationRequestId: requestId,
migrationLeaseToken: leaseToken,
});
- if (result.importedCount > 0) {
- await db()
- .update(loomMigrationRequests)
- .set({
- queuedVideoCount: sql`${loomMigrationRequests.queuedVideoCount} + ${result.importedCount}`,
- lastOperatorUserId: operator.id,
- lastOperatorAt: new Date(),
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(loomMigrationRequests.id, requestId),
- eq(loomMigrationRequests.activeImportLeaseToken, leaseToken),
- isNotNull(loomMigrationRequests.activeOrganizationId),
- ),
- );
- }
revalidatePath("/dashboard/migrations/loom");
revalidatePath("/dashboard/admin/loom-migrations");
return result;
@@ -1240,6 +1240,13 @@ export async function createConciergeLoomFileUpload({
videoId: Video.VideoId.make(existing.id),
requestId,
});
+ await tx
+ .update(loomMigrationRequests)
+ .set({
+ queuedVideoCount: sql`${loomMigrationRequests.queuedVideoCount} + 1`,
+ updatedAt: new Date(),
+ })
+ .where(eq(loomMigrationRequests.id, requestId));
});
}
return {
@@ -1328,6 +1335,13 @@ export async function createConciergeLoomFileUpload({
sourceId: loomVideoId,
});
await tx.insert(loomMigrationImports).values({ videoId, requestId });
+ await tx
+ .update(loomMigrationRequests)
+ .set({
+ queuedVideoCount: sql`${loomMigrationRequests.queuedVideoCount} + 1`,
+ updatedAt: new Date(),
+ })
+ .where(eq(loomMigrationRequests.id, requestId));
if (space) {
await tx.insert(spaceVideos).values({
id: nanoId(),
@@ -1422,23 +1436,6 @@ export async function finishConciergeLoomFileUpload({
processingMessage: "Processing Loom video...",
startFailureMessage: "Loom file processing could not start.",
});
- if (status === "started") {
- await db()
- .update(loomMigrationRequests)
- .set({
- queuedVideoCount: sql`${loomMigrationRequests.queuedVideoCount} + 1`,
- lastOperatorUserId: operator.id,
- lastOperatorAt: new Date(),
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(loomMigrationRequests.id, requestId),
- eq(loomMigrationRequests.activeImportLeaseToken, leaseToken),
- isNotNull(loomMigrationRequests.activeOrganizationId),
- ),
- );
- }
revalidatePath("/dashboard/migrations/loom");
revalidatePath("/dashboard/admin/loom-migrations");
return { status };
diff --git a/apps/web/app/(org)/dashboard/admin/loom-migrations/LoomMigrationQueue.tsx b/apps/web/app/(org)/dashboard/admin/loom-migrations/LoomMigrationQueue.tsx
index f1cf3806b42..34f6f5e2c42 100644
--- a/apps/web/app/(org)/dashboard/admin/loom-migrations/LoomMigrationQueue.tsx
+++ b/apps/web/app/(org)/dashboard/admin/loom-migrations/LoomMigrationQueue.tsx
@@ -193,7 +193,7 @@ function QueueItem({ request }: { request: OperatorLoomMigrationView }) {
Loom invite:{" "}
{request.invitedAt ? "Marked as sent" : "Waiting for customer"}
- Jobs started: {request.queuedVideoCount}
+ Videos queued: {request.queuedVideoCount}
Verified videos: {request.importedVideoCount}
Imports starting: {request.activeImportCount}
diff --git a/apps/web/lib/loom-concierge.ts b/apps/web/lib/loom-concierge.ts
index 0a9a01e5127..7bd62cf691f 100644
--- a/apps/web/lib/loom-concierge.ts
+++ b/apps/web/lib/loom-concierge.ts
@@ -15,11 +15,13 @@ import {
asc,
desc,
eq,
+ gt,
isNotNull,
isNull,
lte,
ne,
or,
+ sql,
} from "drizzle-orm";
import { requireOrganizationSettingsManager } from "@/actions/organization/authorization";
import { MESSENGER_ADMIN_EMAIL } from "@/lib/messenger/constants";
@@ -171,6 +173,28 @@ export async function releaseConciergeImport(requestId: string, token: string) {
);
}
+export async function renewConciergeImport(requestId: string, token: string) {
+ const now = new Date();
+ const deadline = new Date(now.getTime() + IMPORT_LEASE_MS);
+ const result = await db()
+ .update(loomMigrationRequests)
+ .set({
+ activeImportLeaseUntil: sql`DATE_ADD(GREATEST(${loomMigrationRequests.activeImportLeaseUntil}, ${deadline}), INTERVAL 1 SECOND)`,
+ updatedAt: now,
+ })
+ .where(
+ and(
+ eq(loomMigrationRequests.id, requestId),
+ eq(loomMigrationRequests.activeImportLeaseToken, token),
+ gt(loomMigrationRequests.activeImportLeaseUntil, now),
+ isNotNull(loomMigrationRequests.activeOrganizationId),
+ ),
+ );
+ if (affectedRows(result) === 0) {
+ throw new Error("The concierge import expired. Refresh and retry.");
+ }
+}
+
export async function getCustomerMigrationRequests(
organizationId: Organisation.OrganisationId,
) {
From 1a285b2575e04e7191dcdcde0e1140795b198831 Mon Sep 17 00:00:00 2001
From: Abdeslam Yassine Agmar
Date: Tue, 13 Oct 2026 12:48:14 +0000
Subject: [PATCH 08/74] perf(desktop-gpui): add a timeline draw benchmark for
the editor window
`CAP_GPUI_AUTO_TIMELINE_BENCH=1` times a synchronous `Window::draw` of the
editor the way `camera_bench` does for the camera bubble, after the
`CAP_GPUI_AUTO_EDITOR` project is open and its waveforms have landed. It
reports mean / p50 / p95 / max draw time for five scenarios: playback at
the default zoom, playback zoomed out to the whole clip, a paused hover
sweep, a zoomed-in following playback, and a clean draw with nothing
notified. The playing scenarios dirty the timeline through the production
`invalidate_playback_chrome`.
The draw-and-report helpers move out of `camera_bench` into `draw_bench`
so both benches share them.
---
apps/desktop-gpui/src/app_windows.rs | 1 +
apps/desktop-gpui/src/camera_bench.rs | 42 ++----
apps/desktop-gpui/src/draw_bench.rs | 25 ++++
apps/desktop-gpui/src/editor_bench.rs | 188 +++++++++++++++++++++++++
apps/desktop-gpui/src/editor_window.rs | 24 +++-
apps/desktop-gpui/src/main.rs | 2 +
6 files changed, 247 insertions(+), 35 deletions(-)
create mode 100644 apps/desktop-gpui/src/draw_bench.rs
create mode 100644 apps/desktop-gpui/src/editor_bench.rs
diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs
index 563781960e8..63f4ce9f2c6 100644
--- a/apps/desktop-gpui/src/app_windows.rs
+++ b/apps/desktop-gpui/src/app_windows.rs
@@ -5345,6 +5345,7 @@ fn load_editor_project_candidate(
drive_auto_sidebar(handle, cx).await;
drive_auto_playback(path, handle, cx).await;
+ crate::editor_bench::run(handle, cx).await;
drive_auto_export(handle, cx).await;
})
.detach();
diff --git a/apps/desktop-gpui/src/camera_bench.rs b/apps/desktop-gpui/src/camera_bench.rs
index 79b79d34319..9fb27fba982 100644
--- a/apps/desktop-gpui/src/camera_bench.rs
+++ b/apps/desktop-gpui/src/camera_bench.rs
@@ -16,7 +16,10 @@ use std::time::{Duration, Instant};
use gpui::App;
-use crate::app_windows::{self, AppWindows};
+use crate::{
+ app_windows::{self, AppWindows},
+ draw_bench,
+};
const FRAME_WIDTH: usize = 1280;
const FRAME_HEIGHT: usize = 720;
@@ -43,7 +46,7 @@ pub fn run(cx: &mut App) {
let frame = native_frame(&sources[index % sources.len()]);
let delivered = cx.update(|cx| {
let delivered = app_windows::deliver_camera_frame(frame, cx);
- let _ = draw_camera_window(handle, cx);
+ let _ = draw_bench::timed_draw(handle.into(), cx);
delivered
});
if !delivered {
@@ -58,7 +61,7 @@ pub fn run(cx: &mut App) {
let frame = native_frame(&sources[index % sources.len()]);
let elapsed = cx.update(|cx| {
app_windows::deliver_camera_frame(frame, cx);
- draw_camera_window(handle, cx)
+ draw_bench::timed_draw(handle.into(), cx)
});
if let Some(elapsed) = elapsed {
draw_us.push(elapsed.as_secs_f64() * 1e6);
@@ -79,7 +82,7 @@ pub fn run(cx: &mut App) {
let mut clean_us = Vec::with_capacity(MEASURED_FRAMES);
for _ in 0..MEASURED_FRAMES {
cx.background_executor().timer(interval).await;
- let elapsed = cx.update(|cx| draw_camera_window(handle, cx));
+ let elapsed = cx.update(|cx| draw_bench::timed_draw(handle.into(), cx));
if let Some(elapsed) = elapsed {
clean_us.push(elapsed.as_secs_f64() * 1e6);
}
@@ -89,40 +92,13 @@ pub fn run(cx: &mut App) {
"camera-bench: {} frames at {FPS}fps ({FRAME_WIDTH}x{FRAME_HEIGHT} 420v camera frames)",
draw_us.len()
);
- report("frame draw", &mut draw_us);
- report("clean draw", &mut clean_us);
+ draw_bench::report("camera-bench", "frame draw", &mut draw_us);
+ draw_bench::report("camera-bench", "clean draw", &mut clean_us);
std::process::exit(0);
})
.detach();
}
-fn report(label: &str, samples: &mut [f64]) {
- samples.sort_by(|a, b| a.partial_cmp(b).expect("no NaN draw times"));
- let mean = samples.iter().sum::() / samples.len() as f64;
- let p50 = samples[samples.len() / 2];
- let p95 = samples[(samples.len() as f64 * 0.95) as usize];
- let max = samples[samples.len() - 1];
- println!(
- "camera-bench: {label} {mean:.1} mean / {p50:.1} p50 / {p95:.1} p95 / {max:.1} max us"
- );
-}
-
-/// One synchronous `Window::draw`, timed. `AnyWindowHandle::update` passes the
-/// root as an unleased `AnyView`; the typed handle would lease the root entity
-/// that `draw()` re-renders.
-fn draw_camera_window(
- handle: gpui::WindowHandle,
- cx: &mut App,
-) -> Option {
- gpui::AnyWindowHandle::from(handle)
- .update(cx, |_, window, cx| {
- let started = Instant::now();
- window.draw(cx).clear();
- started.elapsed()
- })
- .ok()
-}
-
fn native_frame(
sample_buf: &cidre::arc::R,
) -> cap_recording::NativeCameraFrame {
diff --git a/apps/desktop-gpui/src/draw_bench.rs b/apps/desktop-gpui/src/draw_bench.rs
new file mode 100644
index 00000000000..efbc8ae8a74
--- /dev/null
+++ b/apps/desktop-gpui/src/draw_bench.rs
@@ -0,0 +1,25 @@
+use std::time::{Duration, Instant};
+
+use gpui::{AnyWindowHandle, App};
+
+/// One synchronous `Window::draw`, timed. `AnyWindowHandle::update` passes the
+/// root as an unleased `AnyView`; the typed handle would lease the root entity
+/// that `draw()` re-renders.
+pub(crate) fn timed_draw(handle: AnyWindowHandle, cx: &mut App) -> Option {
+ handle
+ .update(cx, |_, window, cx| {
+ let started = Instant::now();
+ window.draw(cx).clear();
+ started.elapsed()
+ })
+ .ok()
+}
+
+pub(crate) fn report(prefix: &str, label: &str, samples: &mut [f64]) {
+ samples.sort_by(|a, b| a.partial_cmp(b).expect("no NaN draw times"));
+ let mean = samples.iter().sum::() / samples.len() as f64;
+ let p50 = samples[samples.len() / 2];
+ let p95 = samples[(samples.len() as f64 * 0.95) as usize];
+ let max = samples[samples.len() - 1];
+ println!("{prefix}: {label} {mean:.1} mean / {p50:.1} p50 / {p95:.1} p95 / {max:.1} max us");
+}
diff --git a/apps/desktop-gpui/src/editor_bench.rs b/apps/desktop-gpui/src/editor_bench.rs
new file mode 100644
index 00000000000..a72196707cf
--- /dev/null
+++ b/apps/desktop-gpui/src/editor_bench.rs
@@ -0,0 +1,188 @@
+//! Needs a clip of at least 30 s so the `follow` scenario does not run off the
+//! end. Run from `apps/desktop-gpui`:
+//! CAP_GPUI_AUTO_EDITOR= CAP_GPUI_AUTO_TIMELINE_BENCH=1 \
+//! CAP_GPUI_NO_MENUS=1 CAP_GPUI_NO_TRAY=1 CAP_GPUI_MUTE_AUDIO=1 \
+//! cargo run --release
+
+use std::time::{Duration, Instant};
+
+use gpui::{AsyncApp, Context, Window, WindowHandle};
+
+use crate::{draw_bench, editor_timeline, editor_window::EditorWindow};
+
+const WARMUP_FRAMES: usize = 60;
+const MEASURED_FRAMES: usize = 300;
+const FRAME_INTERVAL: Duration = Duration::from_millis(16);
+const READY_POLL: Duration = Duration::from_millis(50);
+const READY_TIMEOUT: Duration = Duration::from_secs(30);
+const FOLLOW_ZOOM_SECS: f64 = 20.0;
+const FOLLOW_START_FRACTION: f64 = 0.82;
+
+#[derive(Clone, Copy)]
+enum Scenario {
+ Tick,
+ TickZoomedOut,
+ Hover,
+ Follow,
+ Clean,
+}
+
+impl Scenario {
+ fn label(self) -> &'static str {
+ match self {
+ Self::Tick => "tick",
+ Self::TickZoomedOut => "tick-zoomed-out",
+ Self::Hover => "hover",
+ Self::Follow => "follow",
+ Self::Clean => "clean",
+ }
+ }
+
+ fn prepare(self, view: &mut EditorWindow, window: &mut Window, cx: &mut Context) {
+ match self {
+ Self::Tick => {
+ if !view.playing {
+ view.toggle_play(window, cx);
+ }
+ }
+ Self::TickZoomedOut => {
+ view.bench_zoom_to(view.total_duration(), cx);
+ if !view.playing {
+ view.toggle_play(window, cx);
+ }
+ }
+ Self::Hover => {
+ if view.playing {
+ view.stop_for_measurement(cx);
+ }
+ view.bench_zoom_to(view.total_duration(), cx);
+ view.seek_fraction(0., window, cx);
+ }
+ Self::Follow => {
+ if view.playing {
+ view.stop_for_measurement(cx);
+ }
+ view.bench_zoom_to(FOLLOW_ZOOM_SECS, cx);
+ view.seek_fraction(FOLLOW_START_FRACTION, window, cx);
+ view.toggle_play(window, cx);
+ }
+ Self::Clean => {
+ if view.playing {
+ view.stop_for_measurement(cx);
+ }
+ view.bench_zoom_to(view.total_duration(), cx);
+ }
+ }
+ }
+
+ fn step(
+ self,
+ iteration: usize,
+ view: &mut EditorWindow,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ match self {
+ Self::Tick | Self::TickZoomedOut | Self::Follow => {
+ view.invalidate_playback_chrome(window, cx)
+ }
+ Self::Hover => {
+ let viewport_width: f32 = window.viewport_size().width.into();
+ let fraction = iteration as f32 / (WARMUP_FRAMES + MEASURED_FRAMES) as f32;
+ let x = editor_timeline::content_left()
+ + editor_timeline::content_width(viewport_width) * fraction;
+ view.timeline_hover(x, window, cx);
+ }
+ Self::Clean => {}
+ }
+ }
+}
+
+pub(crate) async fn run(handle: WindowHandle, cx: &mut AsyncApp) {
+ if !std::env::var("CAP_GPUI_AUTO_TIMELINE_BENCH").is_ok_and(|value| value == "1") {
+ return;
+ }
+
+ let started = Instant::now();
+ loop {
+ let ready = handle
+ .update(cx, |view, _, _| {
+ let (mic, system) = view.has_waveforms();
+ view.latest_frame.is_some() && (mic || system)
+ })
+ .unwrap_or(false);
+ if ready {
+ break;
+ }
+ if started.elapsed() > READY_TIMEOUT {
+ eprintln!(
+ "timeline-bench: no rendered frame and waveforms within {}s",
+ READY_TIMEOUT.as_secs()
+ );
+ std::process::exit(1);
+ }
+ cx.background_executor().timer(READY_POLL).await;
+ }
+
+ let Ok(header) = handle.update(cx, |view, window, _| {
+ let viewport_width: f32 = window.viewport_size().width.into();
+ let content_width = editor_timeline::content_width(viewport_width);
+ let (mic, system) = view.has_waveforms();
+ format!(
+ "timeline-bench: viewport {viewport_width:.0}px, content {content_width:.0}px, \
+ clip {:.1}s, mic {mic}, system {system}, target {} samples/path",
+ view.total_duration(),
+ editor_timeline::waveform_sample_count(f64::from(content_width)),
+ )
+ }) else {
+ eprintln!("timeline-bench: the editor window closed");
+ std::process::exit(1);
+ };
+ println!("{header}");
+
+ for scenario in [
+ Scenario::Tick,
+ Scenario::TickZoomedOut,
+ Scenario::Hover,
+ Scenario::Follow,
+ Scenario::Clean,
+ ] {
+ if handle
+ .update(cx, |view, window, cx| scenario.prepare(view, window, cx))
+ .is_err()
+ {
+ eprintln!("timeline-bench: the editor window closed");
+ std::process::exit(1);
+ }
+
+ let mut draw_us = Vec::with_capacity(MEASURED_FRAMES);
+ for iteration in 0..WARMUP_FRAMES + MEASURED_FRAMES {
+ cx.background_executor().timer(FRAME_INTERVAL).await;
+ let elapsed = cx.update(|cx| {
+ handle
+ .update(cx, |view, window, cx| {
+ scenario.step(iteration, view, window, cx)
+ })
+ .ok()?;
+ draw_bench::timed_draw(handle.into(), cx)
+ });
+ if iteration >= WARMUP_FRAMES
+ && let Some(elapsed) = elapsed
+ {
+ draw_us.push(elapsed.as_secs_f64() * 1e6);
+ }
+ }
+
+ if draw_us.len() < MEASURED_FRAMES {
+ eprintln!(
+ "timeline-bench: only {} of {MEASURED_FRAMES} {} draws completed",
+ draw_us.len(),
+ scenario.label()
+ );
+ std::process::exit(1);
+ }
+ draw_bench::report("timeline-bench", scenario.label(), &mut draw_us);
+ }
+
+ std::process::exit(0);
+}
diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs
index 5270dcd1f30..7484cb3ce60 100644
--- a/apps/desktop-gpui/src/editor_window.rs
+++ b/apps/desktop-gpui/src/editor_window.rs
@@ -3298,6 +3298,22 @@ impl EditorWindow {
self.stop_playback(cx);
}
+ pub(crate) fn has_waveforms(&self) -> (bool, bool) {
+ let present = |tables: &[Arc>]| tables.iter().any(|peaks| !peaks.is_empty());
+ (
+ present(&self.timeline.mic_waveforms),
+ present(&self.timeline.system_waveforms),
+ )
+ }
+
+ pub(crate) fn bench_zoom_to(&mut self, seconds: f64, cx: &mut Context) {
+ let total = self.total_duration();
+ self.view
+ .transform
+ .update_zoom(seconds, self.playhead, total);
+ cx.notify();
+ }
+
fn capture_playback_key(
&mut self,
event: &gpui::KeyDownEvent,
@@ -3741,7 +3757,11 @@ impl EditorWindow {
cx.notify();
}
- fn invalidate_playback_chrome(&mut self, _window: &mut Window, cx: &mut Context) {
+ pub(crate) fn invalidate_playback_chrome(
+ &mut self,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) {
self.timeline_view.update(cx, |_, cx| cx.notify());
self.transport_controls.update(cx, |_, cx| cx.notify());
// While playing, nothing else the parent renders reads the playhead:
@@ -3775,7 +3795,7 @@ impl EditorWindow {
/// `onMouseMove` on the timeline container (`TL/index.tsx:1170-1188`):
/// while paused, the pointer's time becomes `previewTime`; outside the
/// content column, and at all times while playing, it is cleared.
- fn timeline_hover(&mut self, x: f32, window: &mut Window, cx: &mut Context) {
+ pub(crate) fn timeline_hover(&mut self, x: f32, window: &mut Window, cx: &mut Context) {
// `if (editorState.playing) return;` -- the handler bails *before* it
// writes, so a preview time set while paused survives a play rather
// than being cleared. The ghost is hidden by the render's own
diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs
index 98a6b1b2b23..6ff3d707d68 100644
--- a/apps/desktop-gpui/src/main.rs
+++ b/apps/desktop-gpui/src/main.rs
@@ -19,7 +19,9 @@ mod deeplink;
mod dev_restore;
mod devices;
mod diagnostics;
+mod draw_bench;
mod editor_audio;
+mod editor_bench;
mod editor_canvas;
mod editor_clips;
mod editor_color;
From c07fd65533145b20f90a65ad77419a6d112491e3 Mon Sep 17 00:00:00 2001
From: Abdeslam Yassine Agmar
Date: Tue, 13 Oct 2026 12:48:14 +0000
Subject: [PATCH 09/74] perf(desktop-gpui): cache tessellated timeline
waveforms between repaints
The timeline repaints on every playhead tick during playback, and each
repaint rebuilt the mic and system waveform fills with lyon even when
their inputs had not changed. Zoomed out to a whole 180 s clip the two
fills tessellate to about 34,000 vertices, which cost the editor around
16 ms per draw on `main`, a full 60 Hz frame budget.
`waveform_path` now takes one `WaveformGeometry` value, which doubles as
the key of a `WaveformPaths` cache on `TimelineModel`: one slot per clip
segment and track, compared on every builder input with the peak table
matched by `Arc` identity, swept each render so only last frame's visible
paths are kept, and carried across `rebuild_timeline` like the waveforms.
The paint closure clones the stored path on a hit; colour, opacity, mask
and DPI scale are still applied by `paint_path` per paint. Once the view
follow-scrolls, the range changes every tick and the cache misses, so
scrolling playback costs what it did before.
Timeline draw benchmark, 180 s clip, 5 interleaved runs, median p50:
playing at the default zoom before the view follows 1.12 ms -> 0.70 ms,
playing zoomed out 15.7 ms -> 0.80 ms, paused hover zoomed out
16.1 ms -> 0.96 ms, scrolling playback (every draw misses)
1.39 ms -> 1.36 ms.
---
apps/desktop-gpui/src/editor_bench.rs | 26 +-
apps/desktop-gpui/src/editor_timeline.rs | 464 ++++++++++++++++++++---
apps/desktop-gpui/src/editor_window.rs | 7 +
3 files changed, 431 insertions(+), 66 deletions(-)
diff --git a/apps/desktop-gpui/src/editor_bench.rs b/apps/desktop-gpui/src/editor_bench.rs
index a72196707cf..c1c9c5f31fa 100644
--- a/apps/desktop-gpui/src/editor_bench.rs
+++ b/apps/desktop-gpui/src/editor_bench.rs
@@ -156,20 +156,33 @@ pub(crate) async fn run(handle: WindowHandle, cx: &mut AsyncApp) {
}
let mut draw_us = Vec::with_capacity(MEASURED_FRAMES);
+ let mut built = 0;
+ let mut reused = 0;
for iteration in 0..WARMUP_FRAMES + MEASURED_FRAMES {
cx.background_executor().timer(FRAME_INTERVAL).await;
let elapsed = cx.update(|cx| {
- handle
+ let before = handle
.update(cx, |view, window, cx| {
- scenario.step(iteration, view, window, cx)
+ scenario.step(iteration, view, window, cx);
+ view.waveform_cache_stats()
})
.ok()?;
- draw_bench::timed_draw(handle.into(), cx)
+ let elapsed = draw_bench::timed_draw(handle.into(), cx)?;
+ let after = handle
+ .update(cx, |view, _, _| view.waveform_cache_stats())
+ .ok()?;
+ Some((
+ elapsed,
+ after.builds.saturating_sub(before.builds),
+ after.hits.saturating_sub(before.hits),
+ ))
});
if iteration >= WARMUP_FRAMES
- && let Some(elapsed) = elapsed
+ && let Some((elapsed, draw_built, draw_reused)) = elapsed
{
draw_us.push(elapsed.as_secs_f64() * 1e6);
+ built += draw_built;
+ reused += draw_reused;
}
}
@@ -182,6 +195,11 @@ pub(crate) async fn run(handle: WindowHandle, cx: &mut AsyncApp) {
std::process::exit(1);
}
draw_bench::report("timeline-bench", scenario.label(), &mut draw_us);
+ println!(
+ "timeline-bench: {} waveform paths built {built} / reused {reused} in {} timed draws",
+ scenario.label(),
+ draw_us.len()
+ );
}
std::process::exit(0);
diff --git a/apps/desktop-gpui/src/editor_timeline.rs b/apps/desktop-gpui/src/editor_timeline.rs
index 2313271d708..d4c97619835 100644
--- a/apps/desktop-gpui/src/editor_timeline.rs
+++ b/apps/desktop-gpui/src/editor_timeline.rs
@@ -31,7 +31,10 @@
//! A segment wider than the viewport has its true centre off screen, so
//! [`visible_box`] clamps it (`useSegmentVisibleBox`, `TL/Track.tsx:147-181`).
-use std::sync::Arc;
+use std::{
+ collections::HashMap,
+ sync::{Arc, Mutex},
+};
mod playback_follow;
@@ -563,18 +566,18 @@ fn waveform_amplitude(peaks: &[f32], source_time: Option) -> f64 {
/// it by `(canvasWidth, canvasHeight * scale)` after translating down by
/// `canvasHeight * (1 - scale)` (`:285-290`); gpui has no path transform on
/// `paint_path`, so the same maths is applied to each point as it is emitted.
-#[allow(clippy::too_many_arguments)]
-pub fn waveform_path(
- peaks: &[f32],
- range: (f64, f64),
- target_samples: usize,
- holds: &[(f64, f64)],
- segment_start: f64,
- origin: gpui::Point,
- size: gpui::Size,
- scale: f64,
- clip_bounds: gpui::Bounds,
-) -> Option> {
+pub fn waveform_path(geometry: &WaveformGeometry) -> Option> {
+ let &WaveformGeometry {
+ ref peaks,
+ range,
+ target_samples,
+ ref holds,
+ source_start: segment_start,
+ origin,
+ size,
+ scale,
+ clip_bounds,
+ } = geometry;
if peaks.is_empty() || scale <= 0. {
return None;
}
@@ -669,6 +672,129 @@ pub fn waveform_path(
builder.build().ok()
}
+/// Everything [`waveform_path`] reads, and the [`WaveformPaths`] key. The peak
+/// table is compared by pointer: an entry keeps a strong reference, so the
+/// allocation can't be reused while it is cached.
+#[derive(Debug, Clone)]
+pub struct WaveformGeometry {
+ pub peaks: Arc>,
+ pub range: (f64, f64),
+ pub target_samples: usize,
+ pub holds: Vec<(f64, f64)>,
+ pub source_start: f64,
+ pub origin: gpui::Point,
+ pub size: gpui::Size,
+ pub scale: f64,
+ pub clip_bounds: gpui::Bounds,
+}
+
+impl PartialEq for WaveformGeometry {
+ fn eq(&self, other: &Self) -> bool {
+ Arc::ptr_eq(&self.peaks, &other.peaks)
+ && self.range == other.range
+ && self.target_samples == other.target_samples
+ && self.holds == other.holds
+ && self.source_start == other.source_start
+ && self.origin == other.origin
+ && self.size == other.size
+ && self.scale == other.scale
+ && self.clip_bounds == other.clip_bounds
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum WaveformTrack {
+ Mic,
+ System,
+}
+
+/// Shared by every clone of the model, so a drag preview reuses the committed
+/// paths and an edit keeps the paths whose slot and geometry it left alone.
+#[derive(Default)]
+pub struct WaveformPaths(Mutex);
+
+#[derive(Default)]
+struct WaveformPathsInner {
+ entries: HashMap<(usize, WaveformTrack), WaveformEntry>,
+ builds: u64,
+ hits: u64,
+}
+
+struct WaveformEntry {
+ geometry: WaveformGeometry,
+ path: gpui::Path,
+ painted: bool,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct WaveformPathStats {
+ pub builds: u64,
+ pub hits: u64,
+}
+
+impl std::fmt::Debug for WaveformPaths {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self.0.try_lock() {
+ Ok(inner) => f
+ .debug_struct("WaveformPaths")
+ .field("entries", &inner.entries.len())
+ .field("builds", &inner.builds)
+ .field("hits", &inner.hits)
+ .finish(),
+ Err(_) => f.write_str("WaveformPaths()"),
+ }
+ }
+}
+
+impl WaveformPaths {
+ fn inner(&self) -> std::sync::MutexGuard<'_, WaveformPathsInner> {
+ self.0
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ }
+
+ pub fn get_or_build(
+ &self,
+ slot: (usize, WaveformTrack),
+ geometry: WaveformGeometry,
+ ) -> Option> {
+ let mut inner = self.inner();
+ if let Some(entry) = inner.entries.get_mut(&slot)
+ && entry.geometry == geometry
+ {
+ entry.painted = true;
+ let path = entry.path.clone();
+ inner.hits += 1;
+ return Some(path);
+ }
+ let path = waveform_path(&geometry)?;
+ inner.builds += 1;
+ inner.entries.insert(
+ slot,
+ WaveformEntry {
+ geometry,
+ path: path.clone(),
+ painted: true,
+ },
+ );
+ Some(path)
+ }
+
+ pub fn sweep(&self) {
+ self.inner()
+ .entries
+ .retain(|_, entry| std::mem::take(&mut entry.painted));
+ }
+
+ pub fn stats(&self) -> WaveformPathStats {
+ let inner = self.inner();
+ WaveformPathStats {
+ builds: inner.builds,
+ hits: inner.hits,
+ }
+ }
+}
+
fn rounded_corner_inset(x: f64, width: f64, radius: f64) -> f64 {
let distance = x.min(width - x).clamp(0., radius);
if distance >= radius {
@@ -1063,6 +1189,7 @@ pub struct TimelineModel {
/// (`TL/ClipTrack.tsx:713-730`).
pub mic_waveforms: Vec>>,
pub system_waveforms: Vec>>,
+ pub waveform_paths: Arc,
/// The span a live ghost trim is removing, in output time. Drawn as a gap
/// with a red duration badge, the way Blip's ghost resize marks the cut.
pub clip_ghost_gap: Option<(f64, f64)>,
@@ -1292,6 +1419,7 @@ impl TimelineModel {
system_volume_db: config.audio.system_volume_db as f64,
mic_waveforms: Vec::new(),
system_waveforms: Vec::new(),
+ waveform_paths: Arc::default(),
clip_ghost_gap: None,
};
model.rows = build_rows(
@@ -2025,6 +2153,7 @@ fn render_track_content(
theme,
model,
row.kind,
+ index,
segment,
view,
secs_per_pixel,
@@ -2444,6 +2573,7 @@ fn render_segment(
theme: &Theme,
model: &TimelineModel,
kind: TrackKind,
+ index: usize,
segment: &Segment,
view: TimelineView,
secs_per_pixel: f64,
@@ -2508,6 +2638,7 @@ fn render_segment(
if segment.detail.shows_waveform() {
fill = fill.child(render_waveform(
model,
+ index,
segment,
*recording_clip,
holds,
@@ -2999,6 +3130,7 @@ fn render_hold(
#[allow(clippy::too_many_arguments)]
fn render_waveform(
model: &TimelineModel,
+ index: usize,
segment: &Segment,
recording_clip: u32,
holds: &[(f64, f64)],
@@ -3041,6 +3173,7 @@ fn render_waveform(
let full_width = width.max(1.) as f64;
let wave_height = height.min(WAVEFORM_MAX_HEIGHT);
let wave_color = waveform_color(color);
+ let paths = model.waveform_paths.clone();
gpui::canvas(
|bounds, _window, _cx| bounds,
@@ -3063,21 +3196,27 @@ fn render_waveform(
let size = gpui::size(px(slice_width), px(wave_height));
let samples = waveform_sample_count(slice_width as f64);
- for (peaks, scale) in [(&mic, mic_scale), (&system, system_scale)] {
- if let Some(path) = waveform_path(
- peaks,
- (visible_start, visible_end),
- samples,
- &holds,
- segment_start,
+ let clip_bounds = gpui::Bounds {
+ origin: gpui::point(bounds.origin.x, bounds.bottom() - px(height)),
+ size: gpui::size(px(width), px(height)),
+ };
+
+ for (track, peaks, scale) in [
+ (WaveformTrack::Mic, &mic, mic_scale),
+ (WaveformTrack::System, &system, system_scale),
+ ] {
+ let geometry = WaveformGeometry {
+ peaks: peaks.clone(),
+ range: (visible_start, visible_end),
+ target_samples: samples,
+ holds: holds.clone(),
+ source_start: segment_start,
origin,
size,
scale,
- gpui::Bounds {
- origin: gpui::point(bounds.origin.x, bounds.bottom() - px(height)),
- size: gpui::size(px(width), px(height)),
- },
- ) {
+ clip_bounds,
+ };
+ if let Some(path) = paths.get_or_build((index, track), geometry) {
window.with_content_mask(Some(gpui::ContentMask { bounds }), |window| {
window.paint_path(path, wave_color)
});
@@ -4336,21 +4475,22 @@ mod tests {
};
let mut peaks = vec![-60.; 220];
peaks[200..].fill(-30.);
+ let peaks = Arc::new(peaks);
+ let geometry = |scale| WaveformGeometry {
+ peaks: peaks.clone(),
+ range: (0., 2.),
+ target_samples: 400,
+ holds: Vec::new(),
+ source_start: 20.,
+ origin: gpui::point(px(0.), px(26.)),
+ size: gpui::size(px(200.), px(18.)),
+ scale,
+ clip_bounds: bounds,
+ };
for volume in [0.25, 0.5, 1.0, 2.0] {
for gain in [-15., 0.] {
let scale = gain_to_scale(gain) * volume;
- let path = waveform_path(
- &peaks,
- (0., 2.),
- 400,
- &[],
- 20.,
- gpui::point(px(0.), px(26.)),
- gpui::size(px(200.), px(18.)),
- scale,
- bounds,
- )
- .unwrap();
+ let path = waveform_path(&geometry(scale)).unwrap();
let top = path
.vertices
.iter()
@@ -4364,20 +4504,7 @@ mod tests {
assert!((top - expected_top).abs() < 0.01, "{top} != {expected_top}");
}
}
- assert!(
- waveform_path(
- &peaks,
- (0., 2.),
- 400,
- &[],
- 20.,
- gpui::point(px(0.), px(26.)),
- gpui::size(px(200.), px(18.)),
- 0.,
- bounds,
- )
- .is_none()
- );
+ assert!(waveform_path(&geometry(0.)).is_none());
}
#[test]
@@ -4425,6 +4552,219 @@ mod tests {
assert!(waveform_peaks(&[], 1).is_empty());
}
+ fn waveform_vertices(path: &gpui::Path) -> Vec<(f32, f32)> {
+ path.vertices
+ .iter()
+ .map(|vertex| {
+ (
+ f32::from(vertex.xy_position.x),
+ f32::from(vertex.xy_position.y),
+ )
+ })
+ .collect()
+ }
+
+ fn speech_like_peaks(len: usize, period: f32) -> Arc> {
+ Arc::new(
+ (0..len)
+ .map(|i| -40. + 30. * ((i as f32 / period).sin().abs()))
+ .collect(),
+ )
+ }
+
+ fn waveform_geometry(peaks: Arc>) -> WaveformGeometry {
+ WaveformGeometry {
+ peaks,
+ range: (0., 20.),
+ target_samples: 400,
+ holds: Vec::new(),
+ source_start: 5.,
+ origin: gpui::point(px(10.), px(26.)),
+ size: gpui::size(px(300.), px(18.)),
+ scale: 0.8,
+ clip_bounds: gpui::Bounds {
+ origin: gpui::point(px(10.), px(0.)),
+ size: gpui::size(px(300.), px(44.)),
+ },
+ }
+ }
+
+ #[test]
+ fn cached_waveform_matches_an_uncached_build_after_every_input_change() {
+ let paths = WaveformPaths::default();
+ let slot = (0, WaveformTrack::Mic);
+ let base = waveform_geometry(speech_like_peaks(300, 7.));
+ let first = paths.get_or_build(slot, base.clone()).unwrap();
+ let again = paths.get_or_build(slot, base.clone()).unwrap();
+ assert_eq!(waveform_vertices(&first), waveform_vertices(&again));
+ assert_eq!(paths.stats().builds, 1);
+ assert_eq!(paths.stats().hits, 1);
+
+ let same_contents_new_table = Arc::new((*base.peaks).clone());
+ let mutations: Vec<(&str, WaveformGeometry)> = vec![
+ (
+ "range",
+ WaveformGeometry {
+ range: (2., 20.),
+ ..base.clone()
+ },
+ ),
+ (
+ "samples",
+ WaveformGeometry {
+ target_samples: 200,
+ ..base.clone()
+ },
+ ),
+ (
+ "hold added",
+ WaveformGeometry {
+ holds: vec![(4., 6.)],
+ ..base.clone()
+ },
+ ),
+ (
+ "source start",
+ WaveformGeometry {
+ source_start: 6.,
+ ..base.clone()
+ },
+ ),
+ (
+ "origin",
+ WaveformGeometry {
+ origin: gpui::point(px(12.), px(26.)),
+ ..base.clone()
+ },
+ ),
+ (
+ "size",
+ WaveformGeometry {
+ size: gpui::size(px(280.), px(18.)),
+ ..base.clone()
+ },
+ ),
+ (
+ "scale",
+ WaveformGeometry {
+ scale: 0.4,
+ ..base.clone()
+ },
+ ),
+ (
+ "clip bounds",
+ WaveformGeometry {
+ clip_bounds: gpui::Bounds {
+ origin: gpui::point(px(10.), px(0.)),
+ size: gpui::size(px(40.), px(44.)),
+ },
+ ..base.clone()
+ },
+ ),
+ (
+ "new table, new contents",
+ WaveformGeometry {
+ peaks: speech_like_peaks(300, 3.),
+ ..base.clone()
+ },
+ ),
+ ];
+ for (label, mutated) in mutations {
+ let paths = WaveformPaths::default();
+ paths.get_or_build(slot, base.clone()).unwrap();
+ let cached = paths.get_or_build(slot, mutated.clone()).unwrap();
+ let uncached = waveform_path(&mutated).unwrap();
+ assert_eq!(
+ waveform_vertices(&cached),
+ waveform_vertices(&uncached),
+ "{label}"
+ );
+ assert_eq!(paths.stats().builds, 2, "{label} must rebuild");
+ assert_ne!(
+ waveform_vertices(&cached),
+ waveform_vertices(&first),
+ "{label} must change the mesh"
+ );
+ }
+
+ let paths = WaveformPaths::default();
+ paths.get_or_build(slot, base.clone()).unwrap();
+ let rebuilt = paths
+ .get_or_build(
+ slot,
+ WaveformGeometry {
+ peaks: same_contents_new_table,
+ ..base
+ },
+ )
+ .unwrap();
+ assert_eq!(waveform_vertices(&rebuilt), waveform_vertices(&first));
+ assert_eq!(paths.stats().builds, 2);
+ }
+
+ #[test]
+ fn split_segments_from_one_recording_clip_keep_their_own_paths() {
+ let paths = WaveformPaths::default();
+ let peaks = speech_like_peaks(300, 7.);
+ let first_half = WaveformGeometry {
+ range: (0., 10.),
+ ..waveform_geometry(peaks.clone())
+ };
+ let second_half = WaveformGeometry {
+ source_start: 15.,
+ origin: gpui::point(px(320.), px(26.)),
+ ..waveform_geometry(peaks)
+ };
+ for _ in 0..4 {
+ let a = paths
+ .get_or_build((0, WaveformTrack::Mic), first_half.clone())
+ .unwrap();
+ let b = paths
+ .get_or_build((1, WaveformTrack::Mic), second_half.clone())
+ .unwrap();
+ assert_eq!(
+ waveform_vertices(&a),
+ waveform_vertices(&waveform_path(&first_half).unwrap())
+ );
+ assert_eq!(
+ waveform_vertices(&b),
+ waveform_vertices(&waveform_path(&second_half).unwrap())
+ );
+ }
+ assert_eq!(paths.stats().builds, 2);
+ assert_eq!(paths.stats().hits, 6);
+ }
+
+ #[test]
+ fn waveform_cache_keeps_only_paths_painted_last_frame() {
+ let paths = WaveformPaths::default();
+ let geometry = waveform_geometry(speech_like_peaks(300, 7.));
+ for index in 0..4 {
+ paths
+ .get_or_build((index, WaveformTrack::Mic), geometry.clone())
+ .unwrap();
+ paths
+ .get_or_build((index, WaveformTrack::System), geometry.clone())
+ .unwrap();
+ }
+ paths.sweep();
+ paths
+ .get_or_build((1, WaveformTrack::Mic), geometry.clone())
+ .unwrap();
+ paths.sweep();
+ let stats = paths.stats();
+ assert_eq!((stats.builds, stats.hits), (8, 1));
+
+ paths
+ .get_or_build((1, WaveformTrack::Mic), geometry.clone())
+ .unwrap();
+ paths
+ .get_or_build((0, WaveformTrack::Mic), geometry)
+ .unwrap();
+ let stats = paths.stats();
+ assert_eq!((stats.builds, stats.hits), (9, 2));
+ }
+
#[test]
fn waveform_mesh_stays_inside_real_clip_corners() {
for width in [4., 16., 100., 10_000.] {
@@ -4436,17 +4776,17 @@ mod tests {
for gain in [0.1, 0.5, 1., 2.] {
let x = start / 10. * width as f64;
let span = (end - start) / 10. * width as f64;
- let path = waveform_path(
- &[-6.; 200],
- (start, end),
- 200,
- &[],
- 0.,
- gpui::point(px(50. + x as f32), px(56.)),
- gpui::size(px(span as f32), px(18.)),
- gain,
- bounds,
- )
+ let path = waveform_path(&WaveformGeometry {
+ peaks: Arc::new(vec![-6.; 200]),
+ range: (start, end),
+ target_samples: 200,
+ holds: Vec::new(),
+ source_start: 0.,
+ origin: gpui::point(px(50. + x as f32), px(56.)),
+ size: gpui::size(px(span as f32), px(18.)),
+ scale: gain,
+ clip_bounds: bounds,
+ })
.unwrap();
assert!(!path.vertices.is_empty());
for vertex in &path.vertices {
diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs
index 7484cb3ce60..211f8c857e9 100644
--- a/apps/desktop-gpui/src/editor_window.rs
+++ b/apps/desktop-gpui/src/editor_window.rs
@@ -2288,6 +2288,7 @@ impl EditorWindow {
dismiss_indexed_sidebar_menu(&mut self.sidebar.menu);
let mic = std::mem::take(&mut self.timeline.mic_waveforms);
let system = std::mem::take(&mut self.timeline.system_waveforms);
+ let waveform_paths = self.timeline.waveform_paths.clone();
self.timeline = TimelineModel::build_with_lanes(
&self.project,
self.has_camera,
@@ -2296,6 +2297,7 @@ impl EditorWindow {
);
self.timeline.mic_waveforms = mic;
self.timeline.system_waveforms = system;
+ self.timeline.waveform_paths = waveform_paths;
if self.timeline.total_duration > 0.0 {
self.total = self.timeline.total_duration;
}
@@ -3306,6 +3308,10 @@ impl EditorWindow {
)
}
+ pub(crate) fn waveform_cache_stats(&self) -> crate::editor_timeline::WaveformPathStats {
+ self.timeline.waveform_paths.stats()
+ }
+
pub(crate) fn bench_zoom_to(&mut self, seconds: f64, cx: &mut Context) {
let total = self.total_duration();
self.view
@@ -10189,6 +10195,7 @@ impl EditorWindow {
cx: &mut Context,
) -> impl IntoElement {
let theme = self.theme;
+ self.timeline.waveform_paths.sweep();
if !self.project_ready() {
if self
.preparing_presentation
From 6810d187a527810b01bcab6b86fca30f538a188b Mon Sep 17 00:00:00 2001
From: Aviral Shukla
Date: Tue, 13 Oct 2026 20:43:52 +0530
Subject: [PATCH 10/74] feat(mask): implement blur scissor logic and optimise
rendering pipeline
---
crates/rendering/src/layers/mask.rs | 107 +++++++++++++++++++++++++++-
1 file changed, 106 insertions(+), 1 deletion(-)
diff --git a/crates/rendering/src/layers/mask.rs b/crates/rendering/src/layers/mask.rs
index 2e36046ca4b..872d8a32bec 100644
--- a/crates/rendering/src/layers/mask.rs
+++ b/crates/rendering/src/layers/mask.rs
@@ -137,7 +137,7 @@ impl MaskLayer {
pass.set_pipeline(config.render_pipeline);
pass.set_bind_group(0, &bind_group, &[]);
- pass.draw(0..3, 0..1);
+ config.uniforms.draw(&mut pass);
drop(pass);
}
@@ -165,6 +165,50 @@ impl Default for MaskUniforms {
}
impl MaskUniforms {
+ fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
+ if let Some(rect @ [left, top, width, height]) = self.blur_scissor() {
+ if width == 0 || height == 0 {
+ return;
+ }
+ if rect != [0, 0, self.output_size[0] as u32, self.output_size[1] as u32] {
+ pass.set_scissor_rect(left, top, width, height);
+ }
+ }
+ pass.draw(0..3, 0..1);
+ }
+
+ fn blur_scissor(&self) -> Option<[u32; 4]> {
+ let padding = match self.mode {
+ BLUR_HORIZONTAL_MODE => [2.0, self.effect_size.max(1.0) + 2.0],
+ BLUR_VERTICAL_MODE => [1.0, 1.0],
+ _ => return None,
+ };
+ if !self
+ .rect_center
+ .iter()
+ .chain(&self.rect_size)
+ .chain(&self.output_size)
+ .chain(std::iter::once(&self.effect_size))
+ .all(|value| value.is_finite())
+ || self.output_size.iter().any(|size| *size <= 0.0)
+ {
+ return None;
+ }
+
+ let bounds = std::array::from_fn::<_, 2, _>(|axis| {
+ let center = self.rect_center[axis] * self.output_size[axis];
+ let half_size = self.rect_size[axis] * self.output_size[axis] * 0.5;
+ let start = (center - half_size - padding[axis])
+ .floor()
+ .clamp(0.0, self.output_size[axis]) as u32;
+ let end = (center + half_size + padding[axis])
+ .ceil()
+ .clamp(0.0, self.output_size[axis]) as u32;
+ (start, end.saturating_sub(start))
+ });
+ Some([bounds[0].0, bounds[1].0, bounds[0].1, bounds[1].1])
+ }
+
fn from_mask(mask: &PreparedMask, mode: u32) -> Self {
Self {
rect_center: [mask.center.x, mask.center.y],
@@ -324,3 +368,64 @@ impl MaskPipeline {
})
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn uniforms(center: [f32; 2], size: [f32; 2], effect_size: f32, mode: u32) -> MaskUniforms {
+ MaskUniforms {
+ rect_center: center,
+ rect_size: size,
+ effect_size,
+ mode,
+ output_size: [1920.0, 1080.0],
+ ..Default::default()
+ }
+ }
+
+ #[test]
+ fn blur_scissor_bounds() {
+ let cases = [
+ ([0.5, 0.5], [0.1, 0.1], 12.0, [862, 472, 196, 136]),
+ ([0.5, 0.5], [0.5, 0.5], 24.0, [478, 244, 964, 592]),
+ ([0.5, 0.5], [1.0, 1.0], 12.0, [0, 0, 1920, 1080]),
+ ([0.02, 0.04], [0.1, 0.12], 8.0, [0, 0, 137, 118]),
+ ([-0.2, 0.5], [0.1, 0.1], 12.0, [0, 472, 0, 136]),
+ (
+ [0.3333, 0.6667],
+ [0.1234, 0.2345],
+ 7.25,
+ [519, 584, 242, 272],
+ ),
+ ([0.5, 0.5], [0.1, 0.1], 0.25, [862, 483, 196, 114]),
+ ];
+
+ for (center, size, radius, expected) in cases {
+ assert_eq!(
+ uniforms(center, size, radius, BLUR_HORIZONTAL_MODE).blur_scissor(),
+ Some(expected)
+ );
+ }
+ assert_eq!(
+ uniforms([0.5, 0.5], [0.1, 0.1], 12.0, BLUR_VERTICAL_MODE).blur_scissor(),
+ Some([863, 485, 194, 110])
+ );
+ }
+
+ #[test]
+ fn blur_scissor_fallback() {
+ assert_eq!(
+ uniforms([0.5, 0.5], [0.1, 0.1], 12.0, PIXELATE_MODE).blur_scissor(),
+ None
+ );
+ assert_eq!(
+ uniforms([f32::NAN, 0.5], [0.1, 0.1], 12.0, BLUR_HORIZONTAL_MODE).blur_scissor(),
+ None
+ );
+
+ let mut zero_output = uniforms([0.5, 0.5], [0.1, 0.1], 12.0, BLUR_HORIZONTAL_MODE);
+ zero_output.output_size[0] = 0.0;
+ assert_eq!(zero_output.blur_scissor(), None);
+ }
+}
From 6a23daf53e3d8ee674993c4ebfcbc324bd3b18a7 Mon Sep 17 00:00:00 2001
From: Aviral Shukla
Date: Tue, 13 Oct 2026 21:00:38 +0530
Subject: [PATCH 11/74] test(mask): add blur scissor pixel tests and utility
function
---
crates/rendering/src/layers/mask.rs | 152 ++++++++++++++++++++++++++++
1 file changed, 152 insertions(+)
diff --git a/crates/rendering/src/layers/mask.rs b/crates/rendering/src/layers/mask.rs
index 872d8a32bec..2d3a9dda7cb 100644
--- a/crates/rendering/src/layers/mask.rs
+++ b/crates/rendering/src/layers/mask.rs
@@ -428,4 +428,156 @@ mod tests {
zero_output.output_size[0] = 0.0;
assert_eq!(zero_output.blur_scissor(), None);
}
+
+ #[test]
+ #[ignore = "requires a real WGPU adapter"]
+ fn blur_scissor_pixels() {
+ let adapter =
+ pollster::block_on(wgpu::Instance::default().request_adapter(&Default::default()))
+ .expect("GPU mask tests require a WGPU adapter");
+ assert_ne!(adapter.get_info().backend, wgpu::Backend::Noop);
+ let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))
+ .expect("request mask test device");
+ let layer = MaskLayer::new(&device);
+ let mask = |center, size| MaskUniforms {
+ feather: 0.01,
+ opacity: 1.0,
+ output_size: [128.0, 96.0],
+ ..uniforms(center, size, 12.0, BLUR_HORIZONTAL_MODE)
+ };
+ let cases = [
+ (
+ "fractional",
+ &[mask([0.3333, 0.6667], [0.1234, 0.2345])][..],
+ ),
+ ("edge-clipped", &[mask([0.02, 0.99], [0.13, 0.12])][..]),
+ (
+ "overlapping",
+ &[
+ mask([0.47, 0.5], [0.28, 0.3]),
+ mask([0.57, 0.55], [0.26, 0.24]),
+ ],
+ ),
+ ];
+ for (name, masks) in cases {
+ let expected = blur_pixels(&device, &queue, &layer, masks, false);
+ let actual = blur_pixels(&device, &queue, &layer, masks, true);
+ assert_eq!(actual, expected, "{name} pixels differ");
+ }
+ }
+
+ fn blur_pixels(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ layer: &MaskLayer,
+ masks: &[MaskUniforms],
+ scissored: bool,
+ ) -> Vec {
+ let size = wgpu::Extent3d {
+ width: 128,
+ height: 96,
+ depth_or_array_layers: 1,
+ };
+ let texture = || {
+ device.create_texture(&wgpu::TextureDescriptor {
+ label: None,
+ size,
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: wgpu::TextureFormat::Rgba8Unorm,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING
+ | wgpu::TextureUsages::RENDER_ATTACHMENT
+ | wgpu::TextureUsages::COPY_SRC
+ | wgpu::TextureUsages::COPY_DST,
+ view_formats: &[],
+ })
+ };
+ let source = texture();
+ let scratch = texture();
+ let source_view = source.create_view(&Default::default());
+ let scratch_view = scratch.create_view(&Default::default());
+ let pixels = (0..size.width * size.height * 4)
+ .map(|index| (index * 37 % 251) as u8)
+ .collect::>();
+ let layout = wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(size.width * 4),
+ rows_per_image: Some(size.height),
+ };
+ queue.write_texture(source.as_image_copy(), &pixels, layout, size);
+ let mut encoder = device.create_command_encoder(&Default::default());
+ for mask in masks {
+ for (source, target, pipeline, load, mode) in [
+ (
+ &source_view,
+ &scratch_view,
+ &layer.pipeline.render_pipeline,
+ wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
+ BLUR_HORIZONTAL_MODE,
+ ),
+ (
+ &scratch_view,
+ &source_view,
+ &layer.pipeline.blur_composite_pipeline,
+ wgpu::LoadOp::Load,
+ BLUR_VERTICAL_MODE,
+ ),
+ ] {
+ let uniforms = MaskUniforms { mode, ..*mask };
+ let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
+ label: None,
+ contents: bytemuck::bytes_of(&uniforms),
+ usage: wgpu::BufferUsages::UNIFORM,
+ });
+ let bind_group =
+ layer
+ .pipeline
+ .bind_group(device, &uniform_buffer, source, &layer.sampler);
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: target,
+ resolve_target: None,
+ ops: wgpu::Operations {
+ load,
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ ..Default::default()
+ });
+ pass.set_pipeline(pipeline);
+ pass.set_bind_group(0, &bind_group, &[]);
+ if scissored {
+ uniforms.draw(&mut pass);
+ } else {
+ pass.draw(0..3, 0..1);
+ }
+ }
+ }
+ let readback = device.create_buffer(&wgpu::BufferDescriptor {
+ label: None,
+ size: pixels.len() as u64,
+ usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
+ mapped_at_creation: false,
+ });
+ encoder.copy_texture_to_buffer(
+ source.as_image_copy(),
+ wgpu::TexelCopyBufferInfo {
+ buffer: &readback,
+ layout,
+ },
+ size,
+ );
+ queue.submit([encoder.finish()]);
+ readback.slice(..).map_async(wgpu::MapMode::Read, |result| {
+ result.expect("map mask test pixels")
+ });
+ device
+ .poll(wgpu::PollType::Wait)
+ .expect("poll mask test device");
+ let result = readback.slice(..).get_mapped_range().to_vec();
+ readback.unmap();
+ assert!(result != pixels, "test mask must change pixels");
+ result
+ }
}
From 7e14896825a0e60f0f5917168651779191521466 Mon Sep 17 00:00:00 2001
From: Aviral Shukla
Date: Tue, 13 Oct 2026 21:20:42 +0530
Subject: [PATCH 12/74] Add initial implementation for mask scissor
benchmarking
---
scripts/bench-mask-scissor/Cargo.toml | 12 ++
scripts/bench-mask-scissor/src/lib.rs | 242 ++++++++++++++++++++++++++
2 files changed, 254 insertions(+)
create mode 100644 scripts/bench-mask-scissor/Cargo.toml
create mode 100644 scripts/bench-mask-scissor/src/lib.rs
diff --git a/scripts/bench-mask-scissor/Cargo.toml b/scripts/bench-mask-scissor/Cargo.toml
new file mode 100644
index 00000000000..e0d3283ccd9
--- /dev/null
+++ b/scripts/bench-mask-scissor/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "bench-mask-scissor"
+version = "0.1.0"
+edition = "2024"
+publish = false
+
+[workspace]
+
+[dependencies]
+bytemuck = { version = "1.7", features = ["derive"] }
+pollster = "0.4.0"
+wgpu = { version = "25.0.0", features = ["wgpu-core"] }
\ No newline at end of file
diff --git a/scripts/bench-mask-scissor/src/lib.rs b/scripts/bench-mask-scissor/src/lib.rs
new file mode 100644
index 00000000000..4ea578e5621
--- /dev/null
+++ b/scripts/bench-mask-scissor/src/lib.rs
@@ -0,0 +1,242 @@
+pub enum MaskRenderMode {
+ Blur,
+ Pixelate,
+ Highlight,
+}
+
+pub struct XY {
+ pub x: T,
+ pub y: T,
+}
+
+pub struct PreparedMask {
+ pub mode: MaskRenderMode,
+ pub center: XY,
+ pub size: XY,
+ pub feather: f32,
+ pub opacity: f32,
+ pub effect_size: f32,
+ pub darkness: f32,
+ pub output_size: XY,
+}
+
+pub struct RenderSession {
+ views: [wgpu::TextureView; 2],
+ current: usize,
+}
+
+impl RenderSession {
+ fn current_texture_view(&self) -> &wgpu::TextureView {
+ &self.views[self.current]
+ }
+
+ fn other_texture_view(&self) -> &wgpu::TextureView {
+ &self.views[1 - self.current]
+ }
+
+ fn swap_textures(&mut self) {
+ self.current = 1 - self.current;
+ }
+}
+
+pub mod mask {
+ include!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/../../crates/rendering/src/layers/mask.rs"
+ ));
+
+ #[cfg(test)]
+ mod benchmark {
+ use super::*;
+
+ const WIDTH: u32 = 3840;
+ const HEIGHT: u32 = 2160;
+ const BATCH: u32 = 8;
+ const SAMPLES: usize = 21;
+
+ struct BlurPass {
+ uniforms: MaskUniforms,
+ bind_group: wgpu::BindGroup,
+ }
+
+ fn measure(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ layer: &MaskLayer,
+ views: &[wgpu::TextureView; 2],
+ passes: &[[BlurPass; 2]],
+ scissored: bool,
+ ) -> f64 {
+ let mut encoder = device.create_command_encoder(&Default::default());
+ for _ in 0..BATCH {
+ for mask in passes {
+ for (axis, config) in mask.iter().enumerate() {
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: &views[1 - axis],
+ resolve_target: None,
+ ops: wgpu::Operations {
+ load: if axis == 0 {
+ wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT)
+ } else {
+ wgpu::LoadOp::Load
+ },
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ ..Default::default()
+ });
+ pass.set_pipeline(if axis == 0 {
+ &layer.pipeline.render_pipeline
+ } else {
+ &layer.pipeline.blur_composite_pipeline
+ });
+ pass.set_bind_group(0, &config.bind_group, &[]);
+ if scissored {
+ config.uniforms.draw(&mut pass);
+ } else {
+ pass.draw(0..3, 0..1);
+ }
+ }
+ }
+ }
+ let commands = encoder.finish();
+ let started = std::time::Instant::now();
+ queue.submit([commands]);
+ device
+ .poll(wgpu::PollType::Wait)
+ .expect("poll benchmark GPU");
+ started.elapsed().as_secs_f64() * 1_000.0 / f64::from(BATCH)
+ }
+
+ #[test]
+ #[ignore = "requires a real GPU"]
+ fn blur_scissor_benchmark() {
+ let adapter =
+ pollster::block_on(wgpu::Instance::default().request_adapter(&Default::default()))
+ .expect("request benchmark GPU adapter");
+ let info = adapter.get_info();
+ assert_ne!(info.backend, wgpu::Backend::Noop);
+ let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))
+ .expect("request benchmark GPU device");
+ let layer = MaskLayer::new(&device);
+ let size = wgpu::Extent3d {
+ width: WIDTH,
+ height: HEIGHT,
+ depth_or_array_layers: 1,
+ };
+ let textures: [_; 2] = std::array::from_fn(|_| {
+ device.create_texture(&wgpu::TextureDescriptor {
+ label: None,
+ size,
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: wgpu::TextureFormat::Rgba8Unorm,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING
+ | wgpu::TextureUsages::RENDER_ATTACHMENT
+ | wgpu::TextureUsages::COPY_DST,
+ view_formats: &[],
+ })
+ });
+ let views = textures
+ .each_ref()
+ .map(|texture| texture.create_view(&Default::default()));
+ let pixels = (0..WIDTH * HEIGHT * 4)
+ .map(|index| (index * 37 % 251) as u8)
+ .collect::>();
+ queue.write_texture(
+ textures[0].as_image_copy(),
+ &pixels,
+ wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(WIDTH * 4),
+ rows_per_image: Some(HEIGHT),
+ },
+ size,
+ );
+ let mask = |center, size| MaskUniforms {
+ rect_center: center,
+ rect_size: size,
+ feather: 0.01,
+ opacity: 1.0,
+ effect_size: 12.0,
+ output_size: [WIDTH as f32, HEIGHT as f32],
+ ..Default::default()
+ };
+ let cases = [
+ ("Small", vec![mask([0.5, 0.5], [0.1, 0.1])]),
+ ("Medium", vec![mask([0.5, 0.5], [0.5, 0.5])]),
+ (
+ "Overlapping",
+ vec![
+ mask([0.47, 0.5], [0.28, 0.3]),
+ mask([0.57, 0.55], [0.26, 0.24]),
+ ],
+ ),
+ ("Edge-clipped", vec![mask([0.02, 0.04], [0.1, 0.12])]),
+ ("Fullscreen", vec![mask([0.5, 0.5], [1.0, 1.0])]),
+ ];
+ println!(
+ "Adapter: {}, {:?}, {:?}; {}x{}; {SAMPLES} samples; batch {BATCH}; radius 12",
+ info.name, info.backend, info.device_type, WIDTH, HEIGHT
+ );
+ println!(
+ "Submission-to-GPU-completion time per mask sequence; not GPU-only timestamps."
+ );
+ println!("| Mask | Fullscreen ms | Scissored ms | Speedup |");
+ println!("| --- | ---: | ---: | ---: |");
+ for (name, masks) in cases {
+ let passes = masks
+ .iter()
+ .map(|mask| {
+ std::array::from_fn(|axis| {
+ let uniforms = MaskUniforms {
+ mode: if axis == 0 {
+ BLUR_HORIZONTAL_MODE
+ } else {
+ BLUR_VERTICAL_MODE
+ },
+ ..*mask
+ };
+ let buffer =
+ device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
+ label: None,
+ contents: bytemuck::bytes_of(&uniforms),
+ usage: wgpu::BufferUsages::UNIFORM,
+ });
+ BlurPass {
+ uniforms,
+ bind_group: layer.pipeline.bind_group(
+ &device,
+ &buffer,
+ &views[axis],
+ &layer.sampler,
+ ),
+ }
+ })
+ })
+ .collect::>();
+ let run = |scissored| measure(&device, &queue, &layer, &views, &passes, scissored);
+ for _ in 0..3 {
+ run(false);
+ run(true);
+ }
+ let mut samples = [Vec::new(), Vec::new()];
+ for sample in 0..SAMPLES {
+ for variant in [sample % 2, 1 - sample % 2] {
+ samples[variant].push(run(variant == 1));
+ }
+ }
+ let [fullscreen, scissored] = samples.map(|mut times| {
+ times.sort_by(f64::total_cmp);
+ times[SAMPLES / 2]
+ });
+ println!(
+ "| {name} | {fullscreen:.2} | {scissored:.2} | {:.2}x |",
+ fullscreen / scissored
+ );
+ }
+ }
+ }
+}
From d11c05f8957ed1659481cc6f3b61bc797e0b03b2 Mon Sep 17 00:00:00 2001
From: Wleeaf
Date: Tue, 13 Oct 2026 22:51:06 +0300
Subject: [PATCH 13/74] perf(recording): blend the Linux Instant camera into
the screen frame in place
The Linux Instant camera compositor converted every screen frame to RGBA and back, and rebuilt the camera overlay and its mask, just to blend a small camera rectangle. Blend directly into packed RGB screen frames, rebuild the overlay only when a new camera frame arrives, and compute the mask once. The old conversion path remains the fallback for other formats.
---
.../src/instant_recording/linux_camera.rs | 447 ++++++++++++++++--
1 file changed, 401 insertions(+), 46 deletions(-)
diff --git a/crates/recording/src/instant_recording/linux_camera.rs b/crates/recording/src/instant_recording/linux_camera.rs
index 585e5971800..e87fc756e39 100644
--- a/crates/recording/src/instant_recording/linux_camera.rs
+++ b/crates/recording/src/instant_recording/linux_camera.rs
@@ -518,7 +518,7 @@ async fn prepare_camera_frames(
) -> anyhow::Result<(PreparedCameraFrames, cap_timestamp::Timestamps)> {
presentation.validate(info.width, info.height)?;
let first = receiver.first(&CancellationToken::new()).await?;
- Compositor::new(info, presentation).camera_overlay(&first.inner)?;
+ Compositor::new(info, presentation).camera_overlay(&first.inner, 0)?;
// Separate Instant audio is epoch-anchored; camera readiness must not become head silence.
let timestamps = cap_timestamp::Timestamps::now();
Ok((
@@ -705,6 +705,7 @@ fn compositor_thread(
let result = futures::executor::block_on(async {
let mut screen_rx = screen_rx.fuse();
let mut latest_camera = first_camera;
+ let mut camera_generation = 0u64;
let mut freshness_anchor = camera_freshness_anchor(&latest_camera, Instant::now());
loop {
enum Event {
@@ -732,17 +733,19 @@ fn compositor_thread(
Event::Stale => anyhow::bail!("Required camera stopped delivering fresh frames"),
Event::Camera(frame) => {
latest_camera = frame?;
+ camera_generation += 1;
validate_camera_frame(&latest_camera, Instant::now())?;
freshness_anchor = camera_freshness_anchor(&latest_camera, Instant::now());
}
Event::Screen(Some(screen)) => {
if let Some(frame) = camera_rx.latest()? {
latest_camera = frame;
+ camera_generation += 1;
freshness_anchor = camera_freshness_anchor(&latest_camera, Instant::now());
}
validate_camera_frame(&latest_camera, Instant::now())?;
let output = compositor
- .compose(&screen, &latest_camera)
+ .compose(screen, &latest_camera, camera_generation)
.context("Required camera composition failed")?;
if stop_token.is_cancelled() {
break;
@@ -791,6 +794,8 @@ struct Compositor {
screen_to_rgba: Option,
camera_to_rgba: Option,
rgba_to_screen: Option,
+ camera_overlay: Option<(u64, ffmpeg::frame::Video)>,
+ camera_mask: Option>,
}
impl Compositor {
@@ -801,24 +806,62 @@ impl Compositor {
screen_to_rgba: None,
camera_to_rgba: None,
rgba_to_screen: None,
+ camera_overlay: None,
+ camera_mask: None,
}
}
fn compose(
+ &mut self,
+ mut screen: FFmpegVideoFrame,
+ camera: &FFmpegVideoFrame,
+ camera_generation: u64,
+ ) -> anyhow::Result {
+ let Some(layout) = self.in_place_layout(&mut screen.inner) else {
+ return self.compose_converted(&screen, camera, camera_generation);
+ };
+ self.presentation
+ .validate(self.info.width, self.info.height)?;
+ let rect = self.presentation.rect;
+ let camera_overlay = self.camera_overlay(&camera.inner, camera_generation)?;
+ blend_rgba_into_packed(
+ &mut screen.inner,
+ layout,
+ camera_overlay,
+ rect.x as usize,
+ rect.y as usize,
+ )?;
+ Ok(screen)
+ }
+
+ fn in_place_layout(&self, screen: &mut ffmpeg::frame::Video) -> Option {
+ if screen.format() != self.info.pixel_format
+ || screen.width() != self.info.width
+ || screen.height() != self.info.height
+ {
+ return None;
+ }
+ let layout = PackedRgbLayout::of(screen.format())?;
+ let writable = unsafe { ffmpeg::sys::av_frame_make_writable(screen.as_mut_ptr()) };
+ (writable >= 0).then_some(layout)
+ }
+
+ fn compose_converted(
&mut self,
screen: &FFmpegVideoFrame,
camera: &FFmpegVideoFrame,
+ camera_generation: u64,
) -> anyhow::Result {
- let screen_rgba = self.convert_screen(&screen.inner)?;
+ let mut composed = self.convert_screen(&screen.inner)?;
self.presentation
.validate(self.info.width, self.info.height)?;
- let camera_overlay = self.camera_overlay(&camera.inner)?;
- let mut composed = screen_rgba;
+ let rect = self.presentation.rect;
+ let camera_overlay = self.camera_overlay(&camera.inner, camera_generation)?;
blend_rgba(
&mut composed,
- &camera_overlay,
- self.presentation.rect.x as usize,
- self.presentation.rect.y as usize,
+ camera_overlay,
+ rect.x as usize,
+ rect.y as usize,
)?;
let mut output = self.convert_to_screen(&composed)?;
output.set_pts(screen.inner.pts());
@@ -832,10 +875,28 @@ impl Compositor {
fn camera_overlay(
&mut self,
input: &ffmpeg::frame::Video,
- ) -> anyhow::Result {
- let rect = self.presentation.rect;
- let rgba = self.convert_camera(input, rect.width, rect.height)?;
- crop_camera(&rgba, self.presentation)
+ camera_generation: u64,
+ ) -> anyhow::Result<&ffmpeg::frame::Video> {
+ let cached = self
+ .camera_overlay
+ .as_ref()
+ .is_some_and(|(generation, _)| *generation == camera_generation);
+ if !cached {
+ self.camera_overlay = None;
+ let rect = self.presentation.rect;
+ let rgba = self.convert_camera(input, rect.width, rect.height)?;
+ let presentation = self.presentation;
+ let mask = self
+ .camera_mask
+ .get_or_insert_with(|| camera_mask(presentation));
+ let overlay = crop_camera(&rgba, presentation, mask)?;
+ self.camera_overlay = Some((camera_generation, overlay));
+ }
+ Ok(&self
+ .camera_overlay
+ .as_ref()
+ .expect("camera overlay prepared")
+ .1)
}
fn convert_screen(
@@ -1021,9 +1082,29 @@ fn aspect_fill_dimensions(
}
}
+fn camera_mask(presentation: LinuxCameraPresentation) -> Vec {
+ let rect = presentation.rect;
+ let radius = presentation.radius();
+ if radius <= 0.0 {
+ return Vec::new();
+ }
+ let mut mask = Vec::with_capacity(rect.width as usize * rect.height as usize);
+ for y in 0..rect.height as usize {
+ for x in 0..rect.width as usize {
+ let px = x as f64 + 0.5;
+ let py = y as f64 + 0.5;
+ let dx = px - px.clamp(radius, f64::from(rect.width) - radius);
+ let dy = py - py.clamp(radius, f64::from(rect.height) - radius);
+ mask.push((radius + 0.5 - dx.hypot(dy)).clamp(0.0, 1.0));
+ }
+ }
+ mask
+}
+
fn crop_camera(
source: &ffmpeg::frame::Video,
presentation: LinuxCameraPresentation,
+ mask: &[f64],
) -> anyhow::Result {
let rect = presentation.rect;
if source.format() != Pixel::RGBA
@@ -1041,7 +1122,6 @@ fn crop_camera(
let output_stride = output.stride(0);
let source_data = source.data(0);
let output_data = output.data_mut(0);
- let radius = presentation.radius();
for y in 0..rect.height as usize {
for x in 0..rect.width as usize {
let source_x = crop_x
@@ -1059,12 +1139,7 @@ fn crop_camera(
.get_mut(output_offset..output_offset + 4)
.context("RGBA camera output layout is smaller than dimensions")?;
destination.copy_from_slice(source_pixel);
- if radius > 0.0 {
- let px = x as f64 + 0.5;
- let py = y as f64 + 0.5;
- let dx = px - px.clamp(radius, f64::from(rect.width) - radius);
- let dy = py - py.clamp(radius, f64::from(rect.height) - radius);
- let coverage = (radius + 0.5 - dx.hypot(dy)).clamp(0.0, 1.0);
+ if let Some(&coverage) = mask.get(y * rect.width as usize + x) {
destination[3] = (f64::from(destination[3]) * coverage).round() as u8;
}
}
@@ -1117,6 +1192,93 @@ fn blend_rgba(
Ok(())
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct PackedRgbLayout {
+ bytes_per_pixel: usize,
+ red: usize,
+ green: usize,
+ blue: usize,
+ opaque: Option,
+}
+
+impl PackedRgbLayout {
+ fn of(format: Pixel) -> Option {
+ let four = |red, green, blue, opaque| Self {
+ bytes_per_pixel: 4,
+ red,
+ green,
+ blue,
+ opaque: Some(opaque),
+ };
+ let three = |red, green, blue| Self {
+ bytes_per_pixel: 3,
+ red,
+ green,
+ blue,
+ opaque: None,
+ };
+ match format {
+ Pixel::RGBA | Pixel::RGBZ => Some(four(0, 1, 2, 3)),
+ Pixel::BGRA | Pixel::BGRZ => Some(four(2, 1, 0, 3)),
+ Pixel::ARGB | Pixel::ZRGB => Some(four(1, 2, 3, 0)),
+ Pixel::ABGR | Pixel::ZBGR => Some(four(3, 2, 1, 0)),
+ Pixel::RGB24 => Some(three(0, 1, 2)),
+ Pixel::BGR24 => Some(three(2, 1, 0)),
+ _ => None,
+ }
+ }
+}
+
+fn blend_rgba_into_packed(
+ destination: &mut ffmpeg::frame::Video,
+ layout: PackedRgbLayout,
+ source: &ffmpeg::frame::Video,
+ x: usize,
+ y: usize,
+) -> anyhow::Result<()> {
+ if source.format() != Pixel::RGBA {
+ return Err(anyhow!("camera overlay must be RGBA"));
+ }
+ let source_width = source.width() as usize;
+ let source_height = source.height() as usize;
+ if x.saturating_add(source_width) > destination.width() as usize
+ || y.saturating_add(source_height) > destination.height() as usize
+ {
+ return Err(anyhow!("camera overlay exceeds screen frame"));
+ }
+ let bytes_per_pixel = layout.bytes_per_pixel;
+ let source_stride = source.stride(0);
+ let destination_stride = destination.stride(0);
+ let source_data = source.data(0);
+ let destination_data = destination.data_mut(0);
+ for row in 0..source_height {
+ let source_row = &source_data[row * source_stride..][..source_width * 4];
+ let destination_row = &mut destination_data
+ [(y + row) * destination_stride + x * bytes_per_pixel..]
+ [..source_width * bytes_per_pixel];
+ for (source_pixel, destination_pixel) in source_row
+ .chunks_exact(4)
+ .zip(destination_row.chunks_exact_mut(bytes_per_pixel))
+ {
+ let alpha = u16::from(source_pixel[3]);
+ let inverse_alpha = 255 - alpha;
+ for (channel, offset) in [layout.red, layout.green, layout.blue]
+ .into_iter()
+ .enumerate()
+ {
+ destination_pixel[offset] = ((u16::from(source_pixel[channel]) * alpha
+ + u16::from(destination_pixel[offset]) * inverse_alpha
+ + 127)
+ / 255) as u8;
+ }
+ if let Some(opaque) = layout.opaque {
+ destination_pixel[opaque] = 255;
+ }
+ }
+ }
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -1318,7 +1480,7 @@ mod tests {
VideoInfo::from_raw_ffmpeg(Pixel::RGBA, 2, 2, 30),
presentation(2, 2),
);
- let overlay = compositor.camera_overlay(&converted.inner).unwrap();
+ let overlay = compositor.camera_overlay(&converted.inner, 0).unwrap();
assert_eq!(overlay.format(), Pixel::RGBA);
assert_eq!(&overlay.data(0)[..8], &[30, 20, 10, 255, 60, 50, 40, 255]);
assert_eq!(
@@ -1684,7 +1846,7 @@ mod tests {
let before = frame.data(0).to_vec();
let mut request = presentation(4, 2);
request.mirrored = true;
- let output = crop_camera(&frame, request).unwrap();
+ let output = crop_camera(&frame, request, &camera_mask(request)).unwrap();
assert_eq!(&output.data(0)[..4], &[3, 0, 3, 255]);
assert_eq!(&output.data(0)[12..16], &[0, 0, 3, 255]);
assert_eq!(frame.data(0), before);
@@ -1699,14 +1861,11 @@ mod tests {
LinuxCameraShape::Round,
LinuxCameraShape::RoundedRectangle { radius_pixels: 3 },
] {
- let output = crop_camera(
- &frame,
- LinuxCameraPresentation {
- shape,
- ..presentation(8, 8)
- },
- )
- .unwrap();
+ let request = LinuxCameraPresentation {
+ shape,
+ ..presentation(8, 8)
+ };
+ let output = crop_camera(&frame, request, &camera_mask(request)).unwrap();
assert_eq!(output.data(0)[3], 0);
assert_eq!(output.data(0)[output.stride(0) * 4 + 4 * 4 + 3], 255);
}
@@ -1985,19 +2144,16 @@ mod tests {
let stride = source.stride(0);
let center = 2 * stride + 2 * 4;
source.data_mut(0)[center..center + 4].copy_from_slice(&[1, 2, 3, 255]);
- let cropped = crop_camera(
- &source,
- LinuxCameraPresentation {
- rect: LinuxCameraRect {
- x: 0,
- y: 0,
- width: 2,
- height: 2,
- },
- ..LinuxCameraPresentation::default_for(2, 2)
+ let request = LinuxCameraPresentation {
+ rect: LinuxCameraRect {
+ x: 0,
+ y: 0,
+ width: 2,
+ height: 2,
},
- )
- .expect("crop succeeds");
+ ..LinuxCameraPresentation::default_for(2, 2)
+ };
+ let cropped = crop_camera(&source, request, &camera_mask(request)).expect("crop succeeds");
assert_eq!(
&cropped.data(0)[cropped.stride(0)..cropped.stride(0) + 4],
&[1, 2, 3, 255]
@@ -2005,7 +2161,7 @@ mod tests {
}
#[test]
- fn compositor_converts_nv12_camera_without_mutating_screen_and_preserves_pts() {
+ fn compositor_converts_nv12_camera_without_mutating_shared_screen_and_preserves_pts() {
ffmpeg::init().expect("FFmpeg initializes");
let timestamp = Timestamp::Instant(Instant::now());
let info = VideoInfo::from_raw_ffmpeg(ffmpeg::format::Pixel::BGRA, 8, 8, 30);
@@ -2031,10 +2187,11 @@ mod tests {
info,
LinuxCameraPresentation::default_for(info.width, info.height),
);
+ let shared = shared_ref(&screen_frame.inner);
let output = compositor
- .compose(&screen_frame, &camera_frame)
+ .compose(screen_frame, &camera_frame, 0)
.expect("composition succeeds");
- assert_eq!(screen_frame.inner.data(0), original.as_slice());
+ assert_eq!(shared.data(0), original.as_slice());
assert_eq!(camera_frame.inner.data(0), camera_before.0.as_slice());
assert_eq!(camera_frame.inner.data(1), camera_before.1.as_slice());
assert_eq!(output.inner.pts(), Some(1234));
@@ -2074,10 +2231,11 @@ mod tests {
info,
LinuxCameraPresentation::default_for(info.width, info.height),
);
+ let shared = shared_ref(&screen_frame.inner);
let output = compositor
- .compose(&screen_frame, &camera_frame)
+ .compose(screen_frame, &camera_frame, 0)
.expect("composition succeeds");
- assert_eq!(screen_frame.inner.data(0), original_y.as_slice());
+ assert_eq!(shared.data(0), original_y.as_slice());
assert_eq!(output.inner.format(), Pixel::NV12);
assert_eq!(output.inner.pts(), Some(4321));
assert_ne!(output.inner.data(0)[output.inner.stride(0) * 7 + 7], 16);
@@ -2090,4 +2248,201 @@ mod tests {
let source = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, 2, 2);
assert!(blend_rgba(&mut destination, &source, 1, 0).is_err());
}
+
+ fn shared_ref(frame: &ffmpeg::frame::Video) -> ffmpeg::frame::Video {
+ let mut shared = ffmpeg::frame::Video::empty();
+ let result = unsafe { ffmpeg::sys::av_frame_ref(shared.as_mut_ptr(), frame.as_ptr()) };
+ assert!(result >= 0);
+ shared
+ }
+
+ fn pattern_frame(format: Pixel, width: u32, height: u32, seed: u32) -> ffmpeg::frame::Video {
+ let mut frame = ffmpeg::frame::Video::new(format, width, height);
+ let mut state = seed.wrapping_mul(2_654_435_761).wrapping_add(1);
+ for plane in 0..frame.planes() {
+ for byte in frame.data_mut(plane) {
+ state ^= state << 13;
+ state ^= state >> 17;
+ state ^= state << 5;
+ *byte = (state >> 24) as u8;
+ }
+ }
+ frame
+ }
+
+ fn copy_frame(frame: &ffmpeg::frame::Video) -> ffmpeg::frame::Video {
+ let mut copy = ffmpeg::frame::Video::new(frame.format(), frame.width(), frame.height());
+ for plane in 0..frame.planes() {
+ copy.data_mut(plane).copy_from_slice(frame.data(plane));
+ }
+ copy.set_pts(frame.pts());
+ copy
+ }
+
+ fn defined_bytes(frame: &ffmpeg::frame::Video) -> Vec {
+ let layout = PackedRgbLayout::of(frame.format()).unwrap();
+ let padding = match frame.format() {
+ Pixel::RGBZ | Pixel::BGRZ | Pixel::ZRGB | Pixel::ZBGR => layout.opaque,
+ _ => None,
+ };
+ let row_bytes = frame.width() as usize * layout.bytes_per_pixel;
+ frame
+ .data(0)
+ .chunks(frame.stride(0))
+ .take(frame.height() as usize)
+ .flat_map(|row| row[..row_bytes].chunks_exact(layout.bytes_per_pixel))
+ .flat_map(|pixel| {
+ pixel
+ .iter()
+ .enumerate()
+ .map(move |(offset, &byte)| if Some(offset) == padding { 0 } else { byte })
+ })
+ .collect()
+ }
+
+ #[test]
+ fn in_place_composition_matches_converted_composition_for_packed_formats() {
+ ffmpeg::init().expect("FFmpeg initializes");
+ let timestamp = Timestamp::Instant(Instant::now());
+ let (width, height) = (37, 23);
+ for format in [
+ Pixel::BGRZ,
+ Pixel::RGBZ,
+ Pixel::ZRGB,
+ Pixel::ZBGR,
+ Pixel::BGRA,
+ Pixel::RGBA,
+ Pixel::ARGB,
+ Pixel::ABGR,
+ Pixel::RGB24,
+ Pixel::BGR24,
+ ] {
+ for (shape, mirrored) in [
+ (LinuxCameraShape::Round, false),
+ (LinuxCameraShape::Round, true),
+ (
+ LinuxCameraShape::RoundedRectangle { radius_pixels: 3 },
+ false,
+ ),
+ ] {
+ let info = VideoInfo::from_raw_ffmpeg(format, width, height, 30);
+ let presentation = LinuxCameraPresentation {
+ rect: LinuxCameraRect {
+ x: 19,
+ y: 6,
+ width: 15,
+ height: 15,
+ },
+ shape,
+ mirrored,
+ effect: LinuxCameraEffect::None,
+ };
+ let mut screen = pattern_frame(format, width, height, 7);
+ screen.set_pts(Some(99));
+ let camera = FFmpegVideoFrame {
+ inner: pattern_frame(Pixel::YUV420P, 32, 18, 11),
+ timestamp,
+ };
+ let expected = Compositor::new(info, presentation)
+ .compose_converted(
+ &FFmpegVideoFrame {
+ inner: copy_frame(&screen),
+ timestamp,
+ },
+ &camera,
+ 0,
+ )
+ .expect("converted composition succeeds");
+ let mut compositor = Compositor::new(info, presentation);
+ let mut probe = copy_frame(&screen);
+ assert!(compositor.in_place_layout(&mut probe).is_some());
+ let actual = compositor
+ .compose(
+ FFmpegVideoFrame {
+ inner: screen,
+ timestamp,
+ },
+ &camera,
+ 0,
+ )
+ .expect("in-place composition succeeds");
+ assert_eq!(
+ defined_bytes(&actual.inner),
+ defined_bytes(&expected.inner),
+ "{format:?} {shape:?} mirrored={mirrored}"
+ );
+ assert_eq!(actual.inner.pts(), Some(99));
+ }
+ }
+ }
+
+ #[test]
+ fn camera_overlay_is_reused_until_a_new_camera_frame_arrives() {
+ ffmpeg::init().expect("FFmpeg initializes");
+ let timestamp = Timestamp::Instant(Instant::now());
+ let info = VideoInfo::from_raw_ffmpeg(Pixel::BGRZ, 16, 16, 30);
+ let mut compositor = Compositor::new(info, LinuxCameraPresentation::default_for(16, 16));
+ let camera = |seed| FFmpegVideoFrame {
+ inner: pattern_frame(Pixel::RGBA, 8, 8, seed),
+ timestamp,
+ };
+ let screen = || FFmpegVideoFrame {
+ inner: pattern_frame(Pixel::BGRZ, 16, 16, 3),
+ timestamp,
+ };
+ let first = compositor.compose(screen(), &camera(1), 1).unwrap();
+ let reused = compositor.compose(screen(), &camera(2), 1).unwrap();
+ let refreshed = compositor.compose(screen(), &camera(2), 2).unwrap();
+ assert_eq!(first.inner.data(0), reused.inner.data(0));
+ assert_ne!(first.inner.data(0), refreshed.inner.data(0));
+ }
+
+ #[test]
+ #[ignore = "timing benchmark; run with --release --ignored --nocapture"]
+ fn compose_timing_benchmark() {
+ ffmpeg::init().expect("FFmpeg initializes");
+ let timestamp = Timestamp::Instant(Instant::now());
+ let camera = FFmpegVideoFrame {
+ inner: pattern_frame(Pixel::YUV420P, 1280, 720, 5),
+ timestamp,
+ };
+ let frames = 240u64;
+ for (width, height) in [(1920, 1080), (2560, 1440), (3840, 2160)] {
+ let info = VideoInfo::from_raw_ffmpeg(Pixel::BGRZ, width, height, 60);
+ let presentation = LinuxCameraPresentation {
+ shape: LinuxCameraShape::Round,
+ ..LinuxCameraPresentation::default_for(width, height)
+ };
+ let template = pattern_frame(Pixel::BGRZ, width, height, 9);
+ let run = |label: &str, converted: bool, camera_every: u64| {
+ let mut compositor = Compositor::new(info, presentation);
+ let mut times = Vec::new();
+ for index in 0..frames {
+ let screen = FFmpegVideoFrame {
+ inner: copy_frame(&template),
+ timestamp,
+ };
+ let generation = index / camera_every;
+ let started = Instant::now();
+ let output = if converted {
+ compositor.compose_converted(&screen, &camera, generation)
+ } else {
+ compositor.compose(screen, &camera, generation)
+ };
+ times.push(started.elapsed().as_secs_f64() * 1000.0);
+ drop(output.unwrap());
+ }
+ times.sort_by(f64::total_cmp);
+ let mean = times.iter().sum::() / times.len() as f64;
+ println!(
+ "{width}x{height} {label:<34} mean {mean:6.2} ms p50 {:6.2} ms p95 {:6.2} ms",
+ times[times.len() / 2],
+ times[times.len() * 95 / 100],
+ );
+ };
+ run("converted, new camera every frame", true, 1);
+ run("in place, new camera every frame", false, 1);
+ run("in place, 30 fps camera / 60 fps", false, 2);
+ }
+ }
}
From 7a7ab587234f5d0bfc646b39051c92bcdab45cef Mon Sep 17 00:00:00 2001
From: flamboh
Date: Tue, 13 Oct 2026 17:24:29 -0700
Subject: [PATCH 14/74] perf(desktop-gpui): rebuild only changed screenshot
privacy masks
---
.../kernels/benchmarks/mask-cache/Cargo.lock | 65 ++++
.../kernels/benchmarks/mask-cache/Cargo.toml | 22 ++
.../kernels/benchmarks/mask-cache/README.md | 60 ++++
.../kernels/benchmarks/mask-cache/lib.rs | 4 +
.../screenshot-mask-cache-benchmark.rs | 160 ++++++++++
apps/desktop-gpui/kernels/src/lib.rs | 1 +
apps/desktop-gpui/kernels/src/mask_cache.rs | 285 ++++++++++++++++++
.../src/screenshot_annotations.rs | 253 +++++++++-------
apps/desktop-gpui/src/screenshot_editor.rs | 6 +
9 files changed, 750 insertions(+), 106 deletions(-)
create mode 100644 apps/desktop-gpui/kernels/benchmarks/mask-cache/Cargo.lock
create mode 100644 apps/desktop-gpui/kernels/benchmarks/mask-cache/Cargo.toml
create mode 100644 apps/desktop-gpui/kernels/benchmarks/mask-cache/README.md
create mode 100644 apps/desktop-gpui/kernels/benchmarks/mask-cache/lib.rs
create mode 100644 apps/desktop-gpui/kernels/examples/screenshot-mask-cache-benchmark.rs
create mode 100644 apps/desktop-gpui/kernels/src/mask_cache.rs
diff --git a/apps/desktop-gpui/kernels/benchmarks/mask-cache/Cargo.lock b/apps/desktop-gpui/kernels/benchmarks/mask-cache/Cargo.lock
new file mode 100644
index 00000000000..65c42077127
--- /dev/null
+++ b/apps/desktop-gpui/kernels/benchmarks/mask-cache/Cargo.lock
@@ -0,0 +1,65 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
+[[package]]
+name = "cap-screenshot-mask-cache-benchmark"
+version = "0.1.0"
+dependencies = [
+ "image",
+]
+
+[[package]]
+name = "image"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "moxcms",
+ "num-traits",
+]
+
+[[package]]
+name = "moxcms"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "pxfm"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
diff --git a/apps/desktop-gpui/kernels/benchmarks/mask-cache/Cargo.toml b/apps/desktop-gpui/kernels/benchmarks/mask-cache/Cargo.toml
new file mode 100644
index 00000000000..bda064d8078
--- /dev/null
+++ b/apps/desktop-gpui/kernels/benchmarks/mask-cache/Cargo.toml
@@ -0,0 +1,22 @@
+[workspace]
+
+[package]
+name = "cap-screenshot-mask-cache-benchmark"
+version = "0.1.0"
+edition = "2024"
+publish = false
+
+[lib]
+name = "cap_gpui_kernels"
+path = "lib.rs"
+
+[[example]]
+name = "screenshot-mask-cache-benchmark"
+path = "../../examples/screenshot-mask-cache-benchmark.rs"
+
+[dependencies]
+image = { version = "=0.25.10", default-features = false }
+
+[profile.release]
+codegen-units = 1
+lto = "thin"
diff --git a/apps/desktop-gpui/kernels/benchmarks/mask-cache/README.md b/apps/desktop-gpui/kernels/benchmarks/mask-cache/README.md
new file mode 100644
index 00000000000..30fca7dabe6
--- /dev/null
+++ b/apps/desktop-gpui/kernels/benchmarks/mask-cache/README.md
@@ -0,0 +1,60 @@
+# Screenshot mask cache benchmark
+
+Run from the repository root:
+
+```sh
+cargo +stable run --locked --release \
+ --manifest-path apps/desktop-gpui/kernels/benchmarks/mask-cache/Cargo.toml \
+ --example screenshot-mask-cache-benchmark
+```
+
+This standalone package compiles the production `mask_cache.rs` and `screenshot.rs` directly. It requires Rust and the `image` crate, with a committed lockfile matching the desktop workspace's image/filter dependencies. It does not build GPUI, audio, FFmpeg, or platform libraries, and can run on Linux x86_64 as well as macOS arm64. The same example is also available through `cargo run -p cap-gpui-kernels --release --example screenshot-mask-cache-benchmark` in the desktop workspace, when its full dependency prerequisites are installed.
+
+The fixture is a deterministic, nonuniform 3840 × 2160 RGBA image with varying alpha. Each workload has 1, 4, 8, or 16 masks of 640 × 360 pixels at level 16, using either blur or pixelate. One mask moves one pixel per event; the others stay fixed. Each workload primes the cache, runs 12 warmup pairs, then measures 120 drag events. The old path filters every mask on every update. The new path calls the production cache diff and filters only changed masks. Both paths call the unchanged production `mask_filter`.
+
+Execution order alternates between old-first and new-first to reduce order bias. Every overlay's request and all of its output bytes are compared after every warmup and measured event, outside the timed intervals. A mismatch or unexpected filter-call count fails the run. Filter-call counts in the table cover the 120 measured events only; cache priming and warmup are excluded. The speedup is the ratio of means. The median is the average of the two middle samples.
+
+Timings include cache diffing, region extraction/filtering, image allocation, `Arc` ownership, and release of retired CPU images. Request/geometry construction and correctness comparisons are outside timing. The benchmark excludes GPUI image conversion, task scheduling/cancellation, GPU atlas uploads, painting, and screenshot export. It measures completed CPU preparation work, not pointer-event latency or application FPS. One mask is a control case: it has no reusable image during a drag. A replaced frame invalidates every mask and is not expected to benefit. Overlapping masks, deletion, reordering, style changes, frame/layout invalidation, and fractional/clipped rectangles are covered by the production module's unit tests.
+
+Run the portable tests and lint checks:
+
+```sh
+cargo +stable test --locked \
+ --manifest-path apps/desktop-gpui/kernels/benchmarks/mask-cache/Cargo.toml \
+ --all-targets
+cargo +stable clippy --locked \
+ --manifest-path apps/desktop-gpui/kernels/benchmarks/mask-cache/Cargo.toml \
+ --all-targets -- -D warnings
+```
+
+Run the release command at least twice on an otherwise idle machine. Keep the full output, CPU model, operating system, Rust version, and power mode with results. Compare both means and medians, including the one-mask control. Cross-architecture results should use the same lockfile and workload.
+
+## Recorded results
+
+Two consecutive release runs on Apple M4 Pro, macOS 27.2, Rust 1.97.0, battery power with automatic power mode, October 13, 2026. All overlay bytes matched after every event in both runs. These are CPU overlay-preparation measurements with the exclusions described above.
+
+Run 1:
+
+| Mode | Masks | Old mean ms | New mean ms | Old median ms | New median ms | Mean speedup | Old calls | New calls |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
+| blur | 1 | 1.637 | 1.631 | 1.630 | 1.621 | 1.00x | 120 | 120 |
+| blur | 4 | 6.701 | 1.687 | 6.697 | 1.688 | 3.97x | 480 | 120 |
+| blur | 8 | 13.266 | 1.664 | 13.291 | 1.660 | 7.97x | 960 | 120 |
+| blur | 16 | 26.504 | 1.688 | 26.494 | 1.685 | 15.70x | 1920 | 120 |
+| pixelate | 1 | 0.805 | 0.812 | 0.804 | 0.804 | 0.99x | 120 | 120 |
+| pixelate | 4 | 3.310 | 0.828 | 3.306 | 0.826 | 4.00x | 480 | 120 |
+| pixelate | 8 | 6.938 | 0.900 | 6.956 | 0.902 | 7.71x | 960 | 120 |
+| pixelate | 16 | 13.723 | 0.911 | 13.605 | 0.878 | 15.07x | 1920 | 120 |
+
+Run 2:
+
+| Mode | Masks | Old mean ms | New mean ms | Old median ms | New median ms | Mean speedup | Old calls | New calls |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
+| blur | 1 | 1.600 | 1.600 | 1.585 | 1.589 | 1.00x | 120 | 120 |
+| blur | 4 | 6.569 | 1.657 | 6.548 | 1.651 | 3.96x | 480 | 120 |
+| blur | 8 | 13.384 | 1.713 | 13.293 | 1.672 | 7.82x | 960 | 120 |
+| blur | 16 | 26.587 | 1.708 | 26.138 | 1.661 | 15.57x | 1920 | 120 |
+| pixelate | 1 | 0.790 | 0.789 | 0.791 | 0.791 | 1.00x | 120 | 120 |
+| pixelate | 4 | 3.252 | 0.812 | 3.234 | 0.809 | 4.00x | 480 | 120 |
+| pixelate | 8 | 6.749 | 0.862 | 6.664 | 0.857 | 7.83x | 960 | 120 |
+| pixelate | 16 | 13.750 | 0.888 | 13.766 | 0.883 | 15.48x | 1920 | 120 |
diff --git a/apps/desktop-gpui/kernels/benchmarks/mask-cache/lib.rs b/apps/desktop-gpui/kernels/benchmarks/mask-cache/lib.rs
new file mode 100644
index 00000000000..334cafd746a
--- /dev/null
+++ b/apps/desktop-gpui/kernels/benchmarks/mask-cache/lib.rs
@@ -0,0 +1,4 @@
+#[path = "../../src/mask_cache.rs"]
+pub mod mask_cache;
+#[path = "../../src/screenshot.rs"]
+pub mod screenshot;
diff --git a/apps/desktop-gpui/kernels/examples/screenshot-mask-cache-benchmark.rs b/apps/desktop-gpui/kernels/examples/screenshot-mask-cache-benchmark.rs
new file mode 100644
index 00000000000..9c1dc29afef
--- /dev/null
+++ b/apps/desktop-gpui/kernels/examples/screenshot-mask-cache-benchmark.rs
@@ -0,0 +1,160 @@
+use cap_gpui_kernels::mask_cache::{MaskEntry, MaskFrame, MaskRequest, mask_region, update_masks};
+use std::{hint::black_box, sync::Arc, time::Instant};
+
+const SIZE: (u32, u32) = (3840, 2160);
+const WARMUP: usize = 12;
+const EVENTS: usize = 120;
+type Overlay = MaskEntry>;
+
+fn requests(count: usize, pixelate: bool, event: usize) -> Vec {
+ let content = [0., 0., f64::from(SIZE.0), f64::from(SIZE.1)];
+ (0..count)
+ .map(|index| {
+ let x = 80. + (index % 4) as f64 * 720. + if index == 0 { event as f64 } else { 0. };
+ let y = 80. + (index / 4) as f64 * 440.;
+ MaskRequest {
+ id: index.to_string(),
+ frame: MaskFrame {
+ revision: 1,
+ size: SIZE,
+ content: content.map(f64::to_bits),
+ padding: 0_f64.to_bits(),
+ crop: None,
+ },
+ region: mask_region([x, y, 640., 360.], content, SIZE).unwrap(),
+ pixelate,
+ level: 16_f64.to_bits(),
+ }
+ })
+ .collect()
+}
+
+fn rebuild_all(requests: &[MaskRequest], rgba: &[u8], calls: &mut usize) -> Vec {
+ requests
+ .iter()
+ .map(|request| {
+ *calls += 1;
+ MaskEntry {
+ request: request.clone(),
+ value: request.filter(rgba).map(Arc::new),
+ }
+ })
+ .collect()
+}
+
+fn cached(
+ previous: &[Overlay],
+ requests: &[MaskRequest],
+ rgba: &[u8],
+ calls: &mut usize,
+) -> Vec {
+ update_masks(previous, requests, |request| {
+ *calls += 1;
+ request.filter(rgba).map(Arc::new)
+ })
+}
+
+fn stats(samples: &mut [f64]) -> (f64, f64) {
+ samples.sort_by(f64::total_cmp);
+ (
+ samples.iter().sum::() / samples.len() as f64,
+ (samples[samples.len() / 2 - 1] + samples[samples.len() / 2]) / 2.,
+ )
+}
+
+fn main() {
+ let rgba: Vec = (0..SIZE.0 * SIZE.1)
+ .flat_map(|i| {
+ let x = i % SIZE.0;
+ let y = i / SIZE.0;
+ [
+ (x * 13 + y * 7) as u8,
+ (x * 3 + y * 17) as u8,
+ (x ^ (y * 11)) as u8,
+ (128 + (x * 5 + y * 3) % 128) as u8,
+ ]
+ })
+ .collect();
+ println!(
+ "Screenshot mask overlay preparation: {} / {}",
+ std::env::consts::OS,
+ std::env::consts::ARCH
+ );
+ println!(
+ "3840x2160 RGBA; 640x360 masks; level 16; {WARMUP} warmup pairs; {EVENTS} measured drag events"
+ );
+ println!(
+ "Alternating old/new execution order; warm cache; byte comparison after every event outside timing."
+ );
+ println!(
+ "Includes cache diff, filtering, Arc allocation and retired CPU image release. Excludes GPUI conversion, scheduling, atlas and painting."
+ );
+ println!(
+ "| Mode | Masks | Old mean ms | New mean ms | Old median ms | New median ms | Mean speedup | Old calls | New calls |"
+ );
+ println!("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |");
+ for pixelate in [false, true] {
+ for count in [1, 4, 8, 16] {
+ let initial = requests(count, pixelate, 0);
+ let mut old = rebuild_all(&initial, &rgba, &mut 0);
+ let mut new = cached(&[], &initial, &rgba, &mut 0);
+ let mut old_samples = Vec::with_capacity(EVENTS);
+ let mut new_samples = Vec::with_capacity(EVENTS);
+ let mut old_calls = 0;
+ let mut new_calls = 0;
+ for event in 1..=WARMUP + EVENTS {
+ let requests = requests(count, pixelate, event);
+ let mut run_old = || {
+ let start = Instant::now();
+ old = rebuild_all(black_box(&requests), black_box(&rgba), &mut old_calls);
+ start.elapsed().as_secs_f64() * 1000.
+ };
+ let mut run_new = || {
+ let start = Instant::now();
+ new = cached(
+ black_box(&new),
+ black_box(&requests),
+ black_box(&rgba),
+ &mut new_calls,
+ );
+ start.elapsed().as_secs_f64() * 1000.
+ };
+ let (old_ms, new_ms) = if event % 2 == 0 {
+ let new_ms = run_new();
+ (run_old(), new_ms)
+ } else {
+ let old_ms = run_old();
+ (old_ms, run_new())
+ };
+ assert_eq!(old.len(), count);
+ assert_eq!(new.len(), count);
+ for (old, new) in old.iter().zip(&new) {
+ assert_eq!(old.request, new.request);
+ assert_eq!(
+ old.value.as_ref().unwrap().as_raw(),
+ new.value.as_ref().unwrap().as_raw(),
+ "event {event}, mask {}",
+ old.request.id
+ );
+ }
+ if event <= WARMUP {
+ old_calls = 0;
+ new_calls = 0;
+ } else {
+ old_samples.push(old_ms);
+ new_samples.push(new_ms);
+ }
+ }
+ assert_eq!(old_calls, count * EVENTS);
+ assert_eq!(new_calls, EVENTS);
+ let (old_mean, old_median) = stats(&mut old_samples);
+ let (new_mean, new_median) = stats(&mut new_samples);
+ let mode = if pixelate { "pixelate" } else { "blur" };
+ println!(
+ "| {mode} | {count} | {old_mean:.3} | {new_mean:.3} | {old_median:.3} | {new_median:.3} | {:.2}x | {old_calls} | {new_calls} |",
+ old_mean / new_mean
+ );
+ }
+ }
+ println!("All overlay bytes matched at every warmup and measured event.");
+}
diff --git a/apps/desktop-gpui/kernels/src/lib.rs b/apps/desktop-gpui/kernels/src/lib.rs
index f35b4f5ede1..009db0e7ceb 100644
--- a/apps/desktop-gpui/kernels/src/lib.rs
+++ b/apps/desktop-gpui/kernels/src/lib.rs
@@ -1,5 +1,6 @@
pub mod codec;
pub mod frame;
+pub mod mask_cache;
pub mod screenshot;
pub mod texture;
pub mod thumbnail;
diff --git a/apps/desktop-gpui/kernels/src/mask_cache.rs b/apps/desktop-gpui/kernels/src/mask_cache.rs
new file mode 100644
index 00000000000..9421b9a1ddd
--- /dev/null
+++ b/apps/desktop-gpui/kernels/src/mask_cache.rs
@@ -0,0 +1,285 @@
+use crate::screenshot::mask_filter;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct MaskFrame {
+ pub revision: u64,
+ pub size: (u32, u32),
+ pub content: [u64; 4],
+ pub padding: u64,
+ pub crop: Option<[u32; 4]>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct MaskRequest {
+ pub id: String,
+ pub frame: MaskFrame,
+ pub region: (u32, u32, u32, u32),
+ pub pixelate: bool,
+ pub level: u64,
+}
+
+impl MaskRequest {
+ pub fn filter(&self, rgba: &[u8]) -> Option {
+ mask_filter(
+ rgba,
+ self.frame.size.0,
+ self.frame.size.1,
+ self.region,
+ self.pixelate,
+ f64::from_bits(self.level),
+ )
+ }
+}
+
+#[derive(Clone)]
+pub struct MaskEntry {
+ pub request: MaskRequest,
+ pub value: Option,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct MaskPlan {
+ pub reuse: Vec>,
+ pub drop: Vec,
+}
+
+pub fn plan_masks(previous: &[MaskEntry], requests: &[MaskRequest]) -> MaskPlan {
+ let reuse: Vec<_> = requests
+ .iter()
+ .map(|request| previous.iter().position(|entry| entry.request == *request))
+ .collect();
+ let drop = (0..previous.len())
+ .filter(|index| !reuse.contains(&Some(*index)))
+ .collect();
+ MaskPlan { reuse, drop }
+}
+
+pub fn update_masks(
+ previous: &[MaskEntry],
+ requests: &[MaskRequest],
+ mut compute: impl FnMut(&MaskRequest) -> Option,
+) -> Vec> {
+ let plan = plan_masks(previous, requests);
+ requests
+ .iter()
+ .zip(plan.reuse)
+ .map(|(request, reuse)| MaskEntry {
+ request: request.clone(),
+ value: match reuse {
+ Some(index) => previous[index].value.clone(),
+ None => compute(request),
+ },
+ })
+ .collect()
+}
+
+pub fn mask_region(
+ mask: [f64; 4],
+ content: [f64; 4],
+ frame: (u32, u32),
+) -> Option<(u32, u32, u32, u32)> {
+ let [x, y, width, height] = mask;
+ let [content_x, content_y, content_width, content_height] = content;
+ let left = content_x.max(x.min(x + width));
+ let top = content_y.max(y.min(y + height));
+ let right = (content_x + content_width).min(x.max(x + width));
+ let bottom = (content_y + content_height).min(y.max(y + height));
+ if right <= left || bottom <= top {
+ return None;
+ }
+ let x0 = (left.round().max(0.) as u32).min(frame.0);
+ let y0 = (top.round().max(0.) as u32).min(frame.1);
+ let x1 = (right.round().max(0.) as u32).min(frame.0);
+ let y1 = (bottom.round().max(0.) as u32).min(frame.1);
+ (x1 > x0 && y1 > y0).then(|| (x0, y0, x1 - x0, y1 - y0))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::Arc;
+
+ fn requests(count: usize) -> Vec {
+ (0..count)
+ .map(|index| MaskRequest {
+ id: index.to_string(),
+ frame: MaskFrame {
+ revision: 1,
+ size: (64, 48),
+ content: [0., 0., 64., 48.].map(f64::to_bits),
+ padding: 0_f64.to_bits(),
+ crop: None,
+ },
+ region: (index as u32 * 4, 4, 32, 24),
+ pixelate: false,
+ level: 16_f64.to_bits(),
+ })
+ .collect()
+ }
+
+ fn cached(requests: &[MaskRequest]) -> Vec>> {
+ update_masks(&[], requests, |request| Some(Arc::new(request.id.clone())))
+ }
+
+ #[test]
+ fn moving_one_mask_reuses_the_other_images() {
+ let mut requests = requests(4);
+ let previous = cached(&requests);
+ requests[1].region.0 += 1;
+ assert_eq!(
+ plan_masks(&previous, &requests),
+ MaskPlan {
+ reuse: vec![Some(0), None, Some(2), Some(3)],
+ drop: vec![1],
+ }
+ );
+ let mut calls = 0;
+ let next = update_masks(&previous, &requests, |request| {
+ calls += 1;
+ Some(Arc::new(request.id.clone()))
+ });
+ assert_eq!(calls, 1);
+ for index in [0, 2, 3] {
+ assert!(Arc::ptr_eq(
+ previous[index].value.as_ref().unwrap(),
+ next[index].value.as_ref().unwrap()
+ ));
+ }
+ }
+
+ #[test]
+ fn deleting_and_reordering_evicts_only_the_deleted_mask() {
+ let mut requests = requests(4);
+ let previous = cached(&requests);
+ requests.remove(1);
+ requests.reverse();
+ assert_eq!(
+ plan_masks(&previous, &requests),
+ MaskPlan {
+ reuse: vec![Some(3), Some(2), Some(0)],
+ drop: vec![1],
+ }
+ );
+ let next = update_masks(&previous, &requests, |_| panic!("unexpected filter"));
+ assert_eq!(
+ next.iter()
+ .map(|entry| entry.request.id.as_str())
+ .collect::>(),
+ ["3", "2", "0"]
+ );
+ assert_eq!(plan_masks(&next, &[]).drop, [0, 1, 2]);
+ }
+
+ #[test]
+ fn new_and_changed_styles_are_computed() {
+ let mut requests = requests(4);
+ let previous = cached(&requests[..3]);
+ requests[0].pixelate = true;
+ requests[1].level = 20_f64.to_bits();
+ assert_eq!(
+ plan_masks(&previous, &requests),
+ MaskPlan {
+ reuse: vec![None, None, Some(2), None],
+ drop: vec![0, 1],
+ }
+ );
+ }
+
+ #[test]
+ fn frame_and_layout_changes_invalidate_every_mask() {
+ let original = requests(4);
+ let previous = cached(&original);
+ for change in 0..5 {
+ let mut requests = original.clone();
+ for request in &mut requests {
+ match change {
+ 0 => request.frame.revision += 1,
+ 1 => request.frame.size.0 += 1,
+ 2 => request.frame.content[0] = 0.25_f64.to_bits(),
+ 3 => request.frame.padding = 1_f64.to_bits(),
+ _ => request.frame.crop = Some([1, 0, 64, 48]),
+ }
+ }
+ assert_eq!(
+ plan_masks(&previous, &requests),
+ MaskPlan {
+ reuse: vec![None; 4],
+ drop: vec![0, 1, 2, 3],
+ }
+ );
+ }
+ }
+
+ #[test]
+ fn identical_requests_reuse_even_a_failed_filter() {
+ let requests = requests(4);
+ let previous = update_masks::>(&[], &requests, |_| None);
+ let next = update_masks(&previous, &requests, |_| panic!("unexpected filter"));
+ assert!(next.iter().all(|entry| entry.value.is_none()));
+ assert_eq!(plan_masks(&previous, &requests).drop, []);
+ }
+
+ #[test]
+ fn regions_round_after_clipping_and_normalize_negative_sizes() {
+ let content = [10.25, 8.75, 40.5, 30.5];
+ assert_eq!(
+ mask_region([-2.4, 1.2, 80., 60.], content, (48, 36)),
+ Some((10, 9, 38, 27))
+ );
+ assert_eq!(
+ mask_region([40.4, 30.4, -20.2, -10.2], content, (64, 48)),
+ Some((20, 20, 20, 10))
+ );
+ assert_eq!(mask_region([10.1, 10.1, 0.2, 0.2], content, (64, 48)), None);
+ assert_eq!(mask_region([60., 10., 10., 10.], content, (64, 48)), None);
+ assert_eq!(mask_region([0., 0., 20., 20.], content, (0, 0)), None);
+ }
+
+ #[test]
+ fn fractional_moves_with_the_same_filtered_pixels_reuse() {
+ let mut requests = requests(1);
+ let content = [0., 0., 64., 48.];
+ requests[0].region = mask_region([0.1, 4.1, 32., 24.], content, (64, 48)).unwrap();
+ let previous = cached(&requests);
+ requests[0].region = mask_region([0.2, 4.2, 32., 24.], content, (64, 48)).unwrap();
+ assert_eq!(plan_masks(&previous, &requests).reuse, [Some(0)]);
+ requests[0].region = mask_region([0.6, 4.2, 32., 24.], content, (64, 48)).unwrap();
+ assert_eq!(plan_masks(&previous, &requests).reuse, [None]);
+ }
+
+ #[test]
+ fn overlapping_masks_always_sample_the_unmodified_frame() {
+ let rgba: Vec = (0..64 * 48 * 4).map(|i| (i * 37 % 251) as u8).collect();
+ let mut requests = requests(4);
+ requests[1].pixelate = true;
+ let previous = update_masks(&[], &requests, |request| {
+ request.filter(&rgba).map(Arc::new)
+ });
+ requests[1].region.0 += 1;
+ requests.reverse();
+ let mut calls = 0;
+ let next = update_masks(&previous, &requests, |request| {
+ calls += 1;
+ request.filter(&rgba).map(Arc::new)
+ });
+ assert_eq!(calls, 1);
+ for entry in next {
+ let expected = mask_filter(
+ &rgba,
+ 64,
+ 48,
+ entry.request.region,
+ entry.request.pixelate,
+ 16.,
+ )
+ .unwrap();
+ assert_eq!(entry.value.unwrap().as_ref(), &expected);
+ }
+ }
+
+ #[test]
+ fn invalid_buffers_do_not_produce_images() {
+ let request = requests(1).remove(0);
+ assert!(request.filter(&[0; 16]).is_none());
+ }
+}
diff --git a/apps/desktop-gpui/src/screenshot_annotations.rs b/apps/desktop-gpui/src/screenshot_annotations.rs
index 30118d7e579..1f69bdf4a56 100644
--- a/apps/desktop-gpui/src/screenshot_annotations.rs
+++ b/apps/desktop-gpui/src/screenshot_annotations.rs
@@ -25,10 +25,10 @@
use std::cell::Cell;
use std::collections::HashMap;
-use std::hash::{Hash as _, Hasher as _};
use std::rc::Rc;
use std::sync::Arc;
+use cap_gpui_kernels::mask_cache::{MaskEntry, MaskFrame, MaskRequest, update_masks};
use cap_project::{Annotation, AnnotationType, AspectRatio, Crop, MaskType};
use gpui::{
AnyElement, App, AppContext as _, Bounds, Context, Corners, CursorStyle, Entity, FontWeight,
@@ -1201,11 +1201,8 @@ pub struct AnnotationState {
/// The layers list's rows container, for the reorder drag's hit testing.
list: BoundsCell,
reorder: Option,
- masks: Vec,
- /// The inputs the painted overlays were computed from, and the ones a
- /// computation in flight is for.
- mask_key: u64,
- mask_pending: Option,
+ masks: Vec>,
+ mask_pending: Option>,
mask_task: Option>,
}
@@ -2241,88 +2238,86 @@ impl ScreenshotEditorWindow {
// -- Mask overlays ---------------------------------------------------------
- /// What the painted overlays were computed from: the frame, the image rect,
- /// and every mask's geometry and style.
- fn mask_key(&self) -> u64 {
- let mut hasher = std::hash::DefaultHasher::new();
- match &self.frame_rgba {
- Some(rgba) => {
- (Arc::as_ptr(rgba) as usize).hash(&mut hasher);
- rgba.len().hash(&mut hasher);
- }
- None => 0usize.hash(&mut hasher),
+ fn mask_requests(&self) -> Vec {
+ if self.frame_rgba.is_none() {
+ return Vec::new();
}
- self.frame_size.0.to_bits().hash(&mut hasher);
- self.frame_size.1.to_bits().hash(&mut hasher);
+ let size = (
+ self.frame_size.0.max(0.) as u32,
+ self.frame_size.1.max(0.) as u32,
+ );
let rect = self.annotation_image_rect();
- for value in [rect.x, rect.y, rect.width, rect.height] {
- value.to_bits().hash(&mut hasher);
- }
- for annotation in &self.project.annotations {
- if annotation.annotation_type != AnnotationType::Mask {
- continue;
- }
- annotation.id.hash(&mut hasher);
- for value in [
- annotation.x,
- annotation.y,
- annotation.width,
- annotation.height,
- annotation.mask_level.unwrap_or(MASK_FALLBACK_LEVEL),
- ] {
- value.to_bits().hash(&mut hasher);
- }
- matches!(annotation.mask_type, Some(MaskType::Pixelate)).hash(&mut hasher);
- }
- hasher.finish()
+ let frame = MaskFrame {
+ revision: self.mask_frame_revision,
+ size,
+ content: [rect.x, rect.y, rect.width, rect.height].map(f64::to_bits),
+ padding: self.project.background.padding.to_bits(),
+ crop: self
+ .project
+ .background
+ .crop
+ .as_ref()
+ .map(|crop| [crop.position.x, crop.position.y, crop.size.x, crop.size.y]),
+ };
+ self.project
+ .annotations
+ .iter()
+ .filter_map(|mask| mask_request(mask, frame))
+ .collect()
}
/// `renderMaskOverlays` (`Preview.tsx:579-687`), off the main thread. The
/// stale overlay keeps painting until the new one lands, so a drag never
/// blocks on a resample.
pub(crate) fn refresh_mask_overlays(&mut self, window: &mut Window, cx: &mut Context) {
- let key = self.mask_key();
- if self.annotation_state.mask_key == key || self.annotation_state.mask_pending == Some(key)
- {
+ let requests = self.mask_requests();
+ if self.annotation_state.mask_pending.as_ref() == Some(&requests) {
return;
}
+ self.annotation_state.mask_task = None;
+ self.annotation_state.mask_pending = None;
- let masks: Vec = self
- .project
- .annotations
+ let retained: Vec<_> = requests
.iter()
- .filter(|annotation| annotation.annotation_type == AnnotationType::Mask)
- .cloned()
+ .filter_map(|request| {
+ self.annotation_state
+ .masks
+ .iter()
+ .find(|entry| entry.request.id == request.id)
+ .cloned()
+ })
.collect();
- let frame = self.frame_rgba.clone();
-
- let (Some(rgba), false) = (frame, masks.is_empty()) else {
- self.drop_mask_overlays(window);
- // Dropping the task matters as much as dropping the tiles: a
- // resample still in flight would re-install the regions of a mask
- // that has just been deleted, and stamp its key over this one.
- self.annotation_state.mask_task = None;
- self.annotation_state.mask_key = key;
- self.annotation_state.mask_pending = None;
+ if !retained.iter().map(|entry| &entry.request.id).eq(self
+ .annotation_state
+ .masks
+ .iter()
+ .map(|entry| &entry.request.id))
+ {
+ self.install_mask_overlays(retained, window);
cx.notify();
+ }
+ if self
+ .annotation_state
+ .masks
+ .iter()
+ .map(|entry| &entry.request)
+ .eq(requests.iter())
+ {
return;
- };
+ }
- let size = (
- self.frame_size.0.max(0.) as u32,
- self.frame_size.1.max(0.) as u32,
- );
- let rect = self.annotation_image_rect();
- self.annotation_state.mask_pending = Some(key);
+ let previous = self.annotation_state.masks.clone();
+ let Some(rgba) = self.frame_rgba.clone() else {
+ return;
+ };
+ self.annotation_state.mask_pending = Some(requests.clone());
self.annotation_state.mask_task = Some(cx.spawn_in(window, async move |this, cx| {
let overlays = cx
.background_executor()
- .spawn(async move { build_mask_overlays(&rgba, size, &masks, rect) })
+ .spawn(async move { build_mask_overlays(&rgba, &previous, &requests) })
.await;
this.update_in(cx, |this, window, cx| {
- this.drop_mask_overlays(window);
- this.annotation_state.masks = overlays;
- this.annotation_state.mask_key = key;
+ this.install_mask_overlays(overlays, window);
this.annotation_state.mask_pending = None;
cx.notify();
// The masks may have moved on while this was in flight.
@@ -2332,11 +2327,22 @@ impl ScreenshotEditorWindow {
}));
}
- /// Hand the atlas back the tiles the old overlays held.
- fn drop_mask_overlays(&mut self, window: &mut Window) {
- for overlay in self.annotation_state.masks.drain(..) {
- let _ = window.drop_image(overlay.image);
+ fn install_mask_overlays(
+ &mut self,
+ overlays: Vec>,
+ window: &mut Window,
+ ) {
+ for previous in &self.annotation_state.masks {
+ if let Some(previous) = &previous.value
+ && !overlays
+ .iter()
+ .filter_map(|entry| entry.value.as_ref())
+ .any(|next| Arc::ptr_eq(&previous.image, &next.image))
+ {
+ let _ = window.drop_image(previous.image.clone());
+ }
}
+ self.annotation_state.masks = overlays;
}
}
@@ -2365,7 +2371,12 @@ impl ScreenshotEditorWindow {
let paint = LayerPaint {
annotations: self.project.annotations.clone(),
temp,
- masks: self.annotation_state.masks.clone(),
+ masks: self
+ .annotation_state
+ .masks
+ .iter()
+ .filter_map(|entry| entry.value.clone())
+ .collect(),
selected: self.selected_annotation.clone(),
editing: self
.annotation_state
@@ -3943,34 +3954,49 @@ fn checker_quarters(size: f32) -> Vec {
/// pixelate over a 4K screenshot is still not main-thread work.
fn build_mask_overlays(
rgba: &[u8],
- size: (u32, u32),
- masks: &[Annotation],
- rect: Rect,
-) -> Vec {
- let mut overlays = Vec::new();
- for mask in masks {
- let Some((x0, y0, processed)) = masked_region_image(rgba, size, mask, rect) else {
- continue;
- };
- overlays.push(MaskOverlay {
+ previous: &[MaskEntry],
+ requests: &[MaskRequest],
+) -> Vec> {
+ update_masks(previous, requests, |request| {
+ let processed = request.filter(rgba)?;
+ Some(MaskOverlay {
rect: Rect {
- x: x0 as f64,
- y: y0 as f64,
+ x: request.region.0 as f64,
+ y: request.region.1 as f64,
width: processed.width() as f64,
height: processed.height() as f64,
},
image: crate::library::rgba_to_render_image(processed),
- });
+ })
+ })
+}
+
+fn mask_request(mask: &Annotation, frame: MaskFrame) -> Option {
+ if mask.annotation_type != AnnotationType::Mask {
+ return None;
}
- overlays
+ Some(MaskRequest {
+ id: mask.id.clone(),
+ region: cap_gpui_kernels::mask_cache::mask_region(
+ [mask.x, mask.y, mask.width, mask.height],
+ frame.content.map(f64::from_bits),
+ frame.size,
+ )?,
+ frame,
+ pixelate: mask.mask_type == Some(MaskType::Pixelate),
+ level: mask
+ .mask_level
+ .unwrap_or(MASK_FALLBACK_LEVEL)
+ .max(1.)
+ .to_bits(),
+ })
}
/// One mask's blurred or pixelated pixels, sampled from `rgba` (an unmodified
/// copy of the frame) and returned with the region's top-left in frame
-/// coordinates. This is the single implementation of the two filters -- the
-/// preview's overlay tiles come through it, and so does the export's
-/// `applyMaskAnnotations` pass, which calls it with `rect` set to the whole
-/// canvas (`screenshotExport.ts:290-295` passes `{x:0,y:0,w,h}`).
+/// coordinates. The export's `applyMaskAnnotations` pass calls it with
+/// `rect` set to the whole canvas (`screenshotExport.ts:290-295` passes
+/// `{x:0,y:0,w,h}`).
pub(crate) fn masked_region_image(
rgba: &[u8],
size: (u32, u32),
@@ -4003,18 +4029,11 @@ pub(crate) fn masked_region_image(
/// One mask's region in whole frame pixels: its own rect, clipped to the
/// screenshot's content rect and then to the frame.
fn mask_region(mask: &Annotation, rect: Rect, frame: (u32, u32)) -> Option<(u32, u32, u32, u32)> {
- let left = rect.x.max(mask.x.min(mask.x + mask.width));
- let top = rect.y.max(mask.y.min(mask.y + mask.height));
- let right = (rect.x + rect.width).min(mask.x.max(mask.x + mask.width));
- let bottom = (rect.y + rect.height).min(mask.y.max(mask.y + mask.height));
- if right <= left || bottom <= top {
- return None;
- }
- let x0 = (left.round().max(0.) as u32).min(frame.0);
- let y0 = (top.round().max(0.) as u32).min(frame.1);
- let x1 = (right.round().max(0.) as u32).min(frame.0);
- let y1 = (bottom.round().max(0.) as u32).min(frame.1);
- (x1 > x0 && y1 > y0).then(|| (x0, y0, x1 - x0, y1 - y0))
+ cap_gpui_kernels::mask_cache::mask_region(
+ [mask.x, mask.y, mask.width, mask.height],
+ [rect.x, rect.y, rect.width, rect.height],
+ frame,
+ )
}
#[cfg(test)]
@@ -4523,10 +4542,21 @@ mod tests {
pixelate.mask_type = Some(MaskType::Pixelate);
pixelate.mask_level = Some(7.);
- let overlays = build_mask_overlays(&rgba, frame, &[blur, pixelate], rect);
+ let input = MaskFrame {
+ revision: 1,
+ size: frame,
+ content: [rect.x, rect.y, rect.width, rect.height].map(f64::to_bits),
+ padding: 0_f64.to_bits(),
+ crop: None,
+ };
+ let requests: Vec<_> = [blur, pixelate]
+ .iter()
+ .filter_map(|mask| mask_request(mask, input))
+ .collect();
+ let overlays = build_mask_overlays(&rgba, &[], &requests);
assert_eq!(overlays.len(), 2);
assert_eq!(
- overlays[0].rect,
+ overlays[0].value.as_ref().unwrap().rect,
Rect {
x: 8.,
y: 8.,
@@ -4535,7 +4565,7 @@ mod tests {
}
);
assert_eq!(
- overlays[1].rect,
+ overlays[1].value.as_ref().unwrap().rect,
Rect {
x: 40.,
y: 30.,
@@ -4546,7 +4576,18 @@ mod tests {
// A frame buffer that does not match the size it was handed is not
// sliced at all.
- assert!(build_mask_overlays(&rgba[..16], frame, &[], rect).is_empty());
+ assert!(
+ build_mask_overlays(&rgba[..16], &[], &requests)
+ .iter()
+ .all(|entry| entry.value.is_none())
+ );
+ let reused = build_mask_overlays(&rgba, &overlays, &requests);
+ for (previous, next) in overlays.iter().zip(&reused) {
+ assert!(Arc::ptr_eq(
+ &previous.value.as_ref().unwrap().image,
+ &next.value.as_ref().unwrap().image
+ ));
+ }
}
/// A finished stroke stores fractions of its own box, so a later resize
diff --git a/apps/desktop-gpui/src/screenshot_editor.rs b/apps/desktop-gpui/src/screenshot_editor.rs
index 5d727399df5..f5f4175882e 100644
--- a/apps/desktop-gpui/src/screenshot_editor.rs
+++ b/apps/desktop-gpui/src/screenshot_editor.rs
@@ -917,6 +917,7 @@ pub struct ScreenshotEditorWindow {
/// back to blur/pixelate mask regions (`:537-687`); the mask overlay
/// resamples this instead of the atlas image.
pub(crate) frame_rgba: Option>>,
+ pub(crate) mask_frame_revision: u64,
pub(crate) frame_size: (f32, f32),
/// `originalImageSize` -- the source PNG's dimensions, which the crop
/// button gates on and the frame-space transform needs.
@@ -1001,6 +1002,7 @@ impl ScreenshotEditorWindow {
export_status: ExportStatus::Idle,
frame: None,
frame_rgba: None,
+ mask_frame_revision: 0,
frame_size: (1., 1.),
image_size: None,
previous_transform: None,
@@ -1130,6 +1132,10 @@ impl ScreenshotEditorWindow {
self.rescale_annotations_for(next);
self.frame_size = next;
self.frame_rgba = Some(rgba);
+ self.mask_frame_revision = self
+ .mask_frame_revision
+ .checked_add(1)
+ .expect("frame revision");
self.ready = true;
if let Some(previous) = self.frame.replace(image) {
let _ = window.drop_image(previous);
From 63593ed71a707e665996bf5d5f72c2d2c8215ac2 Mon Sep 17 00:00:00 2001
From: flamboh
Date: Tue, 13 Oct 2026 17:40:55 -0700
Subject: [PATCH 15/74] fix(desktop-gpui): retain duplicate-id screenshot masks
safely
---
.../screenshot-mask-cache-benchmark.rs | 65 ++++++---
apps/desktop-gpui/kernels/src/mask_cache.rs | 134 +++++++++++++-----
.../src/screenshot_annotations.rs | 82 ++++++++---
3 files changed, 204 insertions(+), 77 deletions(-)
diff --git a/apps/desktop-gpui/kernels/examples/screenshot-mask-cache-benchmark.rs b/apps/desktop-gpui/kernels/examples/screenshot-mask-cache-benchmark.rs
index 9c1dc29afef..0dfc358085f 100644
--- a/apps/desktop-gpui/kernels/examples/screenshot-mask-cache-benchmark.rs
+++ b/apps/desktop-gpui/kernels/examples/screenshot-mask-cache-benchmark.rs
@@ -4,7 +4,26 @@ use std::{hint::black_box, sync::Arc, time::Instant};
const SIZE: (u32, u32) = (3840, 2160);
const WARMUP: usize = 12;
const EVENTS: usize = 120;
-type Overlay = MaskEntry>;
+#[derive(Clone)]
+struct Overlay {
+ rect: [f64; 4],
+ image: Arc,
+}
+
+type CachedOverlay = MaskEntry;
+
+fn overlay(request: &MaskRequest, rgba: &[u8]) -> Option {
+ let processed = request.filter(rgba)?;
+ Some(Overlay {
+ rect: [
+ f64::from(request.region.0),
+ f64::from(request.region.1),
+ f64::from(processed.width()),
+ f64::from(processed.height()),
+ ],
+ image: Arc::new(processed),
+ })
+}
fn requests(count: usize, pixelate: bool, event: usize) -> Vec {
let content = [0., 0., f64::from(SIZE.0), f64::from(SIZE.1)];
@@ -30,27 +49,25 @@ fn requests(count: usize, pixelate: bool, event: usize) -> Vec {
}
fn rebuild_all(requests: &[MaskRequest], rgba: &[u8], calls: &mut usize) -> Vec {
- requests
- .iter()
- .map(|request| {
- *calls += 1;
- MaskEntry {
- request: request.clone(),
- value: request.filter(rgba).map(Arc::new),
- }
- })
- .collect()
+ let mut overlays = Vec::new();
+ for request in requests {
+ *calls += 1;
+ if let Some(overlay) = overlay(request, rgba) {
+ overlays.push(overlay);
+ }
+ }
+ overlays
}
fn cached(
- previous: &[Overlay],
+ previous: &[CachedOverlay],
requests: &[MaskRequest],
rgba: &[u8],
calls: &mut usize,
-) -> Vec {
+) -> Vec {
update_masks(previous, requests, |request| {
*calls += 1;
- request.filter(rgba).map(Arc::new)
+ overlay(request, rgba)
})
}
@@ -84,7 +101,7 @@ fn main() {
"3840x2160 RGBA; 640x360 masks; level 16; {WARMUP} warmup pairs; {EVENTS} measured drag events"
);
println!(
- "Alternating old/new execution order; warm cache; byte comparison after every event outside timing."
+ "Alternating old/new execution order; warm cache; geometry and byte comparison after every event outside timing."
);
println!(
"Includes cache diff, filtering, Arc allocation and retired CPU image release. Excludes GPUI conversion, scheduling, atlas and painting."
@@ -129,12 +146,20 @@ fn main() {
assert_eq!(old.len(), count);
assert_eq!(new.len(), count);
for (old, new) in old.iter().zip(&new) {
- assert_eq!(old.request, new.request);
+ let expected_rect = [
+ f64::from(new.request.region.0),
+ f64::from(new.request.region.1),
+ f64::from(new.request.region.2),
+ f64::from(new.request.region.3),
+ ];
+ assert_eq!(old.rect, expected_rect);
+ let new_overlay = new.value.as_ref().unwrap();
+ assert_eq!(old.rect, new_overlay.rect);
assert_eq!(
- old.value.as_ref().unwrap().as_raw(),
- new.value.as_ref().unwrap().as_raw(),
+ old.image.as_raw(),
+ new_overlay.image.as_raw(),
"event {event}, mask {}",
- old.request.id
+ new.request.id
);
}
if event <= WARMUP {
@@ -156,5 +181,5 @@ fn main() {
);
}
}
- println!("All overlay bytes matched at every warmup and measured event.");
+ println!("All overlay geometry and bytes matched at every warmup and measured event.");
}
diff --git a/apps/desktop-gpui/kernels/src/mask_cache.rs b/apps/desktop-gpui/kernels/src/mask_cache.rs
index 9421b9a1ddd..958d09a8eca 100644
--- a/apps/desktop-gpui/kernels/src/mask_cache.rs
+++ b/apps/desktop-gpui/kernels/src/mask_cache.rs
@@ -1,4 +1,5 @@
use crate::screenshot::mask_filter;
+use std::{collections::HashSet, hash::Hash};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MaskFrame {
@@ -37,21 +38,63 @@ pub struct MaskEntry {
pub value: Option,
}
-#[derive(Debug, PartialEq, Eq)]
-pub struct MaskPlan {
- pub reuse: Vec>,
- pub drop: Vec,
+pub fn plan_masks(previous: &[MaskEntry], requests: &[MaskRequest]) -> Vec> {
+ let mut used = vec![false; previous.len()];
+ requests
+ .iter()
+ .map(|request| {
+ let index = previous
+ .iter()
+ .enumerate()
+ .position(|(index, entry)| !used[index] && entry.request == *request)?;
+ used[index] = true;
+ Some(index)
+ })
+ .collect()
+}
+
+pub fn retain_masks(
+ previous: &[MaskEntry],
+ requests: &[MaskRequest],
+) -> Vec> {
+ let mut matches = plan_masks(previous, requests);
+ let mut used = vec![false; previous.len()];
+ for index in matches.iter().flatten() {
+ used[*index] = true;
+ }
+ for (request, matched) in requests.iter().zip(&mut matches) {
+ if matched.is_none()
+ && let Some(index) = previous
+ .iter()
+ .enumerate()
+ .position(|(index, entry)| !used[index] && entry.request.id == request.id)
+ {
+ used[index] = true;
+ *matched = Some(index);
+ }
+ }
+ matches
+ .into_iter()
+ .flatten()
+ .map(|index| previous[index].clone())
+ .collect()
}
-pub fn plan_masks(previous: &[MaskEntry], requests: &[MaskRequest]) -> MaskPlan {
- let reuse: Vec<_> = requests
+pub fn retired_mask_values<'a, T, K: Eq + Hash>(
+ previous: &'a [MaskEntry],
+ next: &[MaskEntry],
+ key: impl Fn(&T) -> K,
+) -> Vec<&'a T> {
+ let mut referenced: HashSet<_> = next
.iter()
- .map(|request| previous.iter().position(|entry| entry.request == *request))
- .collect();
- let drop = (0..previous.len())
- .filter(|index| !reuse.contains(&Some(*index)))
+ .filter_map(|entry| entry.value.as_ref())
+ .map(&key)
.collect();
- MaskPlan { reuse, drop }
+ previous
+ .iter()
+ .filter_map(|entry| entry.value.as_ref())
+ .filter(|value| referenced.insert(key(value)))
+ .collect()
}
pub fn update_masks(
@@ -62,7 +105,7 @@ pub fn update_masks(
let plan = plan_masks(previous, requests);
requests
.iter()
- .zip(plan.reuse)
+ .zip(plan)
.map(|(request, reuse)| MaskEntry {
request: request.clone(),
value: match reuse {
@@ -128,10 +171,7 @@ mod tests {
requests[1].region.0 += 1;
assert_eq!(
plan_masks(&previous, &requests),
- MaskPlan {
- reuse: vec![Some(0), None, Some(2), Some(3)],
- drop: vec![1],
- }
+ vec![Some(0), None, Some(2), Some(3)]
);
let mut calls = 0;
let next = update_masks(&previous, &requests, |request| {
@@ -155,10 +195,7 @@ mod tests {
requests.reverse();
assert_eq!(
plan_masks(&previous, &requests),
- MaskPlan {
- reuse: vec![Some(3), Some(2), Some(0)],
- drop: vec![1],
- }
+ vec![Some(3), Some(2), Some(0)]
);
let next = update_masks(&previous, &requests, |_| panic!("unexpected filter"));
assert_eq!(
@@ -167,7 +204,7 @@ mod tests {
.collect::>(),
["3", "2", "0"]
);
- assert_eq!(plan_masks(&next, &[]).drop, [0, 1, 2]);
+ assert_eq!(retired_mask_values(&next, &[], Arc::as_ptr).len(), 3);
}
#[test]
@@ -178,10 +215,7 @@ mod tests {
requests[1].level = 20_f64.to_bits();
assert_eq!(
plan_masks(&previous, &requests),
- MaskPlan {
- reuse: vec![None, None, Some(2), None],
- drop: vec![0, 1],
- }
+ vec![None, None, Some(2), None]
);
}
@@ -200,13 +234,7 @@ mod tests {
_ => request.frame.crop = Some([1, 0, 64, 48]),
}
}
- assert_eq!(
- plan_masks(&previous, &requests),
- MaskPlan {
- reuse: vec![None; 4],
- drop: vec![0, 1, 2, 3],
- }
- );
+ assert_eq!(plan_masks(&previous, &requests), vec![None; 4]);
}
}
@@ -216,7 +244,43 @@ mod tests {
let previous = update_masks::>(&[], &requests, |_| None);
let next = update_masks(&previous, &requests, |_| panic!("unexpected filter"));
assert!(next.iter().all(|entry| entry.value.is_none()));
- assert_eq!(plan_masks(&previous, &requests).drop, []);
+ assert!(retired_mask_values(&previous, &next, Arc::as_ptr).is_empty());
+ }
+
+ #[test]
+ fn duplicate_ids_reserve_exact_matches_before_retaining_changed_masks() {
+ let mut requests = requests(3);
+ requests[1].id = requests[0].id.clone();
+ let previous = cached(&requests);
+ requests.swap(0, 1);
+ requests[0].region.0 += 1;
+ requests.pop();
+ let retained = retain_masks(&previous, &requests);
+ assert_eq!(retained[0].request, previous[1].request);
+ assert_eq!(retained[1].request, previous[0].request);
+ let retired = retired_mask_values(&previous, &retained, Arc::as_ptr);
+ assert_eq!(retired.len(), 1);
+ assert!(Arc::ptr_eq(retired[0], previous[2].value.as_ref().unwrap()));
+ }
+
+ #[test]
+ fn identical_duplicate_requests_use_each_previous_entry_once() {
+ let request = requests(1).remove(0);
+ let requests = vec![request; 3];
+ let previous = cached(&requests[..2]);
+ assert_eq!(plan_masks(&previous, &requests), [Some(0), Some(1), None]);
+ assert_eq!(retain_masks(&previous, &requests).len(), 2);
+ }
+
+ #[test]
+ fn retirement_preserves_shared_images_and_retires_them_only_once() {
+ let requests = requests(2);
+ let image = Arc::new("shared".to_string());
+ let previous = update_masks(&[], &requests, |_| Some(image.clone()));
+ assert!(retired_mask_values(&previous, &previous[1..], Arc::as_ptr).is_empty());
+ let retired = retired_mask_values(&previous, &[], Arc::as_ptr);
+ assert_eq!(retired.len(), 1);
+ assert!(Arc::ptr_eq(retired[0], &image));
}
#[test]
@@ -242,9 +306,9 @@ mod tests {
requests[0].region = mask_region([0.1, 4.1, 32., 24.], content, (64, 48)).unwrap();
let previous = cached(&requests);
requests[0].region = mask_region([0.2, 4.2, 32., 24.], content, (64, 48)).unwrap();
- assert_eq!(plan_masks(&previous, &requests).reuse, [Some(0)]);
+ assert_eq!(plan_masks(&previous, &requests), [Some(0)]);
requests[0].region = mask_region([0.6, 4.2, 32., 24.], content, (64, 48)).unwrap();
- assert_eq!(plan_masks(&previous, &requests).reuse, [None]);
+ assert_eq!(plan_masks(&previous, &requests), [None]);
}
#[test]
diff --git a/apps/desktop-gpui/src/screenshot_annotations.rs b/apps/desktop-gpui/src/screenshot_annotations.rs
index 1f69bdf4a56..484e14e3c67 100644
--- a/apps/desktop-gpui/src/screenshot_annotations.rs
+++ b/apps/desktop-gpui/src/screenshot_annotations.rs
@@ -28,7 +28,9 @@ use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
-use cap_gpui_kernels::mask_cache::{MaskEntry, MaskFrame, MaskRequest, update_masks};
+use cap_gpui_kernels::mask_cache::{
+ MaskEntry, MaskFrame, MaskRequest, retain_masks, retired_mask_values, update_masks,
+};
use cap_project::{Annotation, AnnotationType, AspectRatio, Crop, MaskType};
use gpui::{
AnyElement, App, AppContext as _, Bounds, Context, Corners, CursorStyle, Entity, FontWeight,
@@ -2277,21 +2279,12 @@ impl ScreenshotEditorWindow {
self.annotation_state.mask_task = None;
self.annotation_state.mask_pending = None;
- let retained: Vec<_> = requests
- .iter()
- .filter_map(|request| {
- self.annotation_state
- .masks
- .iter()
- .find(|entry| entry.request.id == request.id)
- .cloned()
- })
- .collect();
- if !retained.iter().map(|entry| &entry.request.id).eq(self
+ let retained = retain_masks(&self.annotation_state.masks, &requests);
+ if !retained.iter().map(|entry| &entry.request).eq(self
.annotation_state
.masks
.iter()
- .map(|entry| &entry.request.id))
+ .map(|entry| &entry.request))
{
self.install_mask_overlays(retained, window);
cx.notify();
@@ -2332,15 +2325,10 @@ impl ScreenshotEditorWindow {
overlays: Vec>,
window: &mut Window,
) {
- for previous in &self.annotation_state.masks {
- if let Some(previous) = &previous.value
- && !overlays
- .iter()
- .filter_map(|entry| entry.value.as_ref())
- .any(|next| Arc::ptr_eq(&previous.image, &next.image))
- {
- let _ = window.drop_image(previous.image.clone());
- }
+ for previous in retired_mask_values(&self.annotation_state.masks, &overlays, |overlay| {
+ Arc::as_ptr(&overlay.image)
+ }) {
+ let _ = window.drop_image(previous.image.clone());
}
self.annotation_state.masks = overlays;
}
@@ -4590,6 +4578,56 @@ mod tests {
}
}
+ #[test]
+ fn duplicate_mask_ids_keep_both_regions_when_another_mask_is_deleted() {
+ let frame = MaskFrame {
+ revision: 1,
+ size: (64, 48),
+ content: [0., 0., 64., 48.].map(f64::to_bits),
+ padding: 0_f64.to_bits(),
+ crop: None,
+ };
+ let a = shape(AnnotationType::Mask, 0., 0., 16., 16.);
+ let b = shape(AnnotationType::Mask, 32., 24., 16., 16.);
+ let mut c = shape(AnnotationType::Mask, 48., 0., 16., 16.);
+ c.id = "unique".into();
+ assert_eq!(a.id, b.id);
+ let mut requests: Vec<_> = [a, b, c]
+ .iter()
+ .filter_map(|mask| mask_request(mask, frame))
+ .collect();
+ let rgba = vec![128; 64 * 48 * 4];
+ let previous = build_mask_overlays(&rgba, &[], &requests);
+ requests.pop();
+ for reorder in [false, true] {
+ if reorder {
+ requests.reverse();
+ }
+ let retained = retain_masks(&previous, &requests);
+ assert_eq!(retained.len(), 2);
+ for (entry, request) in retained.iter().zip(&requests) {
+ assert_eq!(&entry.request, request);
+ let overlay = entry.value.as_ref().unwrap();
+ assert_eq!(overlay.rect.x, f64::from(request.region.0));
+ assert_eq!(overlay.rect.y, f64::from(request.region.1));
+ }
+ let retired =
+ retired_mask_values(&previous, &retained, |overlay| Arc::as_ptr(&overlay.image));
+ assert_eq!(retired.len(), 1);
+ assert!(Arc::ptr_eq(
+ &retired[0].image,
+ &previous[2].value.as_ref().unwrap().image
+ ));
+ let rebuilt = build_mask_overlays(&rgba, &retained, &requests);
+ assert!(
+ retired_mask_values(&retained, &rebuilt, |overlay| {
+ Arc::as_ptr(&overlay.image)
+ })
+ .is_empty()
+ );
+ }
+ }
+
/// A finished stroke stores fractions of its own box, so a later resize
/// carries the samples with it (`:449-453`, `:915-921`).
#[test]
From a2b578c1223ffbd8b02f668a29f960d0a928f224 Mon Sep 17 00:00:00 2001
From: flamboh
Date: Tue, 13 Oct 2026 18:49:21 -0700
Subject: [PATCH 16/74] fix(desktop-gpui): coalesce screenshot mask overlay
builds
---
apps/desktop-gpui/kernels/src/mask_cache.rs | 184 +++++++++++++++++-
.../src/screenshot_annotations.rs | 97 ++++++++-
2 files changed, 268 insertions(+), 13 deletions(-)
diff --git a/apps/desktop-gpui/kernels/src/mask_cache.rs b/apps/desktop-gpui/kernels/src/mask_cache.rs
index 958d09a8eca..b9e3afdcf03 100644
--- a/apps/desktop-gpui/kernels/src/mask_cache.rs
+++ b/apps/desktop-gpui/kernels/src/mask_cache.rs
@@ -38,6 +38,42 @@ pub struct MaskEntry {
pub value: Option,
}
+pub fn should_cancel_mask_build(previous: &[MaskRequest], requests: &[MaskRequest]) -> bool {
+ let Some(first) = previous.first() else {
+ return true;
+ };
+ if previous
+ .iter()
+ .chain(requests)
+ .any(|request| request.frame != first.frame)
+ {
+ return true;
+ }
+ let mut used = vec![false; requests.len()];
+ previous.iter().any(|request| {
+ let duplicated = previous
+ .iter()
+ .filter(|other| other.id == request.id)
+ .count()
+ > 1
+ || requests
+ .iter()
+ .filter(|other| other.id == request.id)
+ .count()
+ > 1;
+ let matched = requests.iter().enumerate().position(|(index, next)| {
+ !used[index] && next.id == request.id && (!duplicated || next == request)
+ });
+ match matched {
+ Some(index) => {
+ used[index] = true;
+ false
+ }
+ None => true,
+ }
+ })
+}
+
pub fn plan_masks(previous: &[MaskEntry], requests: &[MaskRequest]) -> Vec> {
let mut used = vec![false; previous.len()];
requests
@@ -53,10 +89,7 @@ pub fn plan_masks(previous: &[MaskEntry], requests: &[MaskRequest]) -> Vec
.collect()
}
-pub fn retain_masks(
- previous: &[MaskEntry],
- requests: &[MaskRequest],
-) -> Vec> {
+fn match_masks(previous: &[MaskEntry], requests: &[MaskRequest]) -> Vec> {
let mut matches = plan_masks(previous, requests);
let mut used = vec![false; previous.len()];
for index in matches.iter().flatten() {
@@ -74,12 +107,38 @@ pub fn retain_masks(
}
}
matches
+}
+
+pub fn retain_masks(
+ previous: &[MaskEntry],
+ requests: &[MaskRequest],
+) -> Vec> {
+ match_masks(previous, requests)
.into_iter()
.flatten()
.map(|index| previous[index].clone())
.collect()
}
+pub fn finish_mask_build(
+ current: &[MaskEntry],
+ completed: Vec>,
+) -> Vec> {
+ let requests: Vec<_> = completed
+ .iter()
+ .map(|entry| entry.request.clone())
+ .collect();
+ let matches = match_masks(current, &requests);
+ completed
+ .into_iter()
+ .zip(matches)
+ .map(|(entry, matched)| match matched {
+ Some(index) if current[index].request == entry.request => current[index].clone(),
+ _ => entry,
+ })
+ .collect()
+}
+
pub fn retired_mask_values<'a, T, K: Eq + Hash>(
previous: &'a [MaskEntry],
next: &[MaskEntry],
@@ -164,6 +223,123 @@ mod tests {
update_masks(&[], requests, |request| Some(Arc::new(request.id.clone())))
}
+ #[test]
+ fn mask_build_keeps_moves_styles_additions_and_reorders() {
+ let original = requests(3);
+ for change in 0..5 {
+ let mut next = original.clone();
+ match change {
+ 0 => next[0].region.0 += 1,
+ 1 => next[0].level = 20_f64.to_bits(),
+ 2 => next[0].pixelate = true,
+ 3 => next.push(requests(4).pop().unwrap()),
+ _ => next.reverse(),
+ }
+ assert!(!should_cancel_mask_build(&original, &next));
+ }
+ assert!(!should_cancel_mask_build(&original, &original));
+ }
+
+ #[test]
+ fn mask_build_cancels_removals_even_when_another_mask_is_added() {
+ let original = requests(3);
+ assert!(should_cancel_mask_build(&original, &[]));
+ assert!(should_cancel_mask_build(&original, &original[1..]));
+ let mut next = original[1..].to_vec();
+ next.push(requests(4).pop().unwrap());
+ assert!(should_cancel_mask_build(&original, &next));
+ }
+
+ #[test]
+ fn mask_build_cancels_frame_and_layout_changes() {
+ let original = requests(2);
+ for change in 0..5 {
+ let mut next = original.clone();
+ for request in &mut next {
+ match change {
+ 0 => request.frame.revision += 1,
+ 1 => request.frame.size.0 += 1,
+ 2 => request.frame.content[0] = 0.25_f64.to_bits(),
+ 3 => request.frame.padding = 8_f64.to_bits(),
+ _ => request.frame.crop = Some([0, 0, 32, 24]),
+ }
+ }
+ assert!(should_cancel_mask_build(&original, &next));
+ }
+ }
+
+ #[test]
+ fn mask_build_cancels_ambiguous_duplicate_changes_and_removals() {
+ let mut original = requests(2);
+ original[1].id = original[0].id.clone();
+ let mut next = original.clone();
+ next.reverse();
+ assert!(!should_cancel_mask_build(&original, &next));
+ next[0].region.0 += 1;
+ assert!(should_cancel_mask_build(&original, &next));
+ assert!(should_cancel_mask_build(&original, &original[..1]));
+ next[0] = requests(3).pop().unwrap();
+ assert!(should_cancel_mask_build(&original, &next));
+ let identical = vec![original[0].clone(); 2];
+ assert!(!should_cancel_mask_build(&identical, &identical));
+ assert!(should_cancel_mask_build(&identical, &identical[..1]));
+ }
+
+ #[test]
+ fn mask_build_checks_removal_against_the_latest_coalesced_request() {
+ let initial = requests(1);
+ let added = requests(2);
+ assert!(!should_cancel_mask_build(&initial, &added));
+ assert!(should_cancel_mask_build(&added, &initial));
+ assert!(!should_cancel_mask_build(&initial, &added));
+ }
+
+ #[test]
+ fn mask_build_cancels_inconsistent_frames_and_empty_builds() {
+ let original = requests(2);
+ let mut inconsistent = original.clone();
+ inconsistent[1].frame.revision += 1;
+ assert!(should_cancel_mask_build(&original, &inconsistent));
+ assert!(should_cancel_mask_build(&inconsistent, &original));
+ assert!(should_cancel_mask_build(&[], &original));
+ }
+
+ #[test]
+ fn completed_mask_build_matches_current_duplicates_before_retiring_images() {
+ let mut requests = requests(2);
+ requests[1].id = requests[0].id.clone();
+ let snapshot = cached(&requests);
+ let mut current = cached(&requests);
+ current.reverse();
+ let mut built_requests = requests.clone();
+ built_requests[0].region.0 += 1;
+ let completed = update_masks(&snapshot, &built_requests, |request| {
+ Some(Arc::new(request.id.clone()))
+ });
+ let changed = completed[0].value.clone().unwrap();
+ let installed = finish_mask_build(¤t, completed);
+ assert!(Arc::ptr_eq(installed[0].value.as_ref().unwrap(), &changed));
+ assert!(Arc::ptr_eq(
+ installed[1].value.as_ref().unwrap(),
+ current[0].value.as_ref().unwrap()
+ ));
+ let retired = retired_mask_values(¤t, &installed, Arc::as_ptr);
+ assert_eq!(retired.len(), 1);
+ assert!(Arc::ptr_eq(retired[0], current[1].value.as_ref().unwrap()));
+ }
+
+ #[test]
+ fn completed_mask_build_does_not_retire_shared_current_images() {
+ let requests = requests(2);
+ let image = Arc::new("shared".to_string());
+ let current = update_masks(&[], &requests, |_| Some(image.clone()));
+ let mut built_requests = requests.clone();
+ built_requests[0].region.0 += 1;
+ let installed = finish_mask_build(¤t, cached(&built_requests));
+ assert!(retired_mask_values(¤t, &installed, Arc::as_ptr).is_empty());
+ assert!(Arc::ptr_eq(installed[1].value.as_ref().unwrap(), &image));
+ }
+
#[test]
fn moving_one_mask_reuses_the_other_images() {
let mut requests = requests(4);
diff --git a/apps/desktop-gpui/src/screenshot_annotations.rs b/apps/desktop-gpui/src/screenshot_annotations.rs
index 484e14e3c67..2bd7b539353 100644
--- a/apps/desktop-gpui/src/screenshot_annotations.rs
+++ b/apps/desktop-gpui/src/screenshot_annotations.rs
@@ -29,7 +29,8 @@ use std::rc::Rc;
use std::sync::Arc;
use cap_gpui_kernels::mask_cache::{
- MaskEntry, MaskFrame, MaskRequest, retain_masks, retired_mask_values, update_masks,
+ MaskEntry, MaskFrame, MaskRequest, finish_mask_build, retain_masks, retired_mask_values,
+ should_cancel_mask_build, update_masks,
};
use cap_project::{Annotation, AnnotationType, AspectRatio, Crop, MaskType};
use gpui::{
@@ -2276,8 +2277,18 @@ impl ScreenshotEditorWindow {
if self.annotation_state.mask_pending.as_ref() == Some(&requests) {
return;
}
- self.annotation_state.mask_task = None;
- self.annotation_state.mask_pending = None;
+ let keep_build = self
+ .annotation_state
+ .mask_pending
+ .as_ref()
+ .is_some_and(|pending| !should_cancel_mask_build(pending, &requests));
+ if keep_build {
+ // Cancelling on each drag tick can starve installation even when filtering is fast.
+ self.annotation_state.mask_pending = Some(requests.clone());
+ } else {
+ self.annotation_state.mask_task = None;
+ self.annotation_state.mask_pending = None;
+ }
let retained = retain_masks(&self.annotation_state.masks, &requests);
if !retained.iter().map(|entry| &entry.request).eq(self
@@ -2289,12 +2300,13 @@ impl ScreenshotEditorWindow {
self.install_mask_overlays(retained, window);
cx.notify();
}
- if self
- .annotation_state
- .masks
- .iter()
- .map(|entry| &entry.request)
- .eq(requests.iter())
+ if keep_build
+ || self
+ .annotation_state
+ .masks
+ .iter()
+ .map(|entry| &entry.request)
+ .eq(requests.iter())
{
return;
}
@@ -2310,6 +2322,7 @@ impl ScreenshotEditorWindow {
.spawn(async move { build_mask_overlays(&rgba, &previous, &requests) })
.await;
this.update_in(cx, |this, window, cx| {
+ let overlays = finish_mask_build(&this.annotation_state.masks, overlays);
this.install_mask_overlays(overlays, window);
this.annotation_state.mask_pending = None;
cx.notify();
@@ -4628,6 +4641,72 @@ mod tests {
}
}
+ #[test]
+ fn coalesced_mask_build_installs_progress_then_catches_up_without_retiring_reused_images() {
+ let frame = MaskFrame {
+ revision: 1,
+ size: (64, 48),
+ content: [0., 0., 64., 48.].map(f64::to_bits),
+ padding: 0_f64.to_bits(),
+ crop: None,
+ };
+ let a = shape(AnnotationType::Mask, 0., 0., 16., 16.);
+ let b = shape(AnnotationType::Mask, 32., 24., 16., 16.);
+ let mut moving = shape(AnnotationType::Mask, 40., 0., 16., 16.);
+ moving.id = "moving".into();
+ let original: Vec<_> = [a, b, moving]
+ .iter()
+ .filter_map(|mask| mask_request(mask, frame))
+ .collect();
+ let rgba = vec![128; 64 * 48 * 4];
+ let previous = build_mask_overlays(&rgba, &[], &original);
+ let mut started = original.clone();
+ started[2].region.0 += 1;
+ let mut latest = started.clone();
+ for _ in 0..8 {
+ let mut next = latest.clone();
+ next[2].region.0 -= 1;
+ assert!(!should_cancel_mask_build(&latest, &next));
+ latest = next;
+ }
+ latest.reverse();
+ assert!(!should_cancel_mask_build(&started, &latest));
+ let current = retain_masks(&previous, &latest);
+ let completed = build_mask_overlays(&rgba, &previous, &started);
+ let installed = finish_mask_build(¤t, completed);
+ assert_eq!(installed[2].request, started[2]);
+ let retired =
+ retired_mask_values(¤t, &installed, |overlay| Arc::as_ptr(&overlay.image));
+ assert_eq!(retired.len(), 1);
+ assert!(Arc::ptr_eq(
+ &retired[0].image,
+ &previous[2].value.as_ref().unwrap().image
+ ));
+ for index in 0..2 {
+ assert!(Arc::ptr_eq(
+ &installed[index].value.as_ref().unwrap().image,
+ &previous[index].value.as_ref().unwrap().image
+ ));
+ }
+ let retained = retain_masks(&installed, &latest);
+ assert!(
+ retired_mask_values(&installed, &retained, |overlay| Arc::as_ptr(&overlay.image))
+ .is_empty()
+ );
+ let completed = build_mask_overlays(&rgba, &retained, &latest);
+ let caught_up = finish_mask_build(&retained, completed);
+ assert!(
+ caught_up
+ .iter()
+ .map(|entry| &entry.request)
+ .eq(latest.iter())
+ );
+ assert_eq!(
+ retired_mask_values(&retained, &caught_up, |overlay| Arc::as_ptr(&overlay.image)).len(),
+ 1
+ );
+ }
+
/// A finished stroke stores fractions of its own box, so a later resize
/// carries the samples with it (`:449-453`, `:915-921`).
#[test]
From 6d804384efe516896663ba58989d51089771bca1 Mon Sep 17 00:00:00 2001
From: Marcos Daniel Rivera Gonzalez
<139647784+LordOfGrillos@users.noreply.github.com>
Date: Tue, 13 Oct 2026 21:39:52 -0600
Subject: [PATCH 17/74] fix(video-decode): read past Media Foundation samples
that carry no frame
ReadSample hands back a null sample for stream ticks (gaps in the stream),
and read_sample reported those as Ok(None), the same as the end of the
stream. The decoder then stopped a batch early and answered with an older
frame. Read again until a sample, the end-of-stream flag or an error.
---
crates/video-decode/src/media_foundation.rs | 46 +++++++++++----------
1 file changed, 25 insertions(+), 21 deletions(-)
diff --git a/crates/video-decode/src/media_foundation.rs b/crates/video-decode/src/media_foundation.rs
index 45b6cf5a351..6b5755214bb 100644
--- a/crates/video-decode/src/media_foundation.rs
+++ b/crates/video-decode/src/media_foundation.rs
@@ -763,32 +763,36 @@ impl MediaFoundationDecoder {
let mut timestamp = 0i64;
let mut sample: Option = None;
- unsafe {
- self.source_reader
- .ReadSample(
- MF_SOURCE_READER_FIRST_VIDEO_STREAM.0 as u32,
- 0,
- Some(&mut stream_index),
- Some(&mut flags),
- Some(&mut timestamp),
- Some(&mut sample),
- )
- .map_err(|e| format!("ReadSample failed: {e:?}"))?;
- }
-
const MF_SOURCE_READERF_ENDOFSTREAM: u32 = 0x00000001;
const MF_SOURCE_READERF_ERROR: u32 = 0x00000002;
- if flags & MF_SOURCE_READERF_ENDOFSTREAM != 0 {
- return Ok(None);
- }
+ // A stream tick (a gap in the stream) comes without a sample; only the end-of-stream
+ // flag means no samples follow.
+ let sample = loop {
+ unsafe {
+ self.source_reader
+ .ReadSample(
+ MF_SOURCE_READER_FIRST_VIDEO_STREAM.0 as u32,
+ 0,
+ Some(&mut stream_index),
+ Some(&mut flags),
+ Some(&mut timestamp),
+ Some(&mut sample),
+ )
+ .map_err(|e| format!("ReadSample failed: {e:?}"))?;
+ }
- if flags & MF_SOURCE_READERF_ERROR != 0 {
- return Err("Stream error".to_string());
- }
+ if flags & MF_SOURCE_READERF_ENDOFSTREAM != 0 {
+ return Ok(None);
+ }
- let Some(sample) = sample else {
- return Ok(None);
+ if flags & MF_SOURCE_READERF_ERROR != 0 {
+ return Err("Stream error".to_string());
+ }
+
+ if let Some(sample) = sample.take() {
+ break sample;
+ }
};
let buffer = unsafe {
From ba1f2c2460491946672f293344d3369223293b7d Mon Sep 17 00:00:00 2001
From: Marcos Daniel Rivera Gonzalez
<139647784+LordOfGrillos@users.noreply.github.com>
Date: Tue, 13 Oct 2026 21:39:53 -0600
Subject: [PATCH 18/74] refactor(rendering): drive the Media Foundation decoder
from one request state machine
The decoder thread mixed batching, a mid-batch readahead slide, deferred
requests and four fallback chains in one loop. The policy now lives in
FrameRequests, which never touches Media Foundation: the thread takes in
requests, asks it for the next step (seek, decode one sample, or wait) and
does it. Tests drive it with a fake sample stream.
Once the decoder moves past a requested frame, the request gets the newest
sample at or below that frame. Requests are served lowest first, each with
its own seek when decoding forward cannot reach it, so the "first sample
after" fallback only applies to a frame before the stream's first sample.
The cache keeps every sample since the last seek and evicts the oldest, so
the newest sample at or below a frame is always cached.
Behaviour changes:
- Several requests in flight (export transitions between two segments of
one recording decode both through the same decoder) each get their own
frame. The highest used to be the target, and a lower one far from it got
the target's frame.
- A request behind the decoder with nothing cached at or below it seeks back.
Within half the cache size it used to get the next decoded, later frame.
- Every request slides the readahead, cache hits included, so a sequential
reader no longer drains the cache while the decoder idles.
- Readahead stops at its frame count, or before the next sample would evict
the one answering the latest request (frames too big for the cache budget
used to get a later frame); the per-batch time budget is gone.
- Samples outside the old cache window are cached too, so a frame after a
VFR gap past the window gets its own sample, not the one before the gap.
- A failed seek answers what waits from the cache instead of reading on from
the old position as if it had landed.
Export output is byte-identical to the previous decoder on the reference
recording.
---
.../rendering/src/decoder/media_foundation.rs | 827 +++++++++++-------
1 file changed, 501 insertions(+), 326 deletions(-)
diff --git a/crates/rendering/src/decoder/media_foundation.rs b/crates/rendering/src/decoder/media_foundation.rs
index fa140793173..09c2f21d69d 100644
--- a/crates/rendering/src/decoder/media_foundation.rs
+++ b/crates/rendering/src/decoder/media_foundation.rs
@@ -230,9 +230,6 @@ impl MFDecoder {
let video_width = decoder.width();
let video_height = decoder.height();
- let mut cache = BTreeMap::::new();
- let mut cache_bytes = 0usize;
- let mut last_decoded_frame: Option = None;
let mut health = DecoderHealthMonitor::new();
let init_result = DecoderInitResult {
@@ -242,313 +239,106 @@ impl MFDecoder {
};
let _ = ready_tx.send(Ok(init_result));
- struct PendingRequest {
- frame: u32,
- time: f32,
- sender: oneshot::Sender,
- }
-
- let mut carried: Option = None;
- while let Some(r) = carried.take().or_else(|| rx.recv().ok()) {
- let mut pending_requests: Vec = Vec::with_capacity(8);
+ let pixel_count = (video_width as u64) * (video_height as u64);
+ let readahead_frames = if pixel_count > 4_000_000 {
+ 8
+ } else if pixel_count > 2_000_000 {
+ 15
+ } else {
+ 30
+ };
+ let mut requests = FrameRequests::new(readahead_frames);
+ let answer = |sender: oneshot::Sender, frame: Option| {
+ let _ = sender.send(frame.map_or_else(
+ || {
+ DecodedFrame::new(
+ vec![0u8; (video_width * video_height * 4) as usize],
+ video_width,
+ video_height,
+ )
+ },
+ |frame| frame.to_decoded_frame(),
+ ));
+ };
- let mut push_request =
- |requested_time: f32, sender: oneshot::Sender| {
- if sender.is_closed() {
- return;
- }
- let frame = (requested_time * fps as f32).floor() as u32;
- pending_requests.push(PendingRequest {
- frame,
- time: requested_time,
- sender,
- });
+ loop {
+ loop {
+ let message = match requests.next_step() {
+ Step::Wait => rx.recv().ok(),
+ _ => match rx.try_recv() {
+ Err(mpsc::TryRecvError::Empty) => break,
+ message => message.ok(),
+ },
};
-
- match r {
- VideoDecoderMessage::GetFrame(requested_time, _, sender) => {
- push_request(requested_time, sender);
+ // Each request borrows a decoder handle, which owns the sender, so once the
+ // channel is gone nobody is waiting for an answer.
+ let Some(message) = message else {
+ return;
+ };
+ let VideoDecoderMessage::GetFrame(time, _, sender) = message;
+ if sender.is_closed() {
+ continue;
}
- }
- while let Ok(msg) = rx.try_recv() {
- match msg {
- VideoDecoderMessage::GetFrame(requested_time, _, sender) => {
- push_request(requested_time, sender);
+ let frame = (time * fps as f32).floor() as u32;
+ match requests.request(frame, sender) {
+ Some((sender, cached)) => answer(sender, cached),
+ None => {
+ health.record_request();
+ if health.should_warn_unhealthy() {
+ warn!(
+ name = name,
+ consecutive_errors = health.consecutive_errors,
+ texture_failures = health.consecutive_texture_read_failures,
+ total_decoded = health.total_frames_decoded,
+ "MediaFoundation decoder unhealthy, performance may degrade"
+ );
+ }
}
}
}
-
- let mut unfulfilled = Vec::with_capacity(pending_requests.len());
- for req in pending_requests.drain(..) {
- if let Some(frame) = cached_frame_for(&cache, last_decoded_frame, req.frame) {
- let _ = req.sender.send(frame.to_decoded_frame());
- } else if !req.sender.is_closed() {
- unfulfilled.push(req);
- }
- }
- pending_requests = unfulfilled;
-
- if pending_requests.is_empty() {
- continue;
- }
-
- pending_requests.sort_by_key(|r| r.frame);
-
- let target_request = pending_requests.pop().unwrap();
- let deferred_requests = pending_requests;
-
- let requested_frame = target_request.frame;
- let mut sender = Some(target_request.sender);
-
- health.record_request();
-
- if health.should_warn_unhealthy() {
- warn!(
- name = name,
- consecutive_errors = health.consecutive_errors,
- texture_failures = health.consecutive_texture_read_failures,
- total_decoded = health.total_frames_decoded,
- "MediaFoundation decoder unhealthy, performance may degrade"
- );
- }
-
- let mut cache_min = requested_frame.saturating_sub(FRAME_CACHE_SIZE as u32 / 2);
- let mut cache_max = requested_frame + FRAME_CACHE_SIZE as u32 / 2;
-
- let needs_seek = last_decoded_frame
- .map(|last| {
- if requested_frame <= last {
- last - requested_frame > FRAME_CACHE_SIZE as u32 / 2
- } else {
- requested_frame - last > FRAME_CACHE_SIZE as u32
+ requests.waiting.retain(|(_, sender)| !sender.is_closed());
+
+ let answers = match requests.next_step() {
+ Step::Wait => Vec::new(),
+ Step::Seek(frame) => match decoder.seek(frame_to_100ns(frame, fps)) {
+ Ok(()) => {
+ requests.seeked(frame);
+ Vec::new()
}
- })
- .unwrap_or(true);
-
- if needs_seek {
- let time_100ns = frame_to_100ns(requested_frame, fps);
- if let Err(e) = decoder.seek(time_100ns) {
- warn!("MediaFoundation seek failed: {e}");
- }
- cache.clear();
- cache_bytes = 0;
- last_decoded_frame = None;
- }
-
- let mut last_valid_frame: Option = None;
-
- let pixel_count = (video_width as u64) * (video_height as u64);
- let readahead_frames = if pixel_count > 4_000_000 {
- 8u32
- } else if pixel_count > 2_000_000 {
- 15u32
- } else {
- 30u32
- };
- let max_batch_duration = if pixel_count > 4_000_000 {
- Duration::from_millis(200)
- } else if pixel_count > 2_000_000 {
- Duration::from_millis(400)
- } else {
- Duration::from_millis(800)
- };
- let mut batch_start = Instant::now();
- let mut readahead_target = requested_frame + readahead_frames;
-
- loop {
- if sender.as_ref().is_some_and(|s| s.is_closed()) {
- sender.take();
- break;
- }
-
- if sender.is_none() && batch_start.elapsed() > max_batch_duration {
- break;
- }
-
- // Serve requests that arrive during readahead between samples and slide the
- // window with them; otherwise a sequential reader waits out the whole batch
- // and then finds the decoder idle while it consumes the cached frames.
- if sender.is_none() && deferred_requests.is_empty() {
- match rx.try_recv() {
- Ok(VideoDecoderMessage::GetFrame(time, distance, reply)) => {
- let frame = (time * fps as f32).floor() as u32;
- if let Some(cached) =
- cached_frame_for(&cache, last_decoded_frame, frame)
- {
- let _ = reply.send(cached.to_decoded_frame());
- readahead_target =
- readahead_target.max(frame + readahead_frames);
- cache_min = frame.saturating_sub(FRAME_CACHE_SIZE as u32 / 2);
- cache_max = frame + FRAME_CACHE_SIZE as u32 / 2;
- batch_start = Instant::now();
- continue;
- }
- carried =
- Some(VideoDecoderMessage::GetFrame(time, distance, reply));
- break;
- }
- Err(mpsc::TryRecvError::Empty) => {}
- Err(mpsc::TryRecvError::Disconnected) => break,
+ Err(e) => {
+ warn!("MediaFoundation seek failed: {e}");
+ requests.stopped()
}
- }
-
- let decode_start = Instant::now();
- match decoder.read_sample() {
- Ok(Some(mf_frame)) => {
- let decode_time = decode_start.elapsed();
- let frame_number = pts_100ns_to_frame(mf_frame.pts, fps);
-
- let has_valid_zero_copy_handles = {
- let null_ptr = std::ptr::null_mut();
- mf_frame.textures.y.handle.0 != null_ptr
- && mf_frame.textures.uv.handle.0 != null_ptr
- };
-
- let nv12_data = if has_valid_zero_copy_handles {
- health.record_success(decode_time);
- None
- } else {
- match decoder.read_texture_to_cpu(
- &mf_frame.textures.nv12.texture,
- mf_frame.width,
- mf_frame.height,
- ) {
- Ok(data) => {
- health.record_success(decode_time);
- Some(Arc::new(data))
- }
- Err(e) => {
- health.record_texture_read_failure();
- warn!(
- "Failed to read texture to CPU for frame {frame_number}: {e}"
- );
- None
- }
- }
- };
-
- let cached = CachedFrame {
- number: frame_number,
- _texture: has_valid_zero_copy_handles
- .then(|| mf_frame.textures.nv12.texture.clone()),
- _shared_handle: has_valid_zero_copy_handles
- .then_some(mf_frame.textures.nv12.handle),
- _y_handle: has_valid_zero_copy_handles
- .then_some(mf_frame.textures.y.handle),
- _uv_handle: has_valid_zero_copy_handles
- .then_some(mf_frame.textures.uv.handle),
- nv12_data,
- width: mf_frame.width,
- height: mf_frame.height,
- };
-
- if !has_valid_zero_copy_handles {
- decoder.recycle_textures(mf_frame.textures);
+ },
+ Step::Decode => {
+ let decode_start = Instant::now();
+ match decoder.read_sample() {
+ Ok(Some(mf_frame)) => {
+ let cached = cache_sample(
+ &mut decoder,
+ mf_frame,
+ decode_start.elapsed(),
+ &mut health,
+ fps,
+ );
+ let (number, bytes) = (cached.number, cached.estimated_bytes());
+ requests.decoded(number, cached, bytes)
}
-
- last_decoded_frame = Some(frame_number);
-
- if frame_number >= cache_min && frame_number <= cache_max {
- let frame_bytes = cached.estimated_bytes();
- while !cache.is_empty()
- && (cache.len() >= FRAME_CACHE_SIZE
- || cache_bytes.saturating_add(frame_bytes)
- > MAX_FRAME_CACHE_BYTES)
- {
- let key_to_remove = if frame_number > requested_frame {
- *cache.keys().next().unwrap()
- } else {
- *cache.keys().next_back().unwrap()
- };
- if let Some(evicted) = cache.remove(&key_to_remove) {
- cache_bytes =
- cache_bytes.saturating_sub(evicted.estimated_bytes());
- }
- }
- if let Some(replaced) = cache.insert(frame_number, cached.clone()) {
- cache_bytes =
- cache_bytes.saturating_sub(replaced.estimated_bytes());
- }
- cache_bytes = cache_bytes.saturating_add(frame_bytes);
+ Ok(None) => requests.stopped(),
+ Err(e) => {
+ health.record_error();
+ warn!(
+ consecutive_errors = health.consecutive_errors,
+ "MediaFoundation read_sample error: {e}"
+ );
+ requests.stopped()
}
-
- if frame_number <= requested_frame {
- last_valid_frame = Some(cached);
- }
-
- if frame_number > requested_frame {
- let frame_to_send = last_valid_frame.as_ref().or_else(|| {
- cache
- .range(..=requested_frame)
- .next_back()
- .map(|(_, f)| f)
- .or_else(|| cache.get(&frame_number))
- });
-
- if let Some(frame) = frame_to_send
- && let Some(s) = sender.take()
- {
- let _ = s.send(frame.to_decoded_frame());
- }
- }
-
- if frame_number >= readahead_target || frame_number > cache_max {
- break;
- }
- }
- Ok(None) => {
- break;
- }
- Err(e) => {
- health.record_error();
- warn!(
- consecutive_errors = health.consecutive_errors,
- "MediaFoundation read_sample error: {e}"
- );
- break;
}
}
- }
-
- if let Some(s) = sender.take() {
- let fallback = last_valid_frame.as_ref().cloned().or_else(|| {
- cache
- .range(..=requested_frame)
- .next_back()
- .or_else(|| cache.range(requested_frame..).next())
- .map(|(_, f)| f.clone())
- });
- if let Some(frame) = fallback {
- let _ = s.send(frame.to_decoded_frame());
- } else {
- let black_frame = DecodedFrame::new(
- vec![0u8; (video_width * video_height * 4) as usize],
- video_width,
- video_height,
- );
- let _ = s.send(black_frame);
- }
- }
-
- for req in deferred_requests {
- if req.sender.is_closed() {
- continue;
- }
- let frame_to_send = cache
- .get(&req.frame)
- .or_else(|| cache.range(..=req.frame).next_back().map(|(_, f)| f))
- .or(last_valid_frame.as_ref());
- if let Some(frame) = frame_to_send {
- let _ = req.sender.send(frame.to_decoded_frame());
- } else {
- let black_frame = DecodedFrame::new(
- vec![0u8; (video_width * video_height * 4) as usize],
- video_width,
- video_height,
- );
- let _ = req.sender.send(black_frame);
- }
+ };
+ for (sender, cached) in answers {
+ answer(sender, cached);
}
}
});
@@ -568,22 +358,215 @@ fn pts_100ns_to_frame(pts_100ns: i64, fps: u32) -> u32 {
super::pts_to_frame(pts_100ns, ::ffmpeg::Rational::new(1, 10_000_000), fps)
}
-// A frame the decoder has not moved past yet has to be decoded: answering with
-// the cached frame before it shows a stale image at every readahead boundary,
-// and a VFR stream can still have a later sample rounding to the same frame
-// (which replaces it in the cache), so answering early would make the choice
-// depend on request timing. Once the decoder is past a missing frame, the
-// stream has no frame there, and the latest earlier one is the hold the video
-// really shows.
-fn cached_frame_for(
- cache: &BTreeMap,
- last_decoded: Option,
- frame: u32,
-) -> Option<&T> {
- last_decoded
- .is_some_and(|last| last > frame)
- .then(|| cache.range(..=frame).next_back().map(|(_, cached)| cached))
- .flatten()
+fn cache_sample(
+ decoder: &mut cap_video_decode::MediaFoundationDecoder,
+ mf_frame: cap_video_decode::MFDecodedFrame,
+ decode_time: Duration,
+ health: &mut DecoderHealthMonitor,
+ fps: u32,
+) -> CachedFrame {
+ let frame_number = pts_100ns_to_frame(mf_frame.pts, fps);
+
+ let has_valid_zero_copy_handles = {
+ let null_ptr = std::ptr::null_mut();
+ mf_frame.textures.y.handle.0 != null_ptr && mf_frame.textures.uv.handle.0 != null_ptr
+ };
+
+ let nv12_data = if has_valid_zero_copy_handles {
+ health.record_success(decode_time);
+ None
+ } else {
+ match decoder.read_texture_to_cpu(
+ &mf_frame.textures.nv12.texture,
+ mf_frame.width,
+ mf_frame.height,
+ ) {
+ Ok(data) => {
+ health.record_success(decode_time);
+ Some(Arc::new(data))
+ }
+ Err(e) => {
+ health.record_texture_read_failure();
+ warn!("Failed to read texture to CPU for frame {frame_number}: {e}");
+ None
+ }
+ }
+ };
+
+ let cached = CachedFrame {
+ number: frame_number,
+ _texture: has_valid_zero_copy_handles.then(|| mf_frame.textures.nv12.texture.clone()),
+ _shared_handle: has_valid_zero_copy_handles.then_some(mf_frame.textures.nv12.handle),
+ _y_handle: has_valid_zero_copy_handles.then_some(mf_frame.textures.y.handle),
+ _uv_handle: has_valid_zero_copy_handles.then_some(mf_frame.textures.uv.handle),
+ nv12_data,
+ width: mf_frame.width,
+ height: mf_frame.height,
+ };
+
+ if !has_valid_zero_copy_handles {
+ decoder.recycle_textures(mf_frame.textures);
+ }
+ cached
+}
+
+enum Step {
+ Seek(u32),
+ Decode,
+ Wait,
+}
+
+type Answer = (R, Option);
+
+// The decoder thread's request policy, kept apart from Media Foundation so tests can drive
+// it. A request is answered with the newest sample at or below its frame once the decoder
+// has moved past that frame: a VFR stream can still have a later sample rounding to the same
+// frame (which replaces it), so answering earlier would make the choice depend on request
+// timing. Once the decoder is past a frame the stream has no sample for, the one before it is
+// the hold the video really shows.
+struct FrameRequests {
+ // Every sample since the last seek minus the oldest evicted, so the newest entry at or
+ // below a frame is the stream's newest sample there.
+ cache: BTreeMap,
+ cache_bytes: usize,
+ waiting: Vec<(u32, R)>,
+ seeked_to: Option,
+ readahead_frames: u32,
+ latest_request: Option,
+}
+
+impl FrameRequests {
+ fn new(readahead_frames: u32) -> Self {
+ Self {
+ cache: BTreeMap::new(),
+ cache_bytes: 0,
+ waiting: Vec::new(),
+ seeked_to: None,
+ readahead_frames,
+ latest_request: None,
+ }
+ }
+
+ fn newest(&self) -> Option {
+ self.cache.last_key_value().map(|(&frame, _)| frame)
+ }
+
+ fn newest_at(&self, frame: u32) -> Option<&T> {
+ self.cache
+ .range(..=frame)
+ .next_back()
+ .map(|(_, (sample, _))| sample)
+ }
+
+ // A seek lands on the keyframe before its target, which can be any distance below it.
+ fn position(&self) -> Option {
+ self.newest().max(self.seeked_to)
+ }
+
+ fn request(&mut self, frame: u32, reply: R) -> Option> {
+ self.latest_request = Some(frame);
+ if self.newest().is_some_and(|newest| newest > frame)
+ && let Some(sample) = self.newest_at(frame)
+ {
+ return Some((reply, Some(sample.clone())));
+ }
+ self.waiting.push((frame, reply));
+ None
+ }
+
+ // The lowest waiting frame goes first, so each request gets its own sample instead of
+ // whatever a seek for a later one lands on. Decoding forward only reaches frames from the
+ // newest sample on, so one below it seeks back.
+ fn next_step(&self) -> Step {
+ let Some(target) = self.waiting.iter().map(|(frame, _)| *frame).min() else {
+ return if self.wants_readahead() {
+ Step::Decode
+ } else {
+ Step::Wait
+ };
+ };
+ let reachable = self
+ .newest()
+ .or(self.seeked_to)
+ .zip(self.position())
+ .is_some_and(|(from, position)| {
+ (from..=position.saturating_add(FRAME_CACHE_SIZE as u32)).contains(&target)
+ });
+ if reachable {
+ Step::Decode
+ } else {
+ Step::Seek(target)
+ }
+ }
+
+ // Readahead stops before the next sample would evict the one answering the latest
+ // request: with frames too big for the cache budget, that would send every request back
+ // to a seek.
+ fn wants_readahead(&self) -> bool {
+ let (Some(position), Some(latest)) = (self.position(), self.latest_request) else {
+ return false;
+ };
+ let full = self.cache.last_key_value().is_some_and(|(_, (_, bytes))| {
+ self.cache.len() >= FRAME_CACHE_SIZE || self.cache_bytes + bytes > MAX_FRAME_CACHE_BYTES
+ });
+ let evicts_shown = full && self.cache.range(..=latest).nth(1).is_none();
+ position < latest.saturating_add(self.readahead_frames) && !evicts_shown
+ }
+
+ fn seeked(&mut self, frame: u32) {
+ self.cache.clear();
+ self.cache_bytes = 0;
+ self.seeked_to = Some(frame);
+ }
+
+ // A waiting frame the decoder jumped past has no sample at or below it since the seek, so
+ // it gets the first one after.
+ fn decoded(&mut self, frame: u32, sample: T, bytes: usize) -> Vec> {
+ let (passed, waiting): (Vec<_>, _) = std::mem::take(&mut self.waiting)
+ .into_iter()
+ .partition(|(requested, _)| *requested < frame);
+ self.waiting = waiting;
+ let answers = passed
+ .into_iter()
+ .map(|(requested, reply)| {
+ let held = self.newest_at(requested).unwrap_or(&sample);
+ (reply, Some(held.clone()))
+ })
+ .collect();
+
+ if let Some((_, replaced)) = self.cache.remove(&frame) {
+ self.cache_bytes -= replaced;
+ }
+ while !self.cache.is_empty()
+ && (self.cache.len() >= FRAME_CACHE_SIZE
+ || self.cache_bytes + bytes > MAX_FRAME_CACHE_BYTES)
+ {
+ if let Some((_, (_, evicted))) = self.cache.pop_first() {
+ self.cache_bytes -= evicted;
+ }
+ }
+ self.cache.insert(frame, (sample, bytes));
+ self.cache_bytes += bytes;
+ answers
+ }
+
+ // No sample came (end of stream or a read error), so nothing reads again until the next
+ // request.
+ fn stopped(&mut self) -> Vec> {
+ self.latest_request = None;
+ std::mem::take(&mut self.waiting)
+ .into_iter()
+ .map(|(frame, reply)| {
+ let nearest = self.newest_at(frame).or_else(|| {
+ self.cache
+ .range(frame..)
+ .next()
+ .map(|(_, (sample, _))| sample)
+ });
+ (reply, nearest.cloned())
+ })
+ .collect()
+ }
}
#[cfg(test)]
@@ -598,26 +581,218 @@ mod tests {
assert_eq!(pts_100ns_to_frame(10_000_000, 30), 30);
}
+ // Samples are their index in `frames`, which holds the frame each one rounds to.
+ struct FakeStream {
+ frames: Vec,
+ keyframe_every: usize,
+ next: usize,
+ reads: usize,
+ seeks: usize,
+ bytes: usize,
+ fail_next_read: bool,
+ }
+
+ impl FakeStream {
+ fn new(frames: impl IntoIterator- , keyframe_every: usize) -> Self {
+ Self {
+ frames: frames.into_iter().collect(),
+ keyframe_every,
+ next: 0,
+ reads: 0,
+ seeks: 0,
+ bytes: 1,
+ fail_next_read: false,
+ }
+ }
+
+ fn step(
+ &mut self,
+ requests: &mut FrameRequests
,
+ ) -> Option>> {
+ match requests.next_step() {
+ Step::Wait => None,
+ Step::Seek(frame) => {
+ let at = self.frames.iter().rposition(|&f| f <= frame).unwrap_or(0);
+ self.next = at - at % self.keyframe_every;
+ self.seeks += 1;
+ assert!(self.seeks < 100, "seek loop");
+ requests.seeked(frame);
+ Some(Vec::new())
+ }
+ Step::Decode => {
+ self.reads += 1;
+ Some(if std::mem::take(&mut self.fail_next_read) {
+ requests.stopped()
+ } else if let Some(&frame) = self.frames.get(self.next) {
+ self.next += 1;
+ requests.decoded(frame, self.next - 1, self.bytes)
+ } else {
+ requests.stopped()
+ })
+ }
+ }
+ }
+
+ fn run(&mut self, requests: &mut FrameRequests) -> Vec> {
+ let mut answers = Vec::new();
+ while let Some(step) = self.step(requests) {
+ answers.extend(step);
+ }
+ answers
+ }
+ }
+
#[test]
- fn an_undecoded_frame_is_not_answered_with_the_one_before_it() {
- let cache = BTreeMap::from([(14, 'a'), (15, 'b')]);
- assert_eq!(cached_frame_for(&cache, Some(15), 16), None);
- assert_eq!(cached_frame_for(&cache, Some(15), 17), None);
+ fn a_frame_is_answered_once_the_decoder_moves_past_it() {
+ let mut requests = FrameRequests::new(0);
+ let mut stream = FakeStream::new(0..20, 1);
+ assert!(requests.request(15, 0).is_none());
+ assert_eq!(stream.run(&mut requests), [(0, Some(15))]);
+ assert!(requests.request(16, 1).is_none());
+ assert_eq!(requests.request(15, 2), Some((2, Some(15))));
+ assert_eq!(stream.run(&mut requests), [(1, Some(16))]);
}
#[test]
- fn the_newest_decoded_frame_waits_for_a_later_sample() {
- let cache = BTreeMap::from([(14, 'a'), (15, 'b')]);
- assert_eq!(cached_frame_for(&cache, Some(15), 14), Some(&'a'));
- assert_eq!(cached_frame_for(&cache, Some(15), 15), None);
- assert_eq!(cached_frame_for(&cache, Some(16), 15), Some(&'b'));
+ fn colliding_vfr_samples_answer_the_same_whenever_requested() {
+ let frames = [0, 1, 1, 2, 3];
+
+ let mut early = FrameRequests::new(0);
+ let mut stream = FakeStream::new(frames, 10);
+ early.request(1, 0);
+ assert_eq!(stream.run(&mut early), [(0, Some(2))]);
+
+ let mut late = FrameRequests::new(0);
+ let mut stream = FakeStream::new(frames, 10);
+ late.request(0, 0);
+ assert_eq!(stream.run(&mut late), [(0, Some(0))]);
+ assert!(late.request(1, 1).is_none());
+ assert_eq!(stream.run(&mut late), [(1, Some(2))]);
}
#[test]
fn a_frame_the_stream_skipped_holds_the_previous_one() {
- let cache = BTreeMap::from([(14, 'a'), (17, 'b')]);
- assert_eq!(cached_frame_for(&cache, Some(17), 15), Some(&'a'));
- assert_eq!(cached_frame_for(&cache, Some(17), 16), Some(&'a'));
- assert_eq!(cached_frame_for(&cache, None, 16), None);
+ let mut requests = FrameRequests::new(0);
+ let mut stream = FakeStream::new([1, 4, 5], 10);
+ requests.request(0, 0);
+ requests.request(2, 1);
+ requests.request(3, 2);
+ assert_eq!(
+ stream.run(&mut requests),
+ [(0, Some(0)), (1, Some(0)), (2, Some(0))]
+ );
+ }
+
+ #[test]
+ fn readahead_slides_with_every_request() {
+ let mut requests = FrameRequests::new(5);
+ let mut stream = FakeStream::new(0..100, 30);
+ requests.request(0, 0);
+ assert_eq!(stream.run(&mut requests), [(0, Some(0))]);
+ for frame in 1..40 {
+ let sample = frame as usize;
+ assert_eq!(
+ requests.request(frame, sample),
+ Some((sample, Some(sample)))
+ );
+ assert!(stream.run(&mut requests).is_empty());
+ assert_eq!(requests.position(), Some(frame + 5));
+ }
+ assert_eq!(stream.seeks, 1);
+ }
+
+ #[test]
+ fn readahead_never_evicts_a_frame_still_being_shown() {
+ let frames: Vec = (0..=10).chain(50..=90).collect();
+ let mut requests = FrameRequests::new(8);
+ let mut stream = FakeStream::new(frames.clone(), 100);
+ stream.bytes = MAX_FRAME_CACHE_BYTES / 2;
+ for frame in 0..70 {
+ let mut answers: Vec<_> = requests.request(frame, 0).into_iter().collect();
+ answers.extend(stream.run(&mut requests));
+ assert_eq!(answers, [(0, frames.iter().rposition(|&f| f <= frame))]);
+ }
+ assert_eq!(stream.seeks, 1);
+ }
+
+ #[test]
+ fn every_waiting_request_gets_its_own_frame() {
+ let mut requests = FrameRequests::new(0);
+ let mut stream = FakeStream::new(0..400, 30);
+ requests.request(300, 0);
+ requests.request(30, 1);
+ assert_eq!(stream.run(&mut requests), [(1, Some(30)), (0, Some(300))]);
+ assert_eq!(stream.seeks, 2);
+ }
+
+ #[test]
+ fn a_passed_frame_missing_from_the_cache_seeks_back() {
+ let mut requests = FrameRequests::new(30);
+ let mut stream = FakeStream::new(0..200, 10);
+ requests.request(100, 0);
+ assert_eq!(stream.run(&mut requests), [(0, Some(100))]);
+ assert!(requests.request(90, 1).is_none());
+ assert_eq!(stream.run(&mut requests), [(1, Some(90))]);
+ assert_eq!(stream.seeks, 2);
+ }
+
+ #[test]
+ fn far_requests_seek_and_near_ones_decode_through() {
+ let mut requests = FrameRequests::new(0);
+ let mut stream = FakeStream::new(0..400, 30);
+ requests.request(10, 0);
+ stream.run(&mut requests);
+ requests.request(90, 1);
+ assert_eq!(stream.run(&mut requests), [(1, Some(90))]);
+ assert_eq!(stream.seeks, 1);
+ requests.request(300, 2);
+ assert_eq!(stream.run(&mut requests), [(2, Some(300))]);
+ assert_eq!(stream.seeks, 2);
+ }
+
+ #[test]
+ fn a_seek_decodes_through_a_long_gop_without_seeking_again() {
+ let mut requests = FrameRequests::new(0);
+ let mut stream = FakeStream::new(0..400, 200);
+ requests.request(150, 0);
+ assert_eq!(stream.run(&mut requests), [(0, Some(150))]);
+ assert_eq!(stream.seeks, 1);
+ }
+
+ #[test]
+ fn a_request_inside_a_seeks_preroll_decodes_through() {
+ let mut requests = FrameRequests::new(0);
+ let mut stream = FakeStream::new(0..400, 30);
+ requests.request(310, 0);
+ stream.step(&mut requests);
+ stream.step(&mut requests);
+ assert_eq!(requests.newest(), Some(300));
+ requests.request(305, 1);
+ assert_eq!(stream.run(&mut requests), [(1, Some(305)), (0, Some(310))]);
+ assert_eq!(stream.seeks, 1);
+ }
+
+ #[test]
+ fn the_end_of_the_stream_holds_its_last_frame() {
+ let mut requests = FrameRequests::new(30);
+ let mut stream = FakeStream::new(0..5, 10);
+ requests.request(3, 0);
+ assert_eq!(stream.run(&mut requests), [(0, Some(3))]);
+ assert!(requests.request(4, 1).is_none());
+ assert!(requests.request(60, 2).is_none());
+ assert_eq!(stream.run(&mut requests), [(1, Some(4)), (2, Some(4))]);
+ }
+
+ #[test]
+ fn a_read_error_answers_what_waits_and_the_next_request_retries() {
+ let mut requests = FrameRequests::new(0);
+ let mut stream = FakeStream::new(0..10, 10);
+ requests.request(0, 0);
+ stream.run(&mut requests);
+ requests.request(5, 1);
+ stream.fail_next_read = true;
+ assert_eq!(stream.run(&mut requests), [(1, Some(1))]);
+ requests.request(5, 2);
+ assert_eq!(stream.run(&mut requests), [(2, Some(5))]);
}
}
From db5c227cd2b7c1cdc6d77fa3f0f5c88c2a1c85f4 Mon Sep 17 00:00:00 2001
From: EfeDurmaz16
Date: Wed, 14 Oct 2026 13:08:50 +0300
Subject: [PATCH 19/74] perf(gpui): skip unused PCM preparation for video-only
exports
---
apps/desktop-gpui/src/editor_export.rs | 5 ++++-
crates/export/src/lib.rs | 18 ++++++++++++++++--
2 files changed, 20 insertions(+), 3 deletions(-)
diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs
index abbf6d98e15..d1b439e3d44 100644
--- a/apps/desktop-gpui/src/editor_export.rs
+++ b/apps/desktop-gpui/src/editor_export.rs
@@ -2928,7 +2928,10 @@ async fn run_export(
let total = base.total_frames(fps);
(PreparedBase::Mp4(base), total)
} else {
- let base = builder.build().await.map_err(|error| error.to_string())?;
+ let base = builder
+ .build_video_only()
+ .await
+ .map_err(|error| error.to_string())?;
let total = base.total_frames(fps);
(PreparedBase::Other(base), total)
};
diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs
index 1a5667796f9..9591ebd19ac 100644
--- a/crates/export/src/lib.rs
+++ b/crates/export/src/lib.rs
@@ -85,14 +85,18 @@ impl ExporterBuilder {
}
pub async fn build(self) -> Result {
- self.build_inner(None).await
+ self.build_inner(None, true).await
+ }
+
+ pub async fn build_video_only(self) -> Result {
+ self.build_inner(None, false).await
}
pub async fn build_for_mp4(
self,
cancellation: Arc,
) -> Result {
- self.build_inner(Some(cancellation))
+ self.build_inner(Some(cancellation), true)
.await
.map(Mp4ExporterBase)
}
@@ -110,6 +114,7 @@ impl ExporterBuilder {
async fn build_inner(
mut self,
cancellation: Option>,
+ include_audio: bool,
) -> Result {
type Error = ExporterBuildError;
@@ -179,6 +184,15 @@ impl ExporterBuilder {
let (segments, audio) =
finish_audio_preparation(segments, preparation, &control.stop).await?;
(segments, Some(audio))
+ } else if !include_audio {
+ let segments = cap_editor::create_segments_without_audio(
+ &recording_meta,
+ studio_meta,
+ self.force_ffmpeg_decoder,
+ )
+ .await
+ .map_err(Error::MediaLoad)?;
+ (segments, None)
} else {
let segments = cap_editor::create_segments(
&recording_meta,
From 0702ab69f8846af817f4dd52e8da32501f58ac12 Mon Sep 17 00:00:00 2001
From: EfeDurmaz16
Date: Wed, 14 Oct 2026 13:08:50 +0300
Subject: [PATCH 20/74] test(gpui): benchmark complete video-only exports
---
.../docs/video-only-export-performance.md | 97 ++++++++
.../examples/video-only-preparation.rs | 209 ++++++++++++++++++
scripts/check-video-only-export-parity.py | 74 +++++++
scripts/prepare-video-only-export-fixture.py | 119 ++++++++++
4 files changed, 499 insertions(+)
create mode 100644 apps/desktop-gpui/docs/video-only-export-performance.md
create mode 100644 apps/desktop-gpui/examples/video-only-preparation.rs
create mode 100644 scripts/check-video-only-export-parity.py
create mode 100644 scripts/prepare-video-only-export-fixture.py
diff --git a/apps/desktop-gpui/docs/video-only-export-performance.md b/apps/desktop-gpui/docs/video-only-export-performance.md
new file mode 100644
index 00000000000..6f4ac85bfea
--- /dev/null
+++ b/apps/desktop-gpui/docs/video-only-export-performance.md
@@ -0,0 +1,97 @@
+# Video-only export preparation
+
+GIF and cursor-only MOV export no audio. Their GPUI preparation previously
+waited for every recording mic/system track to decode into retained PCM.
+`build_video_only()` uses the existing segment loader without audio. Generic
+`build()` and MP4 preparation keep their existing behavior. Recording metadata
+still probes audio files; this change removes full PCM decoding and retention,
+not all audio I/O.
+
+## Measurements
+
+Baseline source: `68d6c31b44db6b3c68d8da7510b159c538c06d49`.
+Measured on Apple M5 Pro, macOS 26.6.2 (25G83), Rust 1.97.1.
+Three paired repetitions per format/input, alternating pair order, separate
+processes, same release binary with baseline and video-only modes. The binary
+used the GPUI release profile: optimization level 3, thin LTO, one codegen unit.
+It called actual `ExporterBuilder` and GIF/ProRes encoders. The timer includes
+builder setup through encoder finish, excluding configuration JSON loading,
+fixture generation, validation, and GPUI dialogs/UI.
+
+Synthetic inputs: 30-second control and 1800-second stress recordings, each
+with two stereo AAC tracks at 48 kHz and a four-second trimmed timeline.
+Output is 640x360 at 15 fps, 60 decoded frames. Cursor fixtures reuse checked-in
+CLI assets with normalized movement, a shape change, and one click.
+
+| Source | Format | Median total, baseline to candidate | Median peak RSS, baseline to candidate |
+|---|---|---|---|
+| 30 s | GIF | 0.209761 to 0.197071 s | 138.98 to 122.94 MiB |
+| 30 s | Cursor MOV | 0.460177 to 0.450832 s | 88.77 to 66.45 MiB |
+| 1800 s | GIF | 0.871047 to 0.209086 s | 1458.03 to 123.23 MiB |
+| 1800 s | Cursor MOV | 1.120212 to 0.479418 s | 1397.25 to 69.45 MiB |
+
+All 12 baseline/candidate pairs passed decoded RGBA checksums, frame count,
+PTS/order/duration and stream metadata parity, with no audio stream. All six
+MOV pairs additionally passed decoded alpha parity and visible moving alpha.
+Results establish the benefit for these trimmed synthetic inputs; they do not
+predict export speedups for every recording. Short controls show modest gains.
+
+## Reproduce
+
+Requires the project's native FFmpeg setup, `ffmpeg`, `ffprobe`, and Python 3.
+Use the GPUI lockfile and workspace to retain its dependency pins. Build once,
+then time separate binary processes; exclude Cargo/build time from results.
+
+```sh
+python3 scripts/prepare-video-only-export-fixture.py /tmp/video-only-1800.cap --source-seconds 1800 --export-seconds 4 --with-cursor
+cargo +stable run --manifest-path apps/desktop-gpui/Cargo.toml --example video-only-preparation --release -- --project /tmp/video-only-1800.cap --output /tmp/video-only-baseline.gif --mode baseline --format gif
+cargo +stable run --manifest-path apps/desktop-gpui/Cargo.toml --example video-only-preparation --release -- --project /tmp/video-only-1800.cap --output /tmp/video-only-candidate.gif --mode video-only --format gif
+python3 scripts/check-video-only-export-parity.py /tmp/video-only-baseline.gif /tmp/video-only-candidate.gif
+```
+
+For MOV use `--format cursor-mov`, `.mov` output paths, and parity flags
+`--alpha --require-visible-alpha`. Repeat with `--source-seconds 30` for the
+control. Use unique output paths because the example refuses existing files.
+Capture JSON stdout and `/usr/bin/time -l` stderr for peak RSS on macOS.
+`--build-only` isolates preparation but is not a complete-export benchmark.
+The first progress timestamp measures a callback, not an encoded packet.
+
+Corrupt unused audio can now produce a video-only export after a metadata
+warning instead of failing the complete PCM/EOF wait. Display-video errors
+still fail preparation. Native GPUI UI timing and cancellation are outside
+these measurements. Separate source-loading checks passed: no-audio succeeds
+in both modes; invalid mic fails baseline and passes video-only; invalid
+display fails both modes.
+
+
+Raw samples
+
+```csv
+source_seconds,format,repetition,mode,build_secs,total_secs,peak_rss_mib
+30,Gif,0,Baseline,0.056724625,0.360145084,156.609375
+30,Gif,0,VideoOnly,0.012039167,0.189455042,122.937500
+30,Gif,1,VideoOnly,0.012658167,0.199531333,122.906250
+30,Gif,1,Baseline,0.022580584,0.205054375,138.984375
+30,Gif,2,Baseline,0.020476042,0.209760750,133.125000
+30,Gif,2,VideoOnly,0.009499250,0.197070667,137.000000
+30,CursorMov,0,Baseline,0.021880417,0.460177125,88.765625
+30,CursorMov,0,VideoOnly,0.011225542,0.450831625,66.375000
+30,CursorMov,1,VideoOnly,0.012083208,0.459454000,66.593750
+30,CursorMov,1,Baseline,0.021884542,0.456129417,88.562500
+30,CursorMov,2,Baseline,0.024268625,0.472918166,88.968750
+30,CursorMov,2,VideoOnly,0.009287208,0.446681750,66.453125
+1800,Gif,0,Baseline,0.657632584,0.846303542,1459.031250
+1800,Gif,0,VideoOnly,0.012863292,0.209086459,123.468750
+1800,Gif,1,VideoOnly,0.012166709,0.196155334,117.234375
+1800,Gif,1,Baseline,0.664888292,0.871047209,1458.031250
+1800,Gif,2,Baseline,0.685738792,0.914366584,1450.703125
+1800,Gif,2,VideoOnly,0.013697875,0.216982000,123.234375
+1800,CursorMov,0,Baseline,0.668535250,1.155541958,1397.250000
+1800,CursorMov,0,VideoOnly,0.015395500,0.499813208,69.453125
+1800,CursorMov,1,VideoOnly,0.015545292,0.475296500,68.562500
+1800,CursorMov,1,Baseline,0.642078958,1.118089667,1398.000000
+1800,CursorMov,2,Baseline,0.639165958,1.120211791,1397.203125
+1800,CursorMov,2,VideoOnly,0.015095083,0.479417500,70.343750
+```
+
+
diff --git a/apps/desktop-gpui/examples/video-only-preparation.rs b/apps/desktop-gpui/examples/video-only-preparation.rs
new file mode 100644
index 00000000000..dd5ef48ef53
--- /dev/null
+++ b/apps/desktop-gpui/examples/video-only-preparation.rs
@@ -0,0 +1,209 @@
+use std::{
+ collections::HashMap,
+ path::PathBuf,
+ sync::{
+ Arc,
+ atomic::{AtomicU32, AtomicU64, Ordering},
+ },
+ time::Instant,
+};
+
+use cap_export::{
+ ExporterBase, gif::GifExportSettings, make_cursor_only_project, mov::MovExportSettings,
+};
+use cap_project::{ProjectConfiguration, XY};
+
+#[derive(Clone, Copy, Debug)]
+enum Mode {
+ Baseline,
+ VideoOnly,
+}
+
+#[derive(Clone, Copy, Debug)]
+enum Format {
+ Gif,
+ CursorMov,
+}
+
+#[derive(Debug)]
+struct Args {
+ project: PathBuf,
+ output: PathBuf,
+ mode: Mode,
+ format: Format,
+ width: u32,
+ height: u32,
+ fps: u32,
+ build_only: bool,
+ force_ffmpeg: bool,
+}
+
+impl Args {
+ fn parse() -> Result> {
+ let mut arguments = std::env::args().skip(1);
+ let mut values = HashMap::new();
+ let mut build_only = false;
+ let mut force_ffmpeg = false;
+ while let Some(argument) = arguments.next() {
+ match argument.as_str() {
+ "--build-only" if !build_only => build_only = true,
+ "--force-ffmpeg" if !force_ffmpeg => force_ffmpeg = true,
+ "--project" | "--output" | "--mode" | "--format" | "--width" | "--height"
+ | "--fps" => {
+ let value = arguments
+ .next()
+ .ok_or_else(|| format!("Missing value for {argument}"))?;
+ if values.insert(argument.clone(), value).is_some() {
+ return Err(format!("Duplicate argument: {argument}").into());
+ }
+ }
+ "--help" | "-h" => {
+ println!(
+ "Required: --project PATH --output PATH --mode baseline|video-only --format gif|cursor-mov\nOptional: --width 640 --height 360 --fps 15 --build-only --force-ffmpeg"
+ );
+ std::process::exit(0);
+ }
+ _ => return Err(format!("Unknown or duplicate argument: {argument}").into()),
+ }
+ }
+ let project = PathBuf::from(values.remove("--project").ok_or("--project is required")?);
+ let output = PathBuf::from(values.remove("--output").ok_or("--output is required")?);
+ let mode = match values
+ .remove("--mode")
+ .ok_or("--mode is required")?
+ .as_str()
+ {
+ "baseline" => Mode::Baseline,
+ "video-only" => Mode::VideoOnly,
+ _ => return Err("--mode must be baseline or video-only".into()),
+ };
+ let format = match values
+ .remove("--format")
+ .ok_or("--format is required")?
+ .as_str()
+ {
+ "gif" => Format::Gif,
+ "cursor-mov" => Format::CursorMov,
+ _ => return Err("--format must be gif or cursor-mov".into()),
+ };
+ let width = values
+ .remove("--width")
+ .map(|value| value.parse::())
+ .transpose()?
+ .unwrap_or(640);
+ let height = values
+ .remove("--height")
+ .map(|value| value.parse::())
+ .transpose()?
+ .unwrap_or(360);
+ let fps = values
+ .remove("--fps")
+ .map(|value| value.parse::())
+ .transpose()?
+ .unwrap_or(15);
+ if width == 0 || height == 0 || fps == 0 {
+ return Err("Width, height, and FPS must be positive".into());
+ }
+ Ok(Self {
+ project,
+ output,
+ mode,
+ format,
+ width,
+ height,
+ fps,
+ build_only,
+ force_ffmpeg,
+ })
+ }
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ let args = Args::parse()?;
+ let extension = match args.format {
+ Format::Gif => "gif",
+ Format::CursorMov => "mov",
+ };
+ if args.output.extension().and_then(|value| value.to_str()) != Some(extension) {
+ return Err(format!("Output must end with .{extension}").into());
+ }
+ if args.output.exists() {
+ return Err("Refusing to overwrite an existing output".into());
+ }
+ let mut config = ProjectConfiguration::load(&args.project)?;
+ if matches!(args.format, Format::CursorMov) {
+ config = make_cursor_only_project(config);
+ }
+ let started = Instant::now();
+ let builder = ExporterBase::builder(args.project.clone())
+ .with_config(config)
+ .with_output_path(args.output.clone())
+ .with_force_ffmpeg_decoder(args.force_ffmpeg);
+ let base = match args.mode {
+ Mode::Baseline => builder.build().await?,
+ Mode::VideoOnly => builder.build_video_only().await?,
+ };
+ let build_secs = started.elapsed().as_secs_f64();
+ let expected_frames = base.total_frames(args.fps);
+ if args.build_only {
+ println!(
+ "{}",
+ serde_json::json!({
+ "scope": "export_builder_only", "mode": format!("{:?}", args.mode),
+ "format": format!("{:?}", args.format), "build_secs": build_secs,
+ "expected_frames": expected_frames, "project": args.project,
+ })
+ );
+ drop(base);
+ return Ok(());
+ }
+ let frames = Arc::new(AtomicU32::new(0));
+ let first_progress_us = Arc::new(AtomicU64::new(u64::MAX));
+ let count = frames.clone();
+ let first = first_progress_us.clone();
+ let on_progress = move |_frame: u32| {
+ count.fetch_add(1, Ordering::Relaxed);
+ let _ = first.compare_exchange(
+ u64::MAX,
+ started.elapsed().as_micros() as u64,
+ Ordering::Relaxed,
+ Ordering::Relaxed,
+ );
+ true
+ };
+ let resolution_base = XY::new(args.width, args.height);
+ let output = match args.format {
+ Format::Gif => {
+ GifExportSettings {
+ fps: args.fps,
+ resolution_base,
+ quality: None,
+ }
+ .export(base, on_progress)
+ .await?
+ }
+ Format::CursorMov => {
+ MovExportSettings {
+ fps: args.fps,
+ resolution_base,
+ cursor_only: true,
+ }
+ .export(base, on_progress)
+ .await?
+ }
+ };
+ println!(
+ "{}",
+ serde_json::json!({
+ "scope": "gpui_equivalent_export", "mode": format!("{:?}", args.mode),
+ "format": format!("{:?}", args.format), "build_secs": build_secs,
+ "total_secs": started.elapsed().as_secs_f64(),
+ "first_progress_us": first_progress_us.load(Ordering::Relaxed),
+ "progress_frames": frames.load(Ordering::Relaxed),
+ "expected_frames": expected_frames, "output": output,
+ "output_bytes": std::fs::metadata(&output)?.len(),
+ })
+ );
+ Ok(())
+}
diff --git a/scripts/check-video-only-export-parity.py b/scripts/check-video-only-export-parity.py
new file mode 100644
index 00000000000..8398385bbd8
--- /dev/null
+++ b/scripts/check-video-only-export-parity.py
@@ -0,0 +1,74 @@
+import argparse
+import json
+import subprocess
+from pathlib import Path
+
+
+def probe(path):
+ raw = subprocess.check_output([
+ "ffprobe", "-v", "error", "-count_frames", "-show_streams", "-show_format",
+ "-of", "json", str(path),
+ ])
+ data = json.loads(raw)
+ selected = []
+ for stream in data["streams"]:
+ selected.append({key: stream.get(key) for key in [
+ "codec_name", "codec_type", "width", "height", "pix_fmt", "time_base",
+ "duration", "nb_read_frames", "avg_frame_rate",
+ ]})
+ return selected
+
+
+def frames(path, alpha=False):
+ command = ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-i", str(path),
+ "-map", "0:v:0"]
+ if alpha:
+ command += ["-vf", "alphaextract"]
+ else:
+ command += ["-pix_fmt", "rgba"]
+ command += ["-f", "framemd5", "-"]
+ raw = subprocess.check_output(command).decode()
+ return [line.strip() for line in raw.splitlines() if line and not line.startswith("#")]
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("baseline", type=Path)
+ parser.add_argument("candidate", type=Path)
+ parser.add_argument("--alpha", action="store_true")
+ parser.add_argument("--require-visible-alpha", action="store_true")
+ args = parser.parse_args()
+ if args.require_visible_alpha and not args.alpha:
+ parser.error("--require-visible-alpha requires --alpha")
+ a, b = probe(args.baseline), probe(args.candidate)
+ if a != b:
+ raise SystemExit(f"Stream metadata differs: {a!r} vs {b!r}")
+ if any(stream["codec_type"] == "audio" for stream in a):
+ raise SystemExit("Unexpected audio stream in video-only export")
+ fa, fb = frames(args.baseline), frames(args.candidate)
+ if not fa or fa != fb:
+ raise SystemExit("Decoded frame checksums, PTS, durations, or order differ")
+ alpha_match = None
+ visible_alpha = None
+ if args.alpha:
+ aa, ab = frames(args.baseline, True), frames(args.candidate, True)
+ alpha_match = bool(aa) and aa == ab
+ if not alpha_match:
+ raise SystemExit("Decoded alpha differs")
+ if args.require_visible_alpha:
+ raw_alpha = subprocess.check_output([
+ "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
+ "-i", str(args.baseline), "-map", "0:v:0", "-vf", "alphaextract",
+ "-pix_fmt", "gray", "-f", "rawvideo", "-",
+ ])
+ hashes = {line.rsplit(",", 1)[-1].strip() for line in aa}
+ visible_alpha = bool(raw_alpha) and min(raw_alpha) == 0 and max(raw_alpha) > 0 and len(hashes) > 1
+ if not visible_alpha:
+ raise SystemExit("Cursor alpha is empty, fully opaque, or static")
+ print(json.dumps({"decoded_frame_parity": True, "frames": len(fa),
+ "streams": a, "alpha_parity": alpha_match,
+ "visible_moving_alpha": visible_alpha}))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/prepare-video-only-export-fixture.py b/scripts/prepare-video-only-export-fixture.py
new file mode 100644
index 00000000000..16b074743f6
--- /dev/null
+++ b/scripts/prepare-video-only-export-fixture.py
@@ -0,0 +1,119 @@
+import argparse
+import json
+import math
+import shutil
+import subprocess
+from pathlib import Path
+
+
+def add_cursor(fixture, assets_directory):
+ fixture = fixture.resolve()
+ marker = json.loads((fixture / "SYNTHETIC-FIXTURE.json").read_text())
+ if marker.get("synthetic") is not True or marker.get("audio_only"):
+ raise SystemExit("Only disposable synthetic full fixtures may be modified")
+ meta_path = fixture / "recording-meta.json"
+ meta = json.loads(meta_path.read_text())
+ if len(meta.get("segments", [])) != 1:
+ raise SystemExit("Expected one multi-segment recording entry")
+ assets = json.loads((assets_directory / "cursors.json").read_text())
+ for cursor_id in ("0", "2"):
+ relative = assets[cursor_id]["imagePath"]
+ source = assets_directory / f"cursor_{cursor_id}.png"
+ destination = fixture / relative
+ if destination.exists() and destination.read_bytes() != source.read_bytes():
+ raise SystemExit(f"Refusing to replace different cursor asset: {destination}")
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(source, destination)
+ duration = float(marker["export_seconds"])
+ sample_count = max(2, math.ceil(duration * 60))
+ moves = []
+ for index in range(sample_count + 1):
+ phase = index / sample_count
+ moves.append({
+ "active_modifiers": [], "cursor_id": "0" if phase < 0.5 else "2",
+ "time_ms": phase * duration * 1000,
+ "x": 0.2 + 0.6 * phase,
+ "y": 0.5 + 0.2 * math.sin(phase * math.tau),
+ })
+ click_time_ms = duration * 0.7 * 1000
+ clicks = [
+ {"active_modifiers": [], "cursor_num": 1, "cursor_id": "2",
+ "time_ms": click_time_ms, "down": True},
+ {"active_modifiers": [], "cursor_num": 1, "cursor_id": "2",
+ "time_ms": click_time_ms + 80, "down": False},
+ ]
+ cursor_path = fixture / "content/cursor-campaign.json"
+ cursor_path.write_text(json.dumps({"moves": moves, "clicks": clicks}, indent=2) + "\n")
+ meta["cursors"] = {cursor_id: assets[cursor_id] for cursor_id in ("0", "2")}
+ meta["segments"][0]["cursor"] = "content/cursor-campaign.json"
+ meta_path.write_text(json.dumps(meta, indent=2) + "\n")
+ marker["cursor_trajectory"] = "Synthetic normalized 60Hz movement, arrow-to-hand, one click"
+ marker["cursor_asset_source"] = str(assets_directory.resolve())
+ (fixture / "SYNTHETIC-FIXTURE.json").write_text(json.dumps(marker, indent=2) + "\n")
+ print(json.dumps({"fixture": str(fixture), "moves": len(moves), "clicks": len(clicks),
+ "cursor_ids": ["0", "2"], "source": str(assets_directory.resolve())}))
+
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("destination", type=Path)
+ parser.add_argument("--source-seconds", type=int, default=600)
+ parser.add_argument("--export-seconds", type=float, default=4)
+ parser.add_argument("--audio-only", action="store_true")
+ parser.add_argument("--with-cursor", action="store_true")
+ parser.add_argument("--cursor-assets", type=Path, default=Path(__file__).resolve().parents[1] /
+ "apps/cli/skill/cap-demo/assets")
+ args = parser.parse_args()
+ if args.audio_only and args.with_cursor:
+ parser.error("--with-cursor requires video generation")
+ target = args.destination.resolve()
+ if target.exists():
+ raise SystemExit("Refusing to replace an existing fixture directory")
+ if args.source_seconds <= 0 or not 0 < args.export_seconds <= args.source_seconds:
+ raise SystemExit("Invalid durations")
+ content = target / "content"
+ content.mkdir(parents=True)
+ for name, frequency in [("mic", 440), ("system", 880)]:
+ subprocess.run([
+ "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
+ "-f", "lavfi", "-i", f"sine=frequency={frequency}:sample_rate=48000:duration={args.source_seconds}",
+ "-ac", "2", "-c:a", "aac", "-b:a", "96k", str(content / f"{name}.m4a"),
+ ], check=True)
+ if not args.audio_only:
+ subprocess.run([
+ "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
+ "-f", "lavfi", "-i", f"color=c=0x234567:size=640x360:rate=15:duration={args.source_seconds}",
+ "-c:v", "libx264", "-preset", "ultrafast", "-threads", "2",
+ "-pix_fmt", "yuv420p", str(content / "display.mp4"),
+ ], check=True)
+ meta = {
+ "pretty_name": "SYNTHETIC disposable video-only export benchmark",
+ "sharing": None,
+ "segments": [{
+ "display": {"path": "content/display.mp4", "fps": 15},
+ "mic": {"path": "content/mic.m4a"},
+ "system_audio": {"path": "content/system.m4a"},
+ }],
+ }
+ config = {
+ "background": {"source": {"type": "color", "value": [30, 30, 46]}, "padding": 0},
+ "camera": {"hide": True},
+ "audio": {"mute": True},
+ "timeline": {"segments": [{"recordingSegment": 0, "timescale": 1.0, "start": 0.0,
+ "end": args.export_seconds}], "zoomSegments": []},
+ }
+ (target / "recording-meta.json").write_text(json.dumps(meta, indent=2) + "\n")
+ (target / "project-config.json").write_text(json.dumps(config, indent=2) + "\n")
+ (target / "SYNTHETIC-FIXTURE.json").write_text(json.dumps({
+ "synthetic": True, "source_seconds": args.source_seconds,
+ "export_seconds": args.export_seconds, "audio_only": args.audio_only,
+ "purpose": "Real Cap builder/export or ProgressiveAudio workload, synthetic inputs",
+ }, indent=2) + "\n")
+ if args.with_cursor:
+ add_cursor(target, args.cursor_assets)
+ print(target)
+
+
+if __name__ == "__main__":
+ main()
From b0fb820d0b55837390aee5b91626c2959eea9525 Mon Sep 17 00:00:00 2001
From: EfeDurmaz16
Date: Wed, 14 Oct 2026 14:51:27 +0300
Subject: [PATCH 21/74] fix(bench): stream export alpha validation in bounded
chunks
---
scripts/check-video-only-export-parity.py | 44 +++++++++--
scripts/test-video-only-export-parity.py | 92 +++++++++++++++++++++++
2 files changed, 130 insertions(+), 6 deletions(-)
create mode 100644 scripts/test-video-only-export-parity.py
diff --git a/scripts/check-video-only-export-parity.py b/scripts/check-video-only-export-parity.py
index 8398385bbd8..85063c61d48 100644
--- a/scripts/check-video-only-export-parity.py
+++ b/scripts/check-video-only-export-parity.py
@@ -3,6 +3,42 @@
import subprocess
from pathlib import Path
+ALPHA_READ_BYTES = 64 * 1024
+
+
+def read_alpha_extrema(stream):
+ minimum = None
+ maximum = None
+ while True:
+ chunk = stream.read(ALPHA_READ_BYTES)
+ if not chunk:
+ return minimum, maximum
+ chunk_minimum = min(chunk)
+ chunk_maximum = max(chunk)
+ minimum = chunk_minimum if minimum is None else min(minimum, chunk_minimum)
+ maximum = chunk_maximum if maximum is None else max(maximum, chunk_maximum)
+
+
+def alpha_extrema(path):
+ command = [
+ "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
+ "-i", str(path), "-map", "0:v:0", "-vf", "alphaextract",
+ "-pix_fmt", "gray", "-f", "rawvideo", "-",
+ ]
+ with subprocess.Popen(command, stdout=subprocess.PIPE) as process:
+ try:
+ if process.stdout is None:
+ raise RuntimeError("FFmpeg alpha output pipe was not created")
+ extrema = read_alpha_extrema(process.stdout)
+ except BaseException:
+ process.kill()
+ process.wait()
+ raise
+ returncode = process.wait()
+ if returncode:
+ raise subprocess.CalledProcessError(returncode, command)
+ return extrema
+
def probe(path):
raw = subprocess.check_output([
@@ -56,13 +92,9 @@ def main():
if not alpha_match:
raise SystemExit("Decoded alpha differs")
if args.require_visible_alpha:
- raw_alpha = subprocess.check_output([
- "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
- "-i", str(args.baseline), "-map", "0:v:0", "-vf", "alphaextract",
- "-pix_fmt", "gray", "-f", "rawvideo", "-",
- ])
+ minimum, maximum = alpha_extrema(args.baseline)
hashes = {line.rsplit(",", 1)[-1].strip() for line in aa}
- visible_alpha = bool(raw_alpha) and min(raw_alpha) == 0 and max(raw_alpha) > 0 and len(hashes) > 1
+ visible_alpha = minimum == 0 and maximum is not None and maximum > 0 and len(hashes) > 1
if not visible_alpha:
raise SystemExit("Cursor alpha is empty, fully opaque, or static")
print(json.dumps({"decoded_frame_parity": True, "frames": len(fa),
diff --git a/scripts/test-video-only-export-parity.py b/scripts/test-video-only-export-parity.py
new file mode 100644
index 00000000000..6bc1e6ae21c
--- /dev/null
+++ b/scripts/test-video-only-export-parity.py
@@ -0,0 +1,92 @@
+import importlib.util
+import io
+import subprocess
+import unittest
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+SPEC = importlib.util.spec_from_file_location(
+ "video_only_export_parity",
+ Path(__file__).with_name("check-video-only-export-parity.py"),
+)
+CHECKER = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(CHECKER)
+
+
+class VirtualChunk:
+ def __init__(self, size, tracking):
+ self.size = size
+ self.tracking = tracking
+ tracking["live"] += 1
+ tracking["peak"] = max(tracking["peak"], tracking["live"])
+
+ def __len__(self):
+ return self.size
+
+ def __iter__(self):
+ return iter((0, 255))
+
+ def __del__(self):
+ self.tracking["live"] -= 1
+
+
+class VirtualAlphaStream:
+ def __init__(self, size):
+ self.remaining = size
+ self.tracking = {"live": 0, "peak": 0}
+ self.reads = 0
+
+ def read(self, size=-1):
+ if not 0 < size <= CHECKER.ALPHA_READ_BYTES:
+ raise AssertionError("Alpha reads must have a bounded positive size")
+ self.reads += 1
+ size = min(size, self.remaining)
+ self.remaining -= size
+ return VirtualChunk(size, self.tracking) if size else b""
+
+
+class AlphaExtremaTests(unittest.TestCase):
+ def test_long_virtual_stream_has_bounded_reads_and_retention(self):
+ stream = VirtualAlphaStream(640 * 360 * 15 * 1800)
+ self.assertEqual(CHECKER.read_alpha_extrema(stream), (0, 255))
+ self.assertEqual(stream.remaining, 0)
+ self.assertGreater(stream.reads, 90_000)
+ self.assertLessEqual(stream.tracking["peak"], 2)
+ self.assertEqual(stream.tracking["live"], 0)
+
+ def test_empty_transparent_opaque_and_mixed_alpha(self):
+ for data, expected in [
+ (b"", (None, None)),
+ (b"\x00" * 17, (0, 0)),
+ (b"\xff" * 17, (255, 255)),
+ (b"\x00\x7f\xff", (0, 255)),
+ ]:
+ with self.subTest(data=data):
+ self.assertEqual(CHECKER.read_alpha_extrema(io.BytesIO(data)), expected)
+
+ def test_ffmpeg_failure_after_valid_alpha_is_propagated(self):
+ process = MagicMock()
+ process.__enter__.return_value = process
+ process.stdout = io.BytesIO(b"\x00\xff")
+ process.wait.return_value = 7
+ with patch.object(CHECKER.subprocess, "Popen", return_value=process) as spawn:
+ with self.assertRaises(subprocess.CalledProcessError) as failure:
+ CHECKER.alpha_extrema(Path("baseline.mov"))
+ self.assertEqual(failure.exception.returncode, 7)
+ self.assertEqual(spawn.call_args.kwargs, {"stdout": subprocess.PIPE})
+ process.wait.assert_called_once_with()
+ process.kill.assert_not_called()
+
+ def test_read_failure_kills_and_reaps_ffmpeg(self):
+ process = MagicMock()
+ process.__enter__.return_value = process
+ process.stdout.read.side_effect = OSError("Broken alpha read")
+ with patch.object(CHECKER.subprocess, "Popen", return_value=process):
+ with self.assertRaisesRegex(OSError, "Broken alpha read"):
+ CHECKER.alpha_extrema(Path("baseline.mov"))
+ process.kill.assert_called_once_with()
+ process.wait.assert_called_once_with()
+
+
+if __name__ == "__main__":
+ unittest.main()
From ad930c3ce54155bb4dddc61609e1396f169d55e3 Mon Sep 17 00:00:00 2001
From: Marcos Daniel Rivera Gonzalez
<139647784+LordOfGrillos@users.noreply.github.com>
Date: Wed, 14 Oct 2026 09:24:46 -0600
Subject: [PATCH 22/74] perf(video-decode): pipeline the Media Foundation GPU
readback
The decoder thread copied each sample to a staging texture and mapped it
straight away, so it sat blocked in Map while the GPU decoded and copied, and
the GPU sat idle while the CPU copied the frame out. read_nv12 now queues the
copy of the next sample before mapping the current one, through a ring of two
staging textures created with the decoder.
Samples are copied from Media Foundation's output surface directly into
staging, skipping the pool texture that only the disabled zero-copy path
needs. The rendering decoder caches the CPU NV12 data itself and drops the
zero-copy fields its frames could never fill, along with the two zero-copy
constructors that were their only producers.
---
.../rendering/src/decoder/media_foundation.rs | 160 ++-----------
crates/rendering/src/decoder/mod.rs | 41 ----
crates/video-decode/src/lib.rs | 2 +-
crates/video-decode/src/media_foundation.rs | 222 +++++++++++++-----
4 files changed, 187 insertions(+), 238 deletions(-)
diff --git a/crates/rendering/src/decoder/media_foundation.rs b/crates/rendering/src/decoder/media_foundation.rs
index 09c2f21d69d..9b0bfdfa6b7 100644
--- a/crates/rendering/src/decoder/media_foundation.rs
+++ b/crates/rendering/src/decoder/media_foundation.rs
@@ -3,12 +3,11 @@
use std::{
collections::BTreeMap,
path::PathBuf,
- sync::{Arc, mpsc},
+ sync::mpsc,
time::{Duration, Instant},
};
use tokio::sync::oneshot;
use tracing::{info, warn};
-use windows::Win32::{Foundation::HANDLE, Graphics::Direct3D11::ID3D11Texture2D};
use super::{DecodedFrame, DecoderInitResult, DecoderType, FRAME_CACHE_SIZE, VideoDecoderMessage};
@@ -16,7 +15,6 @@ const MAX_FRAME_CACHE_BYTES: usize = 128 * 1024 * 1024;
struct DecoderHealthMonitor {
consecutive_errors: u32,
- consecutive_texture_read_failures: u32,
total_frames_decoded: u64,
total_errors: u64,
last_successful_decode: Instant,
@@ -31,7 +29,6 @@ impl DecoderHealthMonitor {
fn new() -> Self {
Self {
consecutive_errors: 0,
- consecutive_texture_read_failures: 0,
total_frames_decoded: 0,
total_errors: 0,
last_successful_decode: Instant::now(),
@@ -49,7 +46,6 @@ impl DecoderHealthMonitor {
fn record_success(&mut self, decode_time: Duration) {
self.consecutive_errors = 0;
- self.consecutive_texture_read_failures = 0;
self.total_frames_decoded += 1;
self.last_successful_decode = Instant::now();
@@ -67,18 +63,11 @@ impl DecoderHealthMonitor {
self.total_errors += 1;
}
- fn record_texture_read_failure(&mut self) {
- self.consecutive_texture_read_failures += 1;
- }
-
fn is_healthy(&self) -> bool {
const MAX_CONSECUTIVE_ERRORS: u32 = 10;
- const MAX_CONSECUTIVE_TEXTURE_FAILURES: u32 = 5;
const MAX_TIME_SINCE_SUCCESS: Duration = Duration::from_secs(5);
- if self.consecutive_errors >= MAX_CONSECUTIVE_ERRORS
- || self.consecutive_texture_read_failures >= MAX_CONSECUTIVE_TEXTURE_FAILURES
- {
+ if self.consecutive_errors >= MAX_CONSECUTIVE_ERRORS {
return false;
}
@@ -122,63 +111,6 @@ impl DecoderHealthMonitor {
}
}
-#[derive(Clone)]
-struct CachedFrame {
- number: u32,
- _texture: Option,
- _shared_handle: Option,
- _y_handle: Option,
- _uv_handle: Option,
- nv12_data: Option>,
- width: u32,
- height: u32,
-}
-
-impl CachedFrame {
- fn estimated_bytes(&self) -> usize {
- let texture_bytes = self._texture.as_ref().map_or(0, |_| {
- (self.width as usize)
- .saturating_mul(self.height as usize)
- .saturating_mul(3)
- });
- texture_bytes.saturating_add(self.nv12_data.as_ref().map_or(0, |data| data.data.len()))
- }
-
- fn to_decoded_frame(&self) -> DecodedFrame {
- let null_ptr = std::ptr::null_mut();
- let y_handle = self._y_handle.filter(|h| h.0 != null_ptr);
- let uv_handle = self._uv_handle.filter(|h| h.0 != null_ptr);
- if let (Some(texture), Some(y_handle), Some(uv_handle)) =
- (&self._texture, y_handle, uv_handle)
- {
- return DecodedFrame::new_nv12_with_d3d11_texture_and_yuv_handles(
- self.width,
- self.height,
- texture.clone(),
- self._shared_handle,
- Some(y_handle),
- Some(uv_handle),
- );
- }
-
- if let Some(nv12_data) = &self.nv12_data {
- DecodedFrame::new_nv12_with_arc(
- Arc::clone(&nv12_data.data),
- self.width,
- self.height,
- nv12_data.y_stride,
- nv12_data.uv_stride,
- )
- } else {
- warn!(
- "CachedFrame has no CPU data, creating black frame (D3D11 zero-copy not implemented)"
- );
- let black_data = vec![0u8; (self.width * self.height * 3 / 2) as usize];
- DecodedFrame::new_nv12(black_data, self.width, self.height, self.width, self.width)
- }
- }
-}
-
pub struct MFDecoder;
impl MFDecoder {
@@ -248,7 +180,8 @@ impl MFDecoder {
30
};
let mut requests = FrameRequests::new(readahead_frames);
- let answer = |sender: oneshot::Sender, frame: Option| {
+ let answer = |sender: oneshot::Sender,
+ frame: Option| {
let _ = sender.send(frame.map_or_else(
|| {
DecodedFrame::new(
@@ -257,7 +190,15 @@ impl MFDecoder {
video_height,
)
},
- |frame| frame.to_decoded_frame(),
+ |data| {
+ DecodedFrame::new_nv12_with_arc(
+ data.data,
+ video_width,
+ video_height,
+ data.y_stride,
+ data.uv_stride,
+ )
+ },
));
};
@@ -289,7 +230,6 @@ impl MFDecoder {
warn!(
name = name,
consecutive_errors = health.consecutive_errors,
- texture_failures = health.consecutive_texture_read_failures,
total_decoded = health.total_frames_decoded,
"MediaFoundation decoder unhealthy, performance may degrade"
);
@@ -313,24 +253,22 @@ impl MFDecoder {
},
Step::Decode => {
let decode_start = Instant::now();
- match decoder.read_sample() {
- Ok(Some(mf_frame)) => {
- let cached = cache_sample(
- &mut decoder,
- mf_frame,
- decode_start.elapsed(),
- &mut health,
- fps,
- );
- let (number, bytes) = (cached.number, cached.estimated_bytes());
- requests.decoded(number, cached, bytes)
+ match decoder.read_nv12() {
+ Ok(Some(sample)) => {
+ health.record_success(decode_start.elapsed());
+ let bytes = sample.data.data.len();
+ requests.decoded(
+ pts_100ns_to_frame(sample.pts, fps),
+ sample.data,
+ bytes,
+ )
}
Ok(None) => requests.stopped(),
Err(e) => {
health.record_error();
warn!(
consecutive_errors = health.consecutive_errors,
- "MediaFoundation read_sample error: {e}"
+ "MediaFoundation read error: {e}"
);
requests.stopped()
}
@@ -358,58 +296,6 @@ fn pts_100ns_to_frame(pts_100ns: i64, fps: u32) -> u32 {
super::pts_to_frame(pts_100ns, ::ffmpeg::Rational::new(1, 10_000_000), fps)
}
-fn cache_sample(
- decoder: &mut cap_video_decode::MediaFoundationDecoder,
- mf_frame: cap_video_decode::MFDecodedFrame,
- decode_time: Duration,
- health: &mut DecoderHealthMonitor,
- fps: u32,
-) -> CachedFrame {
- let frame_number = pts_100ns_to_frame(mf_frame.pts, fps);
-
- let has_valid_zero_copy_handles = {
- let null_ptr = std::ptr::null_mut();
- mf_frame.textures.y.handle.0 != null_ptr && mf_frame.textures.uv.handle.0 != null_ptr
- };
-
- let nv12_data = if has_valid_zero_copy_handles {
- health.record_success(decode_time);
- None
- } else {
- match decoder.read_texture_to_cpu(
- &mf_frame.textures.nv12.texture,
- mf_frame.width,
- mf_frame.height,
- ) {
- Ok(data) => {
- health.record_success(decode_time);
- Some(Arc::new(data))
- }
- Err(e) => {
- health.record_texture_read_failure();
- warn!("Failed to read texture to CPU for frame {frame_number}: {e}");
- None
- }
- }
- };
-
- let cached = CachedFrame {
- number: frame_number,
- _texture: has_valid_zero_copy_handles.then(|| mf_frame.textures.nv12.texture.clone()),
- _shared_handle: has_valid_zero_copy_handles.then_some(mf_frame.textures.nv12.handle),
- _y_handle: has_valid_zero_copy_handles.then_some(mf_frame.textures.y.handle),
- _uv_handle: has_valid_zero_copy_handles.then_some(mf_frame.textures.uv.handle),
- nv12_data,
- width: mf_frame.width,
- height: mf_frame.height,
- };
-
- if !has_valid_zero_copy_handles {
- decoder.recycle_textures(mf_frame.textures);
- }
- cached
-}
-
enum Step {
Seek(u32),
Decode,
diff --git a/crates/rendering/src/decoder/mod.rs b/crates/rendering/src/decoder/mod.rs
index d5a2d6b96fc..82a10c7cde2 100644
--- a/crates/rendering/src/decoder/mod.rs
+++ b/crates/rendering/src/decoder/mod.rs
@@ -109,20 +109,6 @@ impl SendableD3D11Texture {
}
}
- pub fn new_with_yuv_handles(
- texture: ID3D11Texture2D,
- shared_handle: Option,
- y_handle: Option,
- uv_handle: Option,
- ) -> Self {
- Self {
- texture,
- shared_handle,
- y_handle,
- uv_handle,
- }
- }
-
pub fn inner(&self) -> &ID3D11Texture2D {
&self.texture
}
@@ -473,33 +459,6 @@ impl DecodedFrame {
}
}
- #[cfg(target_os = "windows")]
- pub fn new_nv12_with_d3d11_texture_and_yuv_handles(
- width: u32,
- height: u32,
- texture: ID3D11Texture2D,
- shared_handle: Option,
- y_handle: Option,
- uv_handle: Option,
- ) -> Self {
- Self {
- data: Arc::new(Vec::new()),
- width,
- height,
- format: PixelFormat::Nv12,
- y_stride: width,
- uv_stride: width,
- #[cfg(target_os = "macos")]
- image_buf_backing: None,
- d3d11_texture_backing: Some(Arc::new(SendableD3D11Texture::new_with_yuv_handles(
- texture,
- shared_handle,
- y_handle,
- uv_handle,
- ))),
- }
- }
-
#[cfg(target_os = "windows")]
#[allow(clippy::redundant_closure)]
pub fn d3d11_texture_backing(&self) -> Option<&ID3D11Texture2D> {
diff --git a/crates/video-decode/src/lib.rs b/crates/video-decode/src/lib.rs
index 682df7a778c..4eb9701378c 100644
--- a/crates/video-decode/src/lib.rs
+++ b/crates/video-decode/src/lib.rs
@@ -10,5 +10,5 @@ pub use ffmpeg::FFmpegDecoder;
#[cfg(target_os = "windows")]
pub use media_foundation::{
FrameTextures, MFDecodedFrame, MFDecoderCapabilities, MediaFoundationDecoder, NV12Data,
- get_mf_decoder_capabilities,
+ NV12Sample, get_mf_decoder_capabilities,
};
diff --git a/crates/video-decode/src/media_foundation.rs b/crates/video-decode/src/media_foundation.rs
index 6b5755214bb..f05e5dc844b 100644
--- a/crates/video-decode/src/media_foundation.rs
+++ b/crates/video-decode/src/media_foundation.rs
@@ -1,3 +1,4 @@
+use std::collections::VecDeque;
use std::path::Path;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
@@ -143,6 +144,12 @@ pub struct MFDecodedFrame {
pub plane_time: Duration,
}
+pub struct NV12Sample {
+ pub pts: i64,
+ pub data: NV12Data,
+}
+
+#[derive(Clone)]
pub struct NV12Data {
pub data: Arc>,
pub y_stride: u32,
@@ -514,6 +521,36 @@ fn create_internal_texture(
})
}
+unsafe fn create_staging_texture(
+ device: &ID3D11Device,
+ width: u32,
+ height: u32,
+) -> Result {
+ let desc = D3D11_TEXTURE2D_DESC {
+ Width: width,
+ Height: height,
+ MipLevels: 1,
+ ArraySize: 1,
+ Format: DXGI_FORMAT_NV12,
+ SampleDesc: DXGI_SAMPLE_DESC {
+ Count: 1,
+ Quality: 0,
+ },
+ Usage: D3D11_USAGE_STAGING,
+ BindFlags: 0,
+ CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
+ MiscFlags: 0,
+ };
+
+ unsafe {
+ let mut tex: Option = None;
+ device
+ .CreateTexture2D(&desc, None, Some(&mut tex))
+ .map_err(|e| format!("CreateTexture2D staging failed: {e:?}"))?;
+ tex.ok_or_else(|| "CreateTexture2D staging returned null".to_string())
+ }
+}
+
fn create_uav(
device: &ID3D11Device,
texture: &ID3D11Texture2D,
@@ -536,6 +573,9 @@ fn create_uav(
}
}
+// Samples whose copy to a staging texture can be in flight while an earlier one is read back.
+const READBACK_DEPTH: usize = 2;
+
pub struct MediaFoundationDecoder {
source_reader: IMFSourceReader,
d3d11_device: ID3D11Device,
@@ -545,9 +585,10 @@ pub struct MediaFoundationDecoder {
height: u32,
frame_rate_num: u32,
frame_rate_den: u32,
- staging_texture: Option,
- staging_width: u32,
- staging_height: u32,
+ readback_free: Vec,
+ readback_queue: VecDeque<(i64, ID3D11Texture2D)>,
+ // How the stream stopped, reported once the queued readbacks are drained.
+ readback_end: Option>,
frame_pool: FramePool,
plane_converter: Nv12PlaneConverter,
capabilities: MFDecoderCapabilities,
@@ -606,6 +647,10 @@ impl MediaFoundationDecoder {
let plane_converter = Nv12PlaneConverter::new(&d3d11_device, &d3d11_context)?;
+ let readback_free = (0..READBACK_DEPTH)
+ .map(|_| unsafe { create_staging_texture(&d3d11_device, width, height) })
+ .collect::, _>>()?;
+
if width > capabilities.max_width || height > capabilities.max_height {
warn!(
video_width = width,
@@ -633,9 +678,9 @@ impl MediaFoundationDecoder {
height,
frame_rate_num,
frame_rate_den,
- staging_texture: None,
- staging_width: 0,
- staging_height: 0,
+ readback_free,
+ readback_queue: VecDeque::new(),
+ readback_end: None,
frame_pool: FramePool::new(),
plane_converter,
capabilities,
@@ -663,60 +708,48 @@ impl MediaFoundationDecoder {
&self.capabilities
}
- pub fn read_texture_to_cpu(
- &mut self,
- texture: &ID3D11Texture2D,
- width: u32,
- height: u32,
- ) -> Result {
- unsafe { self.read_texture_to_cpu_inner(texture, width, height) }
- }
-
- unsafe fn read_texture_to_cpu_inner(
- &mut self,
- texture: &ID3D11Texture2D,
- width: u32,
- height: u32,
- ) -> Result {
- if self.staging_width != width
- || self.staging_height != height
- || self.staging_texture.is_none()
+ /// Reads the next sample back to the CPU. The copy of the sample after it is queued
+ /// first, so the GPU decodes and copies that one while this one is copied out.
+ pub fn read_nv12(&mut self) -> Result, String> {
+ while self.readback_end.is_none()
+ && let Some(staging) = self.readback_free.pop()
{
- let desc = D3D11_TEXTURE2D_DESC {
- Width: width,
- Height: height,
- MipLevels: 1,
- ArraySize: 1,
- Format: DXGI_FORMAT_NV12,
- SampleDesc: DXGI_SAMPLE_DESC {
- Count: 1,
- Quality: 0,
- },
- Usage: D3D11_USAGE_STAGING,
- BindFlags: 0,
- CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
- MiscFlags: 0,
- };
-
- let staging_texture = unsafe {
- let mut tex: Option = None;
- self.d3d11_device
- .CreateTexture2D(&desc, None, Some(&mut tex))
- .map_err(|e| format!("CreateTexture2D staging failed: {e:?}"))?;
- tex.ok_or("CreateTexture2D staging returned null")?
- };
-
- self.staging_texture = Some(staging_texture);
- self.staging_width = width;
- self.staging_height = height;
+ match unsafe { self.read_decoder_surface() } {
+ Ok(Some((texture, subresource, pts))) => {
+ unsafe {
+ self.d3d11_context.CopySubresourceRegion(
+ &staging,
+ 0,
+ 0,
+ 0,
+ 0,
+ &texture,
+ subresource,
+ None,
+ );
+ }
+ self.readback_queue.push_back((pts, staging));
+ }
+ end => {
+ self.readback_free.push(staging);
+ self.readback_end = Some(end.map(|_| ()));
+ }
+ }
}
- let staging = self.staging_texture.as_ref().unwrap();
-
- unsafe {
- self.d3d11_context.CopyResource(staging, texture);
- }
+ let Some((pts, staging)) = self.readback_queue.pop_front() else {
+ return self
+ .readback_end
+ .take()
+ .expect("the queue only runs dry once the stream has stopped")
+ .map(|()| None);
+ };
+ let data = unsafe { self.map_staging(&staging) };
+ self.readback_free.push(staging);
+ Ok(Some(NV12Sample { pts, data: data? }))
+ }
+ unsafe fn map_staging(&self, staging: &ID3D11Texture2D) -> Result {
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
unsafe {
self.d3d11_context
@@ -725,8 +758,8 @@ impl MediaFoundationDecoder {
}
let y_stride = mapped.RowPitch;
- let y_height = height;
- let uv_height = height / 2;
+ let y_height = self.height;
+ let uv_height = self.height / 2;
let y_size = (y_stride * y_height) as usize;
let uv_size = (y_stride * uv_height) as usize;
@@ -757,7 +790,12 @@ impl MediaFoundationDecoder {
}
}
- unsafe fn read_sample_inner(&mut self) -> Result, String> {
+ // The decoder's output surface for the next sample: a texture array, the sample's slice in
+ // it, and its time. Media Foundation decodes into the surface again once the sample is
+ // released, so copy out of it before the next read.
+ unsafe fn read_decoder_surface(
+ &mut self,
+ ) -> Result , String> {
let mut stream_index = 0u32;
let mut flags = 0u32;
let mut timestamp = 0i64;
@@ -819,6 +857,16 @@ impl MediaFoundationDecoder {
.map_err(|e| format!("GetSubresourceIndex failed: {e:?}"))?
};
+ Ok(Some((texture, subresource_index, timestamp)))
+ }
+
+ unsafe fn read_sample_inner(&mut self) -> Result , String> {
+ let Some((texture, subresource_index, timestamp)) =
+ (unsafe { self.read_decoder_surface()? })
+ else {
+ return Ok(None);
+ };
+
let frame_textures =
self.frame_pool
.acquire(&self.d3d11_device, self.width, self.height)?;
@@ -876,6 +924,9 @@ impl MediaFoundationDecoder {
.map_err(|e| format!("Seek failed: {e:?}"))?;
}
+ self.readback_free
+ .extend(self.readback_queue.drain(..).map(|(_, staging)| staging));
+ self.readback_end = None;
Ok(())
}
}
@@ -1136,6 +1187,59 @@ mod tests {
}
}
+ #[test]
+ #[ignore = "requires a Windows D3D11 device"]
+ fn pipelined_readback_returns_every_sample_in_order() {
+ let path =
+ Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/h264-decoder-lifecycle.mp4");
+ // This file, like the reference recording, ends with a stream error rather than an
+ // end-of-stream flag, so both readers stop at whichever comes first.
+ let mut expected = Vec::new();
+ let mut decoder = MediaFoundationDecoder::new(&path).unwrap();
+ let staging = decoder.readback_free[0].clone();
+ let expected_end = loop {
+ match unsafe { decoder.read_decoder_surface() } {
+ Ok(Some((texture, subresource, pts))) => unsafe {
+ decoder.d3d11_context.CopySubresourceRegion(
+ &staging,
+ 0,
+ 0,
+ 0,
+ 0,
+ &texture,
+ subresource,
+ None,
+ );
+ expected.push((pts, decoder.map_staging(&staging).unwrap().data));
+ },
+ end => break end.is_err(),
+ }
+ };
+ drop(decoder);
+
+ let mut decoder = MediaFoundationDecoder::new(&path).unwrap();
+ let mut read = Vec::new();
+ let end = loop {
+ match decoder.read_nv12() {
+ Ok(Some(sample)) => read.push((sample.pts, sample.data.data)),
+ end => break end.is_err(),
+ }
+ };
+ assert!(expected.len() > READBACK_DEPTH);
+ assert!(read == expected);
+ assert_eq!(end, expected_end);
+
+ let first = |decoder: &mut MediaFoundationDecoder| {
+ let sample = decoder.read_nv12().unwrap().unwrap();
+ (sample.pts, sample.data.data)
+ };
+ decoder.seek(0).unwrap();
+ assert!(first(&mut decoder) == expected[0]);
+ decoder.read_nv12().unwrap();
+ decoder.seek(0).unwrap();
+ assert!(first(&mut decoder) == expected[0]);
+ }
+
fn normalized(path: &str) -> String {
let encoded = media_foundation_path(Path::new(path));
assert_eq!(encoded.last(), Some(&0));
From adb268d975f9a6a38fab00d5e299c59d68debdcb Mon Sep 17 00:00:00 2001
From: Marcos Daniel Rivera Gonzalez
<139647784+LordOfGrillos@users.noreply.github.com>
Date: Wed, 14 Oct 2026 09:51:20 -0600
Subject: [PATCH 23/74] fix(scripts): reject broken exports and start
benchmarks inside their job
An export that ffprobe cannot read, that has the wrong frame count, or that
falls outside the requested size is now an invalid run instead of a timing
sample. Benchmark processes start suspended and resume only once they are in
the job object, so nothing they spawn escapes its CPU accounting, and a failed
assignment kills the process. A note says why playback forces FFmpeg.
---
scripts/capbench.py | 41 +++++++++++++++++++++++++++++++++++++++-
scripts/test-capbench.py | 9 +++++++++
2 files changed, 49 insertions(+), 1 deletion(-)
diff --git a/scripts/capbench.py b/scripts/capbench.py
index 600fa3ae123..87ec2bb967a 100644
--- a/scripts/capbench.py
+++ b/scripts/capbench.py
@@ -80,6 +80,17 @@ def assign(self, process):
):
raise OSError(ctypes.get_last_error(), "AssignProcessToJobObject failed")
+ def resume(self, process):
+ if self.handle is None:
+ return
+ from ctypes import wintypes
+
+ ntdll = ctypes.WinDLL("ntdll")
+ ntdll.NtResumeProcess.argtypes = [wintypes.HANDLE]
+ status = ntdll.NtResumeProcess(wintypes.HANDLE(int(process._handle)))
+ if status != 0:
+ raise OSError(status, "NtResumeProcess failed")
+
def stats(self):
if self.handle is None:
return {}
@@ -149,9 +160,13 @@ class JobAccounting(ctypes.Structure):
]
+CREATE_SUSPENDED = 0x4
+
+
def run_measured(argv, timeout, env=None, cwd=None, affinity=None):
job = Job(affinity)
started = time.perf_counter()
+ # Started suspended so nothing it spawns can run before it is in the job.
process = subprocess.Popen(
argv,
stdout=subprocess.PIPE,
@@ -161,9 +176,16 @@ def run_measured(argv, timeout, env=None, cwd=None, affinity=None):
text=True,
encoding="utf-8",
errors="replace",
+ creationflags=CREATE_SUSPENDED if job.handle is not None else 0,
)
try:
- job.assign(process)
+ try:
+ job.assign(process)
+ job.resume(process)
+ except BaseException:
+ process.kill()
+ process.wait()
+ raise
lines = []
def pump():
@@ -255,6 +277,18 @@ def parse_export(lines):
return metrics
+def export_problem(probe, expected_frames, resolution):
+ if not probe.get("out_frames"):
+ return "export has no readable video frames"
+ if expected_frames and probe["out_frames"] != expected_frames:
+ return f"export has {probe['out_frames']:.0f} frames, expected {expected_frames:.0f}"
+ max_width, max_height = (float(side) for side in resolution.split("x"))
+ width, height = probe.get("out_width", 0), probe.get("out_height", 0)
+ if not (0 < width <= max_width and 0 < height <= max_height):
+ return f"export is {width:.0f}x{height:.0f}, outside {resolution}"
+ return None
+
+
def export_bench(fps, resolution, quality, extra=()):
def argv(ctx, out_dir):
return [
@@ -264,6 +298,9 @@ def argv(ctx, out_dir):
def post(ctx, out_dir, metrics):
probe = ffprobe_video(out_dir / "export.mp4")
+ problem = export_problem(probe, metrics.get("frames"), resolution)
+ if problem:
+ raise InvalidRun(problem)
metrics.update(probe)
if metrics.get("frames") and metrics.get("wall_s"):
metrics["export_fps"] = metrics["frames"] / metrics["wall_s"]
@@ -405,6 +442,8 @@ def post(ctx, out_dir, metrics):
"export-1080p60": export_bench(60, "1920x1080", "maximum"),
"export-4k60": export_bench(60, "3840x2160", "maximum"),
"export-720p30": export_bench(30, "1280x720", "social"),
+ # The editor decodes with FFmpeg on Windows (editor_instance.rs), so playback measures that path;
+ # Media Foundation is covered by decode-display and the exports.
"playback-pipeline": example_bench(
"playback-pipeline-benchmark",
lambda ctx: ["--recording-path", str(ctx.fixture), "--fps", "60", "--frames", "300", "--force-ffmpeg"],
diff --git a/scripts/test-capbench.py b/scripts/test-capbench.py
index 9eefd98ab85..bcd2bff3919 100644
--- a/scripts/test-capbench.py
+++ b/scripts/test-capbench.py
@@ -107,6 +107,15 @@ def test_export_progress_timing(self):
self.assertEqual(metrics["frames"], 100.0)
self.assertAlmostEqual(metrics["steady_fps"], 50.0)
+ def test_export_problem_rejects_broken_exports(self):
+ good = {"out_frames": 1850.0, "out_width": 1664.0, "out_height": 1080.0}
+ self.assertIsNone(capbench.export_problem(good, 1850.0, "1920x1080"))
+ self.assertIsNotNone(capbench.export_problem({}, 1850.0, "1920x1080"))
+ self.assertIsNotNone(capbench.export_problem({**good, "out_frames": 0.0}, None, "1920x1080"))
+ self.assertIsNotNone(capbench.export_problem({**good, "out_frames": 1849.0}, 1850.0, "1920x1080"))
+ self.assertIsNotNone(capbench.export_problem({**good, "out_width": 0.0}, 1850.0, "1920x1080"))
+ self.assertIsNotNone(capbench.export_problem({**good, "out_height": 2160.0}, 1850.0, "1920x1080"))
+
def test_compare_flags_only_separated_changes(self):
def results(values):
return {"benches": {"b": {"runs": [{"wall_s": v} for v in values], "better": {"wall_s": "lower"}}}}
From 80a038ed4d4a6bd597a55b7fd081902e2623f007 Mon Sep 17 00:00:00 2001
From: Scott Newton
Date: Tue, 13 Oct 2026 14:34:25 -0700
Subject: [PATCH 24/74] perf(rendering): reshape the keyboard overlay only when
its text changes
---
crates/rendering/src/layers/keyboard.rs | 303 +++++++++++++++++++++++-
1 file changed, 290 insertions(+), 13 deletions(-)
diff --git a/crates/rendering/src/layers/keyboard.rs b/crates/rendering/src/layers/keyboard.rs
index a690adf3f93..184eefcad9d 100644
--- a/crates/rendering/src/layers/keyboard.rs
+++ b/crates/rendering/src/layers/keyboard.rs
@@ -149,12 +149,22 @@ fn resolve_background_top(
adjusted_top.clamp(0.0, max_top)
}
+#[derive(PartialEq)]
+struct ShapedKeyboardText {
+ text: String,
+ font_size: u32,
+ wrap_width: u32,
+ family: Family<'static>,
+ weight: Weight,
+}
+
pub struct KeyboardLayer {
font_system: FontSystem,
swash_cache: SwashCache,
text_atlas: TextAtlas,
text_renderer: TextRenderer,
text_buffer: Buffer,
+ shaped: Option,
viewport: Viewport,
background_pipeline: wgpu::RenderPipeline,
background_bind_group: wgpu::BindGroup,
@@ -267,6 +277,7 @@ impl KeyboardLayer {
text_atlas,
text_renderer,
text_buffer,
+ shaped: None,
viewport,
background_pipeline,
background_bind_group,
@@ -398,11 +409,7 @@ impl KeyboardLayer {
let font_size_base = active.segment.font_size_override.unwrap_or(settings.size) as f32;
let font_size = font_size_base * (height as f32 / 1080.0);
- let metrics = Metrics::new(font_size, font_size * 1.2);
-
- let mut updated_buffer = Buffer::new(&mut self.font_system, metrics);
let wrap_width = (width as f32 - margin * 2.0).max(font_size);
- updated_buffer.set_size(&mut self.font_system, Some(wrap_width), None);
let font_family = match settings.font.as_str() {
"System Serif" => Family::Serif,
@@ -426,17 +433,30 @@ impl KeyboardLayer {
(text_alpha * 255.0) as u8,
);
- let attrs = Attrs::new().family(font_family).weight(weight).color(color);
- updated_buffer.set_text(
- &mut self.font_system,
- &visible_text,
- &attrs,
- Shaping::Advanced,
- );
+ let shaped = ShapedKeyboardText {
+ text: visible_text,
+ font_size: font_size.to_bits(),
+ wrap_width: wrap_width.to_bits(),
+ family: font_family,
+ weight,
+ };
+ if self.shaped.as_ref() != Some(&shaped) {
+ let metrics = Metrics::new(font_size, font_size * 1.2);
+ let mut buffer = Buffer::new(&mut self.font_system, metrics);
+ buffer.set_size(&mut self.font_system, Some(wrap_width), None);
+ buffer.set_text(
+ &mut self.font_system,
+ &shaped.text,
+ &Attrs::new().family(font_family).weight(weight),
+ Shaping::Advanced,
+ );
+ self.text_buffer = buffer;
+ self.shaped = Some(shaped);
+ }
let mut layout_width: f32 = 0.0;
let mut layout_height: f32 = 0.0;
- for run in glyphon::cosmic_text::LayoutRunIter::new(&updated_buffer) {
+ for run in glyphon::cosmic_text::LayoutRunIter::new(&self.text_buffer) {
layout_width = layout_width.max(run.line_w);
layout_height = layout_height.max(run.line_top + run.line_height);
}
@@ -485,7 +505,6 @@ impl KeyboardLayer {
bottom: (text_top + text_height + 2.0).ceil() as i32,
};
- self.text_buffer = updated_buffer;
self.viewport.update(queue, Resolution { width, height });
let text_areas = vec![TextArea {
@@ -816,3 +835,261 @@ mod tests {
assert_eq!(resolved_top, 720.0);
}
}
+
+#[cfg(test)]
+mod gpu_tests {
+ use cap_project::{
+ CursorEvents, KeyPressDisplay, KeyboardData, KeyboardSettings, KeyboardTrackSegment,
+ ProjectConfiguration, RecordingMeta, RecordingMetaInner, StudioRecordingMeta,
+ TimelineConfiguration, XY,
+ };
+
+ use super::KeyboardLayer;
+ use crate::{
+ DecodedSegmentFrames, ProjectUniforms, RenderOptions, RenderVideoConstants,
+ ZoomTransformTimeline,
+ };
+
+ const FPS: u32 = 60;
+
+ fn constants(size: XY) -> RenderVideoConstants {
+ let meta: StudioRecordingMeta = serde_json::from_value(serde_json::json!({
+ "display": { "path": "synthetic.mp4", "fps": FPS }
+ }))
+ .unwrap();
+ let recording = RecordingMeta {
+ platform: None,
+ project_path: Default::default(),
+ pretty_name: "Keyboard overlay test".into(),
+ sharing: None,
+ inner: RecordingMetaInner::Studio(Box::new(meta.clone())),
+ upload: None,
+ };
+ pollster::block_on(RenderVideoConstants::new_with_options(
+ RenderOptions {
+ screen_size: size,
+ camera_size: None,
+ preserve_screen_alpha: false,
+ },
+ recording,
+ meta,
+ ))
+ .unwrap()
+ }
+
+ fn typed_segment(id: &str, start: f64, text: &str, key_gap_ms: f64) -> KeyboardTrackSegment {
+ let keys = text
+ .chars()
+ .enumerate()
+ .map(|(index, key)| KeyPressDisplay {
+ key: key.to_string(),
+ time_offset: index as f64 * key_gap_ms,
+ })
+ .collect::>();
+ KeyboardTrackSegment {
+ id: id.to_string(),
+ start,
+ end: start + keys.len() as f64 * key_gap_ms / 1000.0 + 0.8,
+ display_text: text.to_string(),
+ keys,
+ fade_duration_override: None,
+ position_override: None,
+ color_override: None,
+ background_color_override: None,
+ font_size_override: None,
+ uppercase_override: None,
+ }
+ }
+
+ fn uniforms(
+ constants: &RenderVideoConstants,
+ segments: &[KeyboardTrackSegment],
+ size: XY,
+ ) -> ProjectUniforms {
+ let project = ProjectConfiguration::default();
+ let cursor = CursorEvents::default();
+ let frames = frames(size);
+ let mut zoom = ZoomTransformTimeline::from_project(&project, &cursor, 60.0, size);
+ zoom.ensure_precomputed_until(60.0);
+ let mut uniforms = ProjectUniforms::new(
+ constants, &project, 0, FPS, size, &cursor, &frames, 60.0, &zoom,
+ );
+ uniforms.output_size = (size.x, size.y);
+ uniforms.project.keyboard = Some(KeyboardData {
+ settings: KeyboardSettings {
+ enabled: true,
+ ..Default::default()
+ },
+ });
+ uniforms.project.timeline = Some(TimelineConfiguration {
+ segments: vec![],
+ transitions: vec![],
+ zoom_segments: vec![],
+ scene_segments: vec![],
+ mask_segments: vec![],
+ text_segments: vec![],
+ caption_segments: vec![],
+ keyboard_segments: segments.to_vec(),
+ audio_segments: vec![],
+ style_segments: vec![],
+ image_segments: vec![],
+ camera3d_segments: vec![],
+ });
+ uniforms
+ }
+
+ fn frames(size: XY) -> DecodedSegmentFrames {
+ DecodedSegmentFrames {
+ screen_size: size,
+ screen_frame: None,
+ camera_frame: None,
+ segment_time: 0.0,
+ recording_time: 0.0,
+ segment_has_camera: false,
+ }
+ }
+
+ fn render_pixels(
+ constants: &RenderVideoConstants,
+ layer: &KeyboardLayer,
+ size: XY,
+ ) -> Vec {
+ let device = &constants.device;
+ let target = device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("Keyboard Test Target"),
+ size: wgpu::Extent3d {
+ width: size.x,
+ height: size.y,
+ depth_or_array_layers: 1,
+ },
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: wgpu::TextureFormat::Rgba8Unorm,
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
+ view_formats: &[],
+ });
+ let view = target.create_view(&Default::default());
+ let readback = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("Keyboard Test Readback"),
+ size: u64::from(size.x * size.y * 4),
+ usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
+ mapped_at_creation: false,
+ });
+ let mut encoder = device.create_command_encoder(&Default::default());
+ {
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("Keyboard Test Pass"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: &view,
+ resolve_target: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: None,
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ });
+ layer.render(&mut pass);
+ }
+ encoder.copy_texture_to_buffer(
+ wgpu::TexelCopyTextureInfo {
+ texture: &target,
+ mip_level: 0,
+ origin: wgpu::Origin3d::ZERO,
+ aspect: wgpu::TextureAspect::All,
+ },
+ wgpu::TexelCopyBufferInfo {
+ buffer: &readback,
+ layout: wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(size.x * 4),
+ rows_per_image: Some(size.y),
+ },
+ },
+ wgpu::Extent3d {
+ width: size.x,
+ height: size.y,
+ depth_or_array_layers: 1,
+ },
+ );
+ constants.queue.submit([encoder.finish()]);
+ readback.slice(..).map_async(wgpu::MapMode::Read, |_| {});
+ device.poll(wgpu::PollType::Wait).unwrap();
+ let pixels = readback.slice(..).get_mapped_range().to_vec();
+ readback.unmap();
+ pixels
+ }
+
+ #[test]
+ #[ignore = "requires a working wgpu adapter"]
+ fn every_frame_of_a_typed_segment_renders_like_a_fresh_layer() {
+ let size = XY::new(512, 288);
+ let constants = constants(size);
+ let segments = [typed_segment("typed", 0.2, "git push", 90.0)];
+ let mut uniforms = uniforms(&constants, &segments, size);
+ let frames = frames(size);
+ let mut reused = KeyboardLayer::new(&constants.device, &constants.queue);
+ let mut drawn_frames = 0;
+ for frame in 0..((segments[0].end + 0.2) * FPS as f64) as u32 {
+ uniforms.frame_number = frame;
+ reused.prepare(&uniforms, &frames, size, &constants, None);
+ let mut fresh = KeyboardLayer::new(&constants.device, &constants.queue);
+ fresh.prepare(&uniforms, &frames, size, &constants, None);
+ assert_eq!(reused.has_content(), fresh.has_content(), "frame {frame}");
+ let pixels = render_pixels(&constants, &reused, size);
+ assert_eq!(
+ pixels,
+ render_pixels(&constants, &fresh, size),
+ "frame {frame}"
+ );
+ drawn_frames += usize::from(pixels.chunks_exact(4).any(|pixel| pixel[3] != 0));
+ }
+ assert!(drawn_frames > 40);
+ }
+
+ #[test]
+ #[ignore = "benchmark; requires a working wgpu adapter"]
+ fn benchmark_keyboard_layer_prepare() {
+ let size = XY::new(1920, 1080);
+ let constants = constants(size);
+ let mut segments = Vec::new();
+ let mut start = 0.5;
+ for (index, text) in ["cargo test --release", "git status", "npm run dev"]
+ .iter()
+ .enumerate()
+ {
+ let segment = typed_segment(&format!("typed-{index}"), start, text, 120.0);
+ start = segment.end + 0.5;
+ segments.push(segment);
+ }
+ let mut uniforms = uniforms(&constants, &segments, size);
+ let frames = frames(size);
+ let total_frames = (start * FPS as f64) as u32;
+ let mut layer = KeyboardLayer::new(&constants.device, &constants.queue);
+
+ let mut samples = Vec::new();
+ for pass in 0..4 {
+ for frame in 0..total_frames {
+ uniforms.frame_number = frame;
+ let started = std::time::Instant::now();
+ layer.prepare(&uniforms, &frames, size, &constants, None);
+ let elapsed = started.elapsed().as_secs_f64() * 1e6;
+ if pass > 0 && layer.has_content() {
+ samples.push(elapsed);
+ }
+ }
+ }
+ constants.device.poll(wgpu::PollType::Wait).unwrap();
+ samples.sort_by(f64::total_cmp);
+ println!(
+ "keyboard layer prepare ({} visible frames): p50 {:.1} us, p95 {:.1} us, mean {:.1} us",
+ samples.len(),
+ samples[samples.len() / 2],
+ samples[samples.len() * 95 / 100],
+ samples.iter().sum::() / samples.len() as f64,
+ );
+ }
+}
From 4577e9d359d4eafe402f72cc4962ebc61952e0e2 Mon Sep 17 00:00:00 2001
From: Scott Newton
Date: Tue, 13 Oct 2026 14:45:21 -0700
Subject: [PATCH 25/74] perf(rendering): reshape captions only when their text
or style changes
---
crates/rendering/src/layers/captions.rs | 378 +++++++++++++++++++++++-
1 file changed, 363 insertions(+), 15 deletions(-)
diff --git a/crates/rendering/src/layers/captions.rs b/crates/rendering/src/layers/captions.rs
index 0c47bddd912..798d72fb858 100644
--- a/crates/rendering/src/layers/captions.rs
+++ b/crates/rendering/src/layers/captions.rs
@@ -261,6 +261,16 @@ fn calculate_word_highlight(
0.0
}
+#[derive(PartialEq)]
+struct ShapedCaptionText {
+ text: String,
+ font_size: u32,
+ wrap_width: u32,
+ family: Family<'static>,
+ weight: Weight,
+ color: Color,
+}
+
pub struct CaptionsLayer {
_settings_buffer: wgpu::Buffer,
font_system: FontSystem,
@@ -268,6 +278,7 @@ pub struct CaptionsLayer {
text_atlas: TextAtlas,
text_renderer: TextRenderer,
text_buffer: Buffer,
+ shaped: Option,
current_text: Option,
current_segment_start: f32,
current_segment_end: f32,
@@ -412,6 +423,7 @@ impl CaptionsLayer {
text_atlas,
text_renderer,
text_buffer,
+ shaped: None,
current_text: None,
current_segment_start: 0.0,
current_segment_end: 0.0,
@@ -605,12 +617,13 @@ impl CaptionsLayer {
.clamp(0.0, 1.0);
let font_size = caption_data.settings.size as f32 * (height as f32 / 1080.0);
- let metrics = Metrics::new(font_size, font_size * 1.2);
-
- let mut updated_buffer = Buffer::new(&mut self.font_system, metrics);
let wrap_width = (width as f32 - margin * 2.0).max(font_size);
- updated_buffer.set_size(&mut self.font_system, Some(wrap_width), None);
- updated_buffer.set_wrap(&mut self.font_system, glyphon::Wrap::None);
+ let new_buffer = |font_system: &mut FontSystem| {
+ let mut buffer = Buffer::new(font_system, Metrics::new(font_size, font_size * 1.2));
+ buffer.set_size(font_system, Some(wrap_width), None);
+ buffer.set_wrap(font_system, glyphon::Wrap::None);
+ buffer
+ };
let font_family = match caption_data.settings.font.as_str() {
"System Serif" => Family::Serif,
@@ -708,13 +721,16 @@ impl CaptionsLayer {
));
}
- updated_buffer.set_rich_text(
+ let mut buffer = new_buffer(&mut self.font_system);
+ buffer.set_rich_text(
&mut self.font_system,
rich_text,
&Attrs::new().family(font_family).weight(weight),
Shaping::Advanced,
None,
);
+ self.text_buffer = buffer;
+ self.shaped = None;
} else {
let color = Color::rgba(
(base_color[0] * 255.0) as u8,
@@ -722,19 +738,31 @@ impl CaptionsLayer {
(base_color[2] * 255.0) as u8,
(highlight_alpha * 255.0) as u8,
);
- let attrs = Attrs::new().family(font_family).weight(weight).color(color);
- updated_buffer.set_text(
- &mut self.font_system,
- caption_text.as_str(),
- &attrs,
- Shaping::Advanced,
- );
+ let shaped = ShapedCaptionText {
+ text: caption_text,
+ font_size: font_size.to_bits(),
+ wrap_width: wrap_width.to_bits(),
+ family: font_family,
+ weight,
+ color,
+ };
+ if self.shaped.as_ref() != Some(&shaped) {
+ let mut buffer = new_buffer(&mut self.font_system);
+ buffer.set_text(
+ &mut self.font_system,
+ &shaped.text,
+ &Attrs::new().family(font_family).weight(weight).color(color),
+ Shaping::Advanced,
+ );
+ self.text_buffer = buffer;
+ self.shaped = Some(shaped);
+ }
}
let mut layout_width: f32 = 0.0;
let mut layout_height: f32 = 0.0;
let mut highlight_extent: Option<(f32, f32, f32, f32)> = None;
- for run in LayoutRunIter::new(&updated_buffer) {
+ for run in LayoutRunIter::new(&self.text_buffer) {
layout_width = layout_width.max(run.line_w);
layout_height = layout_height.max(run.line_top + run.line_height);
@@ -844,7 +872,6 @@ impl CaptionsLayer {
bottom: (text_top + draw_text_height + 2.0).ceil() as i32,
};
- self.text_buffer = updated_buffer;
self.viewport.update(queue, Resolution { width, height });
let mut text_areas = Vec::new();
@@ -1260,3 +1287,324 @@ mod tests {
assert_eq!(word_byte_range("missing", &words, 0, false), None);
}
}
+
+#[cfg(test)]
+mod gpu_tests {
+ use cap_project::{
+ CaptionSettings, CaptionTrackSegment, CaptionWord, CaptionsData, CursorEvents,
+ ProjectConfiguration, RecordingMeta, RecordingMetaInner, StudioRecordingMeta,
+ TimelineConfiguration, XY,
+ };
+
+ use super::CaptionsLayer;
+ use crate::{
+ DecodedSegmentFrames, ProjectUniforms, RenderOptions, RenderVideoConstants,
+ ZoomTransformTimeline,
+ };
+
+ const FPS: u32 = 60;
+
+ fn constants(size: XY