diff --git a/apps/media-server/src/__tests__/lib/ffmpeg-edit.test.ts b/apps/media-server/src/__tests__/lib/ffmpeg-edit.test.ts new file mode 100644 index 00000000000..8a3dbc30856 --- /dev/null +++ b/apps/media-server/src/__tests__/lib/ffmpeg-edit.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import { + buildStreamCopySegmentArgs, + buildTranscodeSegmentArgs, + normalizeEditRanges, +} from "../../lib/ffmpeg-edit"; + +describe("ffmpeg edit helpers", () => { + test("normalizes edit ranges", () => { + expect( + normalizeEditRanges( + [ + { start: 3, end: 5 }, + { start: -1, end: 0.01 }, + { start: 8, end: 12 }, + ], + 10, + ), + ).toEqual([ + { start: 3, end: 5 }, + { start: 8, end: 10 }, + ]); + }); + + test("builds stream-copy segment args", () => { + const args = buildStreamCopySegmentArgs( + "/input.mp4", + { + start: 1, + end: 3.25, + }, + "/segment.mp4", + ); + + expect(args).toContain("copy"); + expect(args).toContain("-avoid_negative_ts"); + expect(args).toContain("2.250"); + }); + + test("builds no-audio transcode args", () => { + const args = buildTranscodeSegmentArgs( + "/input.mp4", + { start: 0, end: 1 }, + "/segment.mp4", + false, + ); + + expect(args).toContain("libx264"); + expect(args).toContain( + "[0:v:0]fps=30,trim=start=0.000:end=1.000,setpts=PTS-STARTPTS[v]", + ); + expect(args).toContain("-an"); + expect(args).not.toContain("0:a:0?"); + }); + + test("builds audio transcode args", () => { + const args = buildTranscodeSegmentArgs( + "/input.mp4", + { start: 0, end: 1 }, + "/segment.mp4", + true, + ); + + expect(args).toContain("aac"); + expect(args).toContain( + "[0:v:0]fps=30,trim=start=0.000:end=1.000,setpts=PTS-STARTPTS[v];[0:a:0]atrim=start=0.000:end=1.000,asetpts=PTS-STARTPTS[a]", + ); + expect(args).toContain("[a]"); + }); +}); diff --git a/apps/media-server/src/__tests__/routes/video.test.ts b/apps/media-server/src/__tests__/routes/video.test.ts index a5e9a999a62..a04723230ba 100644 --- a/apps/media-server/src/__tests__/routes/video.test.ts +++ b/apps/media-server/src/__tests__/routes/video.test.ts @@ -465,6 +465,113 @@ describe("POST /video/process", () => { }); }); +describe("POST /video/edit", () => { + beforeEach(() => { + mock.restore(); + }); + + test("returns 401 without media server secret", async () => { + const response = await app.fetch( + unauthenticatedVideoPostRequest("/video/edit", { + videoId: "test-id", + userId: "user-id", + sourceUrl: "https://example.com/source.mp4", + outputPresignedUrl: "https://s3.example.com/output", + keepRanges: [{ start: 0, end: 1 }], + }), + ); + + expect(response.status).toBe(401); + }); + + test("returns 400 for invalid JSON", async () => { + const response = await app.fetch( + new Request("http://localhost/video/edit", { + method: "POST", + headers: AUTH_HEADERS, + body: "{", + }), + ); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.code).toBe("INVALID_REQUEST"); + }); + + test("returns 400 for missing keep ranges", async () => { + const response = await app.fetch( + videoPostRequest("/video/edit", { + videoId: "test-id", + userId: "user-id", + sourceUrl: "https://example.com/source.mp4", + outputPresignedUrl: "https://s3.example.com/output", + }), + ); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.code).toBe("INVALID_REQUEST"); + }); + + test("returns 400 for invalid ranges", async () => { + const response = await app.fetch( + videoPostRequest("/video/edit", { + videoId: "test-id", + userId: "user-id", + sourceUrl: "https://example.com/source.mp4", + outputPresignedUrl: "https://s3.example.com/output", + keepRanges: [{ start: 3, end: 1 }], + }), + ); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.code).toBe("INVALID_REQUEST"); + }); + + test("returns jobId when edit starts successfully", async () => { + mock.module("../../lib/job-manager", () => ({ + canAcceptNewVideoProcess: () => true, + getActiveVideoProcessCount: () => 0, + getMaxConcurrentVideoProcesses: () => 3, + getSystemResources: jobManager.getSystemResources, + getAllJobs: () => [], + generateJobId: () => "edit-job-id", + createJob: () => ({ + jobId: "edit-job-id", + videoId: "test-id", + userId: "user-id", + phase: "queued", + progress: 0, + createdAt: new Date(), + updatedAt: new Date(), + }), + getJob: () => null, + updateJob: () => null, + deleteJob: () => {}, + sendWebhook: async () => {}, + getJobProgress: jobManager.getJobProgress, + })); + + const { default: appWithMock } = await import("../../app"); + + const response = await appWithMock.fetch( + videoPostRequest("/video/edit", { + videoId: "test-id", + userId: "user-id", + sourceUrl: "https://example.com/source.mp4", + outputPresignedUrl: "https://s3.example.com/output", + keepRanges: [{ start: 0, end: 1 }], + }), + ); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.jobId).toBe("edit-job-id"); + expect(data.status).toBe("queued"); + }); +}); + describe("GET /video/process/:jobId/status", () => { beforeEach(() => { mock.restore(); diff --git a/apps/media-server/src/app.ts b/apps/media-server/src/app.ts index f4092160811..e50979955ad 100644 --- a/apps/media-server/src/app.ts +++ b/apps/media-server/src/app.ts @@ -27,6 +27,7 @@ app.get("/", (c) => { "/video/thumbnail", "/video/convert", "/video/process", + "/video/edit", "/video/process/:jobId/status", "/video/process/:jobId/cancel", "/video/cleanup", diff --git a/apps/media-server/src/lib/ffmpeg-edit.ts b/apps/media-server/src/lib/ffmpeg-edit.ts new file mode 100644 index 00000000000..8d55bea3919 --- /dev/null +++ b/apps/media-server/src/lib/ffmpeg-edit.ts @@ -0,0 +1,388 @@ +import { writeFile } from "node:fs/promises"; +import { file, spawn } from "bun"; +import type { VideoMetadata } from "./job-manager"; +import { registerSubprocess, terminateProcess } from "./subprocess"; +import { createTempFile, type TempFileHandle } from "./temp-files"; + +export type EditRange = { + start: number; + end: number; +}; + +type ProgressCallback = (progress: number, message: string) => void; + +type RenderEditedVideoInput = { + inputPath: string; + keepRanges: EditRange[]; + metadata: VideoMetadata; + onProgress?: ProgressCallback; + abortSignal?: AbortSignal; +}; + +const MIN_RANGE_DURATION = 0.05; +const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; +const MAX_TIMEOUT_MS = 60 * 60 * 1000; +const DEFAULT_OUTPUT_FPS = 30; + +function roundTime(value: number) { + return Math.round(value * 1000) / 1000; +} + +function getRangeDuration(range: EditRange) { + return Math.max(0, range.end - range.start); +} + +function getTotalRangeDuration(ranges: EditRange[]) { + return ranges.reduce((total, range) => total + getRangeDuration(range), 0); +} + +function getTimeoutMs(ranges: EditRange[]) { + const durationMs = getTotalRangeDuration(ranges) * 20_000; + return Math.min(MAX_TIMEOUT_MS, Math.max(DEFAULT_TIMEOUT_MS, durationMs)); +} + +export function normalizeEditRanges( + ranges: EditRange[], + sourceDuration: number, +) { + const duration = + Number.isFinite(sourceDuration) && sourceDuration > 0 + ? roundTime(sourceDuration) + : 0; + + const sortedRanges = ranges + .map((range) => { + const start = Number.isFinite(range.start) ? range.start : 0; + const end = Number.isFinite(range.end) ? range.end : 0; + return { + start: roundTime(Math.min(Math.max(0, start), duration)), + end: roundTime(Math.min(Math.max(0, end), duration)), + }; + }) + .filter((range) => range.end - range.start >= MIN_RANGE_DURATION) + .sort((a, b) => a.start - b.start || a.end - b.end); + + const mergedRanges: EditRange[] = []; + for (const range of sortedRanges) { + const previous = mergedRanges.at(-1); + if (previous && range.start <= previous.end + MIN_RANGE_DURATION) { + previous.end = Math.max(previous.end, range.end); + continue; + } + mergedRanges.push({ ...range }); + } + + return mergedRanges; +} + +function formatTime(value: number) { + return roundTime(value).toFixed(3); +} + +function getOutputFps(fps: number | undefined) { + return Number.isFinite(fps) && fps && fps > 0 + ? Math.min(120, Math.max(1, Math.round(fps * 100) / 100)) + : DEFAULT_OUTPUT_FPS; +} + +export function buildStreamCopySegmentArgs( + inputPath: string, + range: EditRange, + outputPath: string, +) { + return [ + "ffmpeg", + "-hide_banner", + "-y", + "-ss", + formatTime(range.start), + "-i", + inputPath, + "-t", + formatTime(getRangeDuration(range)), + "-map", + "0", + "-c", + "copy", + "-avoid_negative_ts", + "make_zero", + outputPath, + ]; +} + +export function buildTranscodeSegmentArgs( + inputPath: string, + range: EditRange, + outputPath: string, + hasAudio: boolean, + fps = DEFAULT_OUTPUT_FPS, +) { + const videoFilter = `fps=${getOutputFps(fps)},trim=start=${formatTime(range.start)}:end=${formatTime(range.end)},setpts=PTS-STARTPTS`; + const filterComplex = hasAudio + ? `[0:v:0]${videoFilter}[v];[0:a:0]atrim=start=${formatTime(range.start)}:end=${formatTime(range.end)},asetpts=PTS-STARTPTS[a]` + : `[0:v:0]${videoFilter}[v]`; + + return [ + "ffmpeg", + "-hide_banner", + "-y", + "-i", + inputPath, + "-filter_complex", + filterComplex, + "-map", + "[v]", + "-c:v", + "libx264", + "-preset", + "fast", + "-crf", + "18", + "-pix_fmt", + "yuv420p", + ...(hasAudio ? ["-map", "[a]", "-c:a", "aac", "-b:a", "160k"] : ["-an"]), + "-movflags", + "+faststart", + outputPath, + ]; +} + +function buildConcatArgs(listPath: string, outputPath: string) { + return [ + "ffmpeg", + "-hide_banner", + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + listPath, + "-map", + "0", + "-c", + "copy", + "-movflags", + "+faststart", + outputPath, + ]; +} + +async function drainStream(stream: ReadableStream | null) { + if (!stream) return; + const reader = stream.getReader(); + try { + while (true) { + const { done } = await reader.read(); + if (done) break; + } + } finally { + reader.releaseLock(); + } +} + +async function readStream(stream: ReadableStream | null) { + if (!stream) return ""; + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + return new TextDecoder().decode(Buffer.concat(chunks)); +} + +async function withTimeout( + promise: Promise, + timeoutMs: number, + cleanup: () => Promise, +) { + let timeoutId: ReturnType | undefined; + let cleanupPromise: Promise | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + cleanupPromise = cleanup(); + reject(new Error(`Operation timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + + try { + const result = await Promise.race([promise, timeoutPromise]); + if (timeoutId) clearTimeout(timeoutId); + return result; + } catch (error) { + if (cleanupPromise) { + await cleanupPromise; + } + if (timeoutId) clearTimeout(timeoutId); + throw error; + } +} + +async function runFfmpegCommand( + args: string[], + timeoutMs: number, + abortSignal?: AbortSignal, +) { + const proc = registerSubprocess( + spawn({ + cmd: args, + stdout: "pipe", + stderr: "pipe", + }), + ); + + let abortCleanup: (() => void) | undefined; + if (abortSignal) { + abortCleanup = () => { + void terminateProcess(proc); + }; + abortSignal.addEventListener("abort", abortCleanup, { once: true }); + } + + try { + await withTimeout( + (async () => { + const [stderrText, exitCode] = await Promise.all([ + readStream(proc.stderr as ReadableStream), + drainStream(proc.stdout as ReadableStream).then( + () => proc.exited, + ), + ]); + + if (exitCode !== 0) { + throw new Error( + `FFmpeg exited with code ${exitCode}. Last stderr: ${stderrText.slice(-2000)}`, + ); + } + })(), + timeoutMs, + () => terminateProcess(proc), + ); + } finally { + if (abortCleanup) { + abortSignal?.removeEventListener("abort", abortCleanup); + } + await terminateProcess(proc); + } +} + +function concatFileLine(path: string) { + return `file '${path.replaceAll("'", "'\\''")}'`; +} + +async function concatSegments( + segmentFiles: TempFileHandle[], + timeoutMs: number, + abortSignal?: AbortSignal, +) { + const concatList = await createTempFile(".txt"); + const outputFile = await createTempFile(".mp4"); + + try { + await writeFile( + concatList.path, + `${segmentFiles.map((segment) => concatFileLine(segment.path)).join("\n")}\n`, + ); + await runFfmpegCommand( + buildConcatArgs(concatList.path, outputFile.path), + timeoutMs, + abortSignal, + ); + + const outputSize = await file(outputFile.path).size; + if (outputSize === 0) { + throw new Error("FFmpeg produced empty edited output"); + } + + return outputFile; + } catch (error) { + await outputFile.cleanup(); + throw error; + } finally { + await concatList.cleanup(); + } +} + +async function renderSegments({ + keepRanges, + timeoutMs, + buildArgs, + onProgress, + abortSignal, + progressStart, + progressEnd, +}: { + keepRanges: EditRange[]; + timeoutMs: number; + buildArgs: (range: EditRange, outputPath: string) => string[]; + onProgress?: ProgressCallback; + abortSignal?: AbortSignal; + progressStart: number; + progressEnd: number; +}) { + const segmentFiles: TempFileHandle[] = []; + + try { + for (const [index, range] of keepRanges.entries()) { + const segmentFile = await createTempFile(".mp4"); + segmentFiles.push(segmentFile); + await runFfmpegCommand( + buildArgs(range, segmentFile.path), + timeoutMs, + abortSignal, + ); + const progress = + progressStart + + ((index + 1) / keepRanges.length) * (progressEnd - progressStart); + onProgress?.(progress, "Preparing edit..."); + } + + const outputFile = await concatSegments( + segmentFiles, + timeoutMs, + abortSignal, + ); + onProgress?.(progressEnd, "Edit prepared"); + return outputFile; + } finally { + await Promise.all(segmentFiles.map((segment) => segment.cleanup())); + } +} + +export async function renderEditedVideo({ + inputPath, + keepRanges, + metadata, + onProgress, + abortSignal, +}: RenderEditedVideoInput) { + const normalizedRanges = normalizeEditRanges(keepRanges, metadata.duration); + if (normalizedRanges.length === 0) { + throw new Error("Edit must keep at least one range"); + } + + const timeoutMs = getTimeoutMs(normalizedRanges); + + return await renderSegments({ + keepRanges: normalizedRanges, + timeoutMs, + buildArgs: (range, outputPath) => + buildTranscodeSegmentArgs( + inputPath, + range, + outputPath, + Boolean(metadata.audioCodec), + metadata.fps, + ), + onProgress, + abortSignal, + progressStart: 5, + progressEnd: 75, + }); +} diff --git a/apps/media-server/src/routes/video.ts b/apps/media-server/src/routes/video.ts index 3ec464600d2..fb44df309e5 100644 --- a/apps/media-server/src/routes/video.ts +++ b/apps/media-server/src/routes/video.ts @@ -2,6 +2,7 @@ import { file } from "bun"; import { Hono } from "hono"; import { z } from "zod"; import { validateMediaServerSecret } from "../lib/auth"; +import { renderEditedVideo } from "../lib/ffmpeg-edit"; import type { ResilientInputFlags } from "../lib/ffmpeg-video"; import { downloadVideoToTemp, @@ -75,6 +76,27 @@ const processSchema = z.object({ remuxOnly: z.boolean().optional(), }); +const editRangeSchema = z + .object({ + start: z.number().min(0), + end: z.number().min(0), + }) + .refine((range) => range.end > range.start, { + message: "Range end must be greater than start", + }); + +const editSchema = z.object({ + videoId: z.string(), + userId: z.string(), + sourceUrl: z.string().url(), + outputPresignedUrl: z.string().url(), + thumbnailPresignedUrl: z.string().url().optional(), + previewGifPresignedUrl: z.string().url().optional(), + webhookUrl: z.string().url().optional(), + webhookSecret: z.string().optional(), + keepRanges: z.array(editRangeSchema).min(1), +}); + function getInstanceId(): string { return process.env.HOSTNAME || `pid-${process.pid}`; } @@ -507,6 +529,105 @@ video.post("/process", async (c) => { }); }); +video.post("/edit", async (c) => { + if (!validateMediaServerSecret(c)) { + return c.json({ error: "Unauthorized" }, 401); + } + + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid request", code: "INVALID_REQUEST" }, 400); + } + const result = editSchema.safeParse(body); + + if (!result.success) { + return c.json( + { + error: "Invalid request", + code: "INVALID_REQUEST", + details: result.error.message, + }, + 400, + ); + } + + if (!canAcceptNewVideoProcess()) { + const activeVideoProcesses = getActiveVideoProcessCount(); + const resources = getSystemResources(); + const jobs = getAllJobs(); + return c.json( + { + error: "Server is busy", + code: "SERVER_BUSY", + details: resources.throttleReason + ? `Throttled: ${resources.throttleReason} (${activeVideoProcesses}/${resources.effectiveMax} active)` + : `Too many concurrent video processing jobs (${activeVideoProcesses}/${resources.effectiveMax}), please retry later`, + instanceId: getInstanceId(), + pid: process.pid, + activeVideoProcesses, + maxConcurrentVideoProcesses: getMaxConcurrentVideoProcesses(), + effectiveMaxVideoProcesses: resources.effectiveMax, + resources, + jobCount: jobs.length, + jobs: jobs.map((job) => ({ + jobId: job.jobId, + videoId: job.videoId, + phase: job.phase, + progress: job.progress, + updatedAt: job.updatedAt, + })), + }, + 503, + ); + } + + const { + videoId, + userId, + sourceUrl, + outputPresignedUrl, + thumbnailPresignedUrl, + previewGifPresignedUrl, + webhookUrl, + webhookSecret, + } = result.data; + + const jobId = generateJobId(); + const job = createJob(jobId, videoId, userId, webhookUrl, webhookSecret); + + editVideoAsync( + job.jobId, + sourceUrl, + outputPresignedUrl, + thumbnailPresignedUrl, + previewGifPresignedUrl, + result.data, + ).catch((err) => { + console.error(`[video/edit] Async edit error for job ${jobId}:`, err); + const currentJob = getJob(jobId); + if ( + currentJob && + currentJob.phase !== "error" && + currentJob.phase !== "complete" && + currentJob.phase !== "cancelled" + ) { + updateJob(jobId, { + phase: "error", + error: err instanceof Error ? err.message : String(err), + message: "Edit failed (unhandled)", + }); + } + }); + + return c.json({ + jobId, + status: "queued", + message: "Video edit started", + }); +}); + function isWebmInput(extension: string | undefined): boolean { if (!extension) return false; const normalized = extension.toLowerCase().replace(/^\./, ""); @@ -612,7 +733,12 @@ async function processWithResilientRetry( updateJob(jobId, { progress: scaledProgress, message }); const currentJob = getJob(jobId); if (currentJob) { - sendWebhook(currentJob); + void sendWebhook(currentJob).catch((error) => + console.warn( + `[video/process] Failed to send webhook update for job ${jobId}:`, + error, + ), + ); } }; @@ -732,6 +858,170 @@ async function generateAndUploadPreviewGif( } } +async function editVideoAsync( + jobId: string, + sourceUrl: string, + outputPresignedUrl: string, + thumbnailPresignedUrl: string | undefined, + previewGifPresignedUrl: string | undefined, + options: z.infer, +): Promise { + if (!getJob(jobId)) { + return; + } + + const abortController = new AbortController(); + updateJob(jobId, { abortController }); + + try { + updateJob(jobId, { + phase: "downloading", + progress: 0, + message: "Downloading source video...", + }); + const downloadingJob = getJob(jobId); + if (downloadingJob) { + await sendWebhook(downloadingJob); + } + + const inputTempFile = await downloadVideoToTemp( + sourceUrl, + ".mp4", + abortController.signal, + ); + updateJob(jobId, { inputTempFile }); + + updateJob(jobId, { + phase: "probing", + progress: 5, + message: "Analyzing source video...", + }); + const probingJob = getJob(jobId); + if (probingJob) { + await sendWebhook(probingJob); + } + + const sourceMetadata = await probeVideoFile(inputTempFile.path); + + updateJob(jobId, { + phase: "processing", + progress: 10, + message: "Applying edit...", + }); + const processingJob = getJob(jobId); + if (processingJob) { + await sendWebhook(processingJob); + } + + const outputTempFile = await withJobHeartbeat(jobId, () => + renderEditedVideo({ + inputPath: inputTempFile.path, + keepRanges: options.keepRanges, + metadata: sourceMetadata, + abortSignal: abortController.signal, + onProgress: (progress, message) => { + updateJob(jobId, { + progress: Math.min(80, 10 + progress * 0.9), + message, + }); + const currentJob = getJob(jobId); + if (currentJob) { + void sendWebhook(currentJob).catch((error) => + console.warn( + `[video/edit] Failed to send webhook update for job ${jobId}:`, + error, + ), + ); + } + }, + }), + ); + updateJob(jobId, { outputTempFile }); + + const outputMetadata = await probeVideoFile(outputTempFile.path); + updateJob(jobId, { metadata: outputMetadata }); + + updateJob(jobId, { + phase: "uploading", + progress: 80, + message: "Uploading edited video...", + }); + const uploadingJob = getJob(jobId); + if (uploadingJob) { + await sendWebhook(uploadingJob); + } + + await uploadFileToS3(outputTempFile.path, outputPresignedUrl, "video/mp4"); + + if (thumbnailPresignedUrl || previewGifPresignedUrl) { + updateJob(jobId, { + phase: "generating_thumbnail", + progress: 90, + message: "Generating preview assets...", + }); + const thumbnailJob = getJob(jobId); + if (thumbnailJob) { + await sendWebhook(thumbnailJob); + } + } + + if (thumbnailPresignedUrl) { + const thumbnailData = await generateThumbnail( + outputTempFile.path, + outputMetadata.duration, + ); + await uploadToS3(thumbnailData, thumbnailPresignedUrl, "image/jpeg"); + } + + await generateAndUploadPreviewGif( + outputTempFile.path, + outputMetadata.duration, + previewGifPresignedUrl, + abortController.signal, + "video/edit", + ); + + updateJob(jobId, { + phase: "complete", + progress: 100, + message: "Edit complete", + }); + const completedJob = getJob(jobId); + if (completedJob) { + await sendWebhook(completedJob); + } + + await inputTempFile.cleanup(); + await outputTempFile.cleanup(); + + setTimeout(() => deleteJob(jobId), 5 * 60 * 1000); + } catch (err) { + console.error(`[video/edit] Error editing job ${jobId}:`, err); + + const updatedJob = updateJob(jobId, { + phase: "error", + error: err instanceof Error ? err.message : String(err), + message: "Edit failed", + }); + + try { + if (updatedJob) { + await sendWebhook(updatedJob); + } + } finally { + const currentJob = getJob(jobId); + if (currentJob) { + await Promise.allSettled([ + currentJob.inputTempFile?.cleanup(), + currentJob.outputTempFile?.cleanup(), + ]); + } + + setTimeout(() => deleteJob(jobId), 5 * 60 * 1000); + } + } +} + async function processVideoAsync( jobId: string, videoUrl: string, diff --git a/apps/web/__tests__/unit/save-video-edits.test.ts b/apps/web/__tests__/unit/save-video-edits.test.ts new file mode 100644 index 00000000000..9e5cb5ad020 --- /dev/null +++ b/apps/web/__tests__/unit/save-video-edits.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getCurrentUserMock = vi.fn(); +const whereMock = vi.fn(); +const selectMock = vi.fn(() => ({ + from: vi.fn(() => ({ + where: whereMock, + })), +})); +const insertMock = vi.fn(); + +vi.mock("@cap/database", () => ({ + db: () => ({ + select: selectMock, + insert: insertMock, + }), +})); + +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: getCurrentUserMock, +})); + +vi.mock("@cap/utils", () => ({ + userIsPro: (user?: { isPro?: boolean } | null) => Boolean(user?.isPro), +})); + +vi.mock("@cap/web-backend", () => ({ + Storage: { + getAccessForVideo: vi.fn(), + }, +})); + +vi.mock("workflow/api", () => ({ + start: vi.fn(), +})); + +vi.mock("@/lib/server", () => ({ + runPromise: vi.fn(), +})); + +vi.mock("@/lib/video-storage", () => ({ + decodeStorageVideo: vi.fn(), +})); + +vi.mock("server-only", () => ({})); + +describe("saveVideoEdits", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("requires an owner session", async () => { + getCurrentUserMock.mockResolvedValueOnce(null); + const { saveVideoEdits } = await import("@/actions/videos/save-edits"); + + await expect( + saveVideoEdits("video-1" as never, { + version: 1, + sourceDuration: 10, + keepRanges: [{ start: 0, end: 10 }], + }), + ).rejects.toThrow("Unauthorized"); + + expect(selectMock).not.toHaveBeenCalled(); + }); + + it("rejects active processing rows before saving", async () => { + getCurrentUserMock.mockResolvedValueOnce({ id: "user-1", isPro: true }); + whereMock + .mockResolvedValueOnce([ + { + id: "video-1", + ownerId: "user-1", + duration: 10, + source: { type: "webMP4" }, + isScreenshot: false, + metadata: null, + }, + ]) + .mockResolvedValueOnce([{ phase: "processing" }]); + const { saveVideoEdits } = await import("@/actions/videos/save-edits"); + + await expect( + saveVideoEdits("video-1" as never, { + version: 1, + sourceDuration: 10, + keepRanges: [{ start: 0, end: 10 }], + }), + ).rejects.toThrow("Video is already uploading or processing"); + + expect(insertMock).not.toHaveBeenCalled(); + }); + + it("rejects failed edit rows before the workflow clears them", async () => { + getCurrentUserMock.mockResolvedValueOnce({ id: "user-1", isPro: true }); + whereMock + .mockResolvedValueOnce([ + { + id: "video-1", + ownerId: "user-1", + duration: 10, + source: { type: "webMP4" }, + isScreenshot: false, + metadata: null, + }, + ]) + .mockResolvedValueOnce([{ phase: "error" }]); + const { saveVideoEdits } = await import("@/actions/videos/save-edits"); + + await expect( + saveVideoEdits("video-1" as never, { + version: 1, + sourceDuration: 10, + keepRanges: [{ start: 0, end: 10 }], + }), + ).rejects.toThrow( + "Previous edit failed and is being cleaned up. Try again in a moment.", + ); + + expect(insertMock).not.toHaveBeenCalled(); + }); + + it("rejects completed edit rows before the workflow clears them", async () => { + getCurrentUserMock.mockResolvedValueOnce({ id: "user-1", isPro: true }); + whereMock + .mockResolvedValueOnce([ + { + id: "video-1", + ownerId: "user-1", + duration: 10, + source: { type: "webMP4" }, + isScreenshot: false, + metadata: null, + }, + ]) + .mockResolvedValueOnce([{ phase: "complete" }]); + const { saveVideoEdits } = await import("@/actions/videos/save-edits"); + + await expect( + saveVideoEdits("video-1" as never, { + version: 1, + sourceDuration: 10, + keepRanges: [{ start: 0, end: 10 }], + }), + ).rejects.toThrow("Previous edit is finishing up. Try again in a moment."); + + expect(insertMock).not.toHaveBeenCalled(); + }); + + it("requires Cap Pro before saving edits", async () => { + getCurrentUserMock.mockResolvedValueOnce({ id: "user-1", isPro: false }); + const { saveVideoEdits } = await import("@/actions/videos/save-edits"); + + await expect( + saveVideoEdits("video-1" as never, { + version: 1, + sourceDuration: 10, + keepRanges: [{ start: 0, end: 10 }], + }), + ).rejects.toThrow("Cap Pro is required to edit videos"); + + expect(selectMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/__tests__/unit/upload-progress-playback.test.ts b/apps/web/__tests__/unit/upload-progress-playback.test.ts index 649494b136e..6020ebf2457 100644 --- a/apps/web/__tests__/unit/upload-progress-playback.test.ts +++ b/apps/web/__tests__/unit/upload-progress-playback.test.ts @@ -117,7 +117,7 @@ describe("shouldDeferPlaybackSource", () => { ); }); - it("reloads playback when upload progress clears before media finishes loading", () => { + it("reloads playback when upload progress clears", () => { expect( shouldReloadPlaybackAfterUploadCompletes( { @@ -127,7 +127,6 @@ describe("shouldDeferPlaybackSource", () => { message: "Finishing video...", }, null, - false, ), ).toBe(true); expect( @@ -139,12 +138,9 @@ describe("shouldDeferPlaybackSource", () => { message: "Finishing video...", }, null, - true, ), - ).toBe(false); - expect(shouldReloadPlaybackAfterUploadCompletes(null, null, false)).toBe( - false, - ); + ).toBe(true); + expect(shouldReloadPlaybackAfterUploadCompletes(null, null)).toBe(false); }); it("detects processing that never actually started", () => { diff --git a/apps/web/__tests__/unit/video-edit-drafts.test.ts b/apps/web/__tests__/unit/video-edit-drafts.test.ts new file mode 100644 index 00000000000..b9d8eb8e10b --- /dev/null +++ b/apps/web/__tests__/unit/video-edit-drafts.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { + clearTimelineDraft, + getTimelineDraftKey, + parseTimelineDraft, + readTimelineDraft, + serializeTimelineDraft, + type TimelineDraftStorage, + writeTimelineDraft, +} from "@/lib/video-edit-drafts"; +import { + areTimelineStatesEquivalent, + createTimelineState, + splitTimelineAt, +} from "@/lib/video-edits"; + +class MemoryTimelineDraftStorage implements TimelineDraftStorage { + readonly values = new Map(); + + getItem(key: string) { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string) { + this.values.set(key, value); + } + + removeItem(key: string) { + this.values.delete(key); + } +} + +function createThrowingStorage(): TimelineDraftStorage { + return { + getItem() { + throw new Error("blocked"); + }, + setItem() { + throw new Error("blocked"); + }, + removeItem() { + throw new Error("blocked"); + }, + }; +} + +describe("video edit draft storage", () => { + it("creates stable per-video storage keys", () => { + expect(getTimelineDraftKey("video-1")).toBe( + "cap:edit-timeline-draft:video-1", + ); + }); + + it("serializes and parses a split-only draft", () => { + const state = splitTimelineAt(createTimelineState(10), 5); + const parsed = parseTimelineDraft(serializeTimelineDraft(10, state), 10); + + expect(parsed).not.toBeNull(); + if (!parsed) throw new Error("Expected parsed draft"); + expect(parsed.splitPoints).toEqual([5]); + expect(areTimelineStatesEquivalent(state, parsed)).toBe(true); + }); + + it("rejects invalid, stale, or mismatched drafts", () => { + const state = splitTimelineAt(createTimelineState(10), 5); + const raw = serializeTimelineDraft(10, state); + const parsed = JSON.parse(raw) as Record; + + expect(parseTimelineDraft("not json", 10)).toBeNull(); + expect( + parseTimelineDraft(JSON.stringify({ ...parsed, version: 0 }), 10), + ).toBeNull(); + expect(parseTimelineDraft(raw, 12)).toBeNull(); + expect( + parseTimelineDraft( + JSON.stringify({ + ...parsed, + state: { ...state, splitPoints: ["bad"] }, + }), + 10, + ), + ).toBeNull(); + }); + + it("reads, writes, and clears drafts through injected storage", () => { + const storage = new MemoryTimelineDraftStorage(); + const key = getTimelineDraftKey("video-1"); + const state = splitTimelineAt(createTimelineState(10), 4); + + writeTimelineDraft(storage, key, 10, state); + const restored = readTimelineDraft(storage, key, 10); + + expect(restored).not.toBeNull(); + if (!restored) throw new Error("Expected restored draft"); + expect(areTimelineStatesEquivalent(state, restored)).toBe(true); + + clearTimelineDraft(storage, key); + expect(readTimelineDraft(storage, key, 10)).toBeNull(); + }); + + it("treats storage failures as unavailable drafts", () => { + const storage = createThrowingStorage(); + const key = getTimelineDraftKey("video-1"); + const state = splitTimelineAt(createTimelineState(10), 4); + + expect(readTimelineDraft(storage, key, 10)).toBeNull(); + expect(() => writeTimelineDraft(storage, key, 10, state)).not.toThrow(); + expect(() => clearTimelineDraft(storage, key)).not.toThrow(); + }); +}); diff --git a/apps/web/__tests__/unit/video-edits.test.ts b/apps/web/__tests__/unit/video-edits.test.ts new file mode 100644 index 00000000000..eb52d2e25d3 --- /dev/null +++ b/apps/web/__tests__/unit/video-edits.test.ts @@ -0,0 +1,327 @@ +import { describe, expect, it } from "vitest"; +import { + areEditSpecsEquivalent, + areTimelineStatesEquivalent, + composeEditSpecs, + createIdentityEditSpec, + createTimelineHistory, + createTimelineState, + deleteSelectedTimelineSegment, + dragTimelineDisplaySplitPoint, + findNextPlayableTime, + getTimelineDisplayDuration, + getTimelineDisplaySegments, + getTimelineDisplaySplitPoints, + getTimelineKeepRanges, + getTimelineSegments, + mapOutputTimeToSourceTime, + mapSourceTimeToOutputTime, + mapTimelineDisplayTimeToSourceTime, + mapTimelineSourceTimeToDisplayTime, + normalizeKeepRanges, + pushTimelineHistory, + redoTimelineHistory, + remapCurrentOutputTimeThroughEdit, + removeSplitPoint, + removeTimelineDisplaySplitPoint, + selectTimelineSegment, + splitTimelineAt, + undoTimelineHistory, +} from "@/lib/video-edits"; + +describe("video edit specs", () => { + it("normalizes keep ranges", () => { + const spec = normalizeKeepRanges( + [ + { start: 8, end: 12 }, + { start: 2, end: 4 }, + { start: 3.5, end: 5 }, + { start: Number.NaN, end: 0.01 }, + ], + 10, + ); + + expect(spec).toEqual({ + version: 1, + sourceDuration: 10, + keepRanges: [ + { start: 2, end: 5 }, + { start: 8, end: 10 }, + ], + }); + }); + + it("maps source and output timestamps", () => { + const spec = normalizeKeepRanges( + [ + { start: 1, end: 3 }, + { start: 5, end: 8 }, + ], + 10, + ); + + expect(mapSourceTimeToOutputTime(2, spec)).toBe(1); + expect(mapSourceTimeToOutputTime(4, spec)).toBeNull(); + expect(mapOutputTimeToSourceTime(3, spec)).toBe(6); + }); + + it("composes repeated edits through the retained source", () => { + const previousSpec = normalizeKeepRanges( + [ + { start: 0, end: 4 }, + { start: 6, end: 10 }, + ], + 10, + ); + const nextOutputSpec = normalizeKeepRanges([{ start: 1, end: 6 }], 8); + const composed = composeEditSpecs(previousSpec, nextOutputSpec); + + expect(composed.keepRanges).toEqual([ + { start: 1, end: 4 }, + { start: 6, end: 8 }, + ]); + expect(remapCurrentOutputTimeThroughEdit(5, previousSpec, composed)).toBe( + 4, + ); + expect( + remapCurrentOutputTimeThroughEdit(7, previousSpec, composed), + ).toBeNull(); + }); + + it("creates an identity edit spec", () => { + expect(createIdentityEditSpec(4.2)).toEqual({ + version: 1, + sourceDuration: 4.2, + keepRanges: [{ start: 0, end: 4.2 }], + }); + }); + + it("detects normalized no-op edit specs", () => { + expect( + areEditSpecsEquivalent( + { + version: 1, + sourceDuration: 10, + keepRanges: [{ start: 0, end: 10 }], + }, + { + version: 1, + sourceDuration: 10, + keepRanges: [ + { start: 0, end: 4 }, + { start: 4, end: 10 }, + ], + }, + ), + ).toBe(true); + + expect( + areEditSpecsEquivalent( + createIdentityEditSpec(10), + normalizeKeepRanges([{ start: 1, end: 10 }], 10), + ), + ).toBe(false); + }); +}); + +describe("timeline editing", () => { + it("splits, selects, and deletes a segment", () => { + const splitState = splitTimelineAt(createTimelineState(10), 4); + const firstSegment = getTimelineSegments(splitState)[0]; + expect(firstSegment).toBeDefined(); + + const selected = selectTimelineSegment(splitState, firstSegment?.id ?? ""); + const deleted = deleteSelectedTimelineSegment(selected); + + expect(getTimelineKeepRanges(deleted)).toEqual([{ start: 4, end: 10 }]); + }); + + it("collapses deleted segments out of the displayed timeline", () => { + const splitState = splitTimelineAt( + splitTimelineAt(createTimelineState(10), 3), + 7, + ); + const middleSegment = getTimelineSegments(splitState)[1]; + expect(middleSegment).toBeDefined(); + if (!middleSegment) throw new Error("Expected middle segment"); + + const deleted = deleteSelectedTimelineSegment( + selectTimelineSegment(splitState, middleSegment.id), + ); + + expect(getTimelineKeepRanges(deleted)).toEqual([ + { start: 0, end: 3 }, + { start: 7, end: 10 }, + ]); + expect(getTimelineDisplayDuration(deleted)).toBe(6); + expect( + getTimelineDisplaySegments(deleted).map( + ({ start, end, displayStart, displayEnd }) => ({ + start, + end, + displayStart, + displayEnd, + }), + ), + ).toEqual([ + { start: 0, end: 3, displayStart: 0, displayEnd: 3 }, + { start: 7, end: 10, displayStart: 3, displayEnd: 6 }, + ]); + expect( + getTimelineDisplaySplitPoints(deleted).map( + ({ time, sourceTimes, splitIndices }) => ({ + time, + sourceTimes, + splitIndices, + }), + ), + ).toEqual([{ time: 3, sourceTimes: [3, 7], splitIndices: [0, 1] }]); + expect(mapTimelineSourceTimeToDisplayTime(deleted, 7)).toBe(3); + expect(mapTimelineDisplayTimeToSourceTime(deleted, 3)).toBe(3); + }); + + it("removes a collapsed display split by restoring the deleted segment", () => { + const splitState = splitTimelineAt( + splitTimelineAt(createTimelineState(10), 3), + 7, + ); + const middleSegment = getTimelineSegments(splitState)[1]; + expect(middleSegment).toBeDefined(); + if (!middleSegment) throw new Error("Expected middle segment"); + + const deleted = deleteSelectedTimelineSegment( + selectTimelineSegment(splitState, middleSegment.id), + ); + const restored = removeTimelineDisplaySplitPoint(deleted, 0); + + expect(getTimelineKeepRanges(restored)).toEqual([{ start: 0, end: 10 }]); + expect(getTimelineDisplaySplitPoints(restored)).toEqual([]); + }); + + it("drags a collapsed display split to shrink either adjacent clip", () => { + const splitState = splitTimelineAt( + splitTimelineAt(createTimelineState(10), 3), + 7, + ); + const middleSegment = getTimelineSegments(splitState)[1]; + expect(middleSegment).toBeDefined(); + if (!middleSegment) throw new Error("Expected middle segment"); + + const deleted = deleteSelectedTimelineSegment( + selectTimelineSegment(splitState, middleSegment.id), + ); + const leftShrunk = dragTimelineDisplaySplitPoint(deleted, 0, "center", 2); + const rightShrunk = dragTimelineDisplaySplitPoint(deleted, 0, "center", 8); + + expect(getTimelineKeepRanges(leftShrunk)).toEqual([ + { start: 0, end: 2 }, + { start: 7, end: 10 }, + ]); + expect(getTimelineDisplaySplitPoints(leftShrunk)[0]?.sourceTimes).toEqual([ + 2, 7, + ]); + expect(getTimelineKeepRanges(rightShrunk)).toEqual([ + { start: 0, end: 3 }, + { start: 8, end: 10 }, + ]); + expect(getTimelineDisplaySplitPoints(rightShrunk)[0]?.sourceTimes).toEqual([ + 3, 8, + ]); + }); + + it("restores a deleted segment when removing its split boundary", () => { + const splitState = splitTimelineAt( + splitTimelineAt(createTimelineState(10), 3), + 7, + ); + const middleSegment = getTimelineSegments(splitState)[1]; + expect(middleSegment).toBeDefined(); + if (!middleSegment) throw new Error("Expected middle segment"); + + const deleted = deleteSelectedTimelineSegment( + selectTimelineSegment(splitState, middleSegment.id), + ); + expect(getTimelineKeepRanges(deleted)).toEqual([ + { start: 0, end: 3 }, + { start: 7, end: 10 }, + ]); + + const afterFirstSplitRemoved = removeSplitPoint(deleted, 0); + expect( + getTimelineSegments(afterFirstSplitRemoved).map( + ({ start, end, deleted }) => ({ + start, + end, + deleted, + }), + ), + ).toEqual([ + { start: 0, end: 7, deleted: false }, + { start: 7, end: 10, deleted: false }, + ]); + + const afterSecondSplitRemoved = removeSplitPoint(afterFirstSplitRemoved, 0); + expect( + getTimelineSegments(afterSecondSplitRemoved).map( + ({ start, end, deleted }) => ({ + start, + end, + deleted, + }), + ), + ).toEqual([{ start: 0, end: 10, deleted: false }]); + expect(getTimelineKeepRanges(afterSecondSplitRemoved)).toEqual([ + { start: 0, end: 10 }, + ]); + }); + + it("tracks undo and redo state", () => { + const initial = createTimelineState(10); + const history = createTimelineHistory(initial); + const nextHistory = pushTimelineHistory( + history, + splitTimelineAt(initial, 5), + ); + const undone = undoTimelineHistory(nextHistory); + const redone = redoTimelineHistory(undone); + + expect( + getTimelineSegments(undone.entries[undone.index] ?? initial), + ).toHaveLength(1); + expect( + getTimelineSegments(redone.entries[redone.index] ?? initial), + ).toHaveLength(2); + }); + + it("detects timeline draft changes independently from output changes", () => { + const initial = createTimelineState(10); + const split = splitTimelineAt(initial, 5); + const initialSegment = getTimelineSegments(initial)[0]; + expect(initialSegment).toBeDefined(); + if (!initialSegment) throw new Error("Expected initial segment"); + const selected = selectTimelineSegment(initial, initialSegment.id); + + expect(areTimelineStatesEquivalent(initial, split)).toBe(false); + expect(areTimelineStatesEquivalent(initial, selected)).toBe(true); + expect( + areEditSpecsEquivalent( + normalizeKeepRanges(getTimelineKeepRanges(initial), initial.duration), + normalizeKeepRanges(getTimelineKeepRanges(split), split.duration), + ), + ).toBe(true); + }); + + it("finds preview skip targets", () => { + const spec = normalizeKeepRanges( + [ + { start: 0, end: 2 }, + { start: 5, end: 8 }, + ], + 10, + ); + + expect(findNextPlayableTime(1, spec)).toBe(1); + expect(findNextPlayableTime(3, spec)).toBe(5); + expect(findNextPlayableTime(8.1, spec)).toBeNull(); + }); +}); diff --git a/apps/web/__tests__/unit/video-frame-thumbnail.test.ts b/apps/web/__tests__/unit/video-frame-thumbnail.test.ts index 9b0ff6b0c42..8bec6942a60 100644 --- a/apps/web/__tests__/unit/video-frame-thumbnail.test.ts +++ b/apps/web/__tests__/unit/video-frame-thumbnail.test.ts @@ -101,6 +101,16 @@ describe("captureVideoFrameDataUrl", () => { expect(ctx.drawImage).toHaveBeenCalledWith(video, 0, 0, 320, 180); }); + it("respects custom JPEG quality", () => { + const { canvas } = createMockCanvas(); + captureVideoFrameDataUrl({ + video: createMockVideo(), + createCanvas: () => canvas, + quality: 0.45, + }); + expect(canvas.toDataURL).toHaveBeenCalledWith("image/jpeg", 0.45); + }); + it("uses the injected createCanvas factory", () => { const { canvas } = createMockCanvas(); const factory = vi.fn().mockReturnValue(canvas); diff --git a/apps/web/actions/videos/save-edits.ts b/apps/web/actions/videos/save-edits.ts new file mode 100644 index 00000000000..49ecab7250c --- /dev/null +++ b/apps/web/actions/videos/save-edits.ts @@ -0,0 +1,207 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { videoEdits, videos, videoUploads } from "@cap/database/schema"; +import type { VideoEditSpec } from "@cap/database/types"; +import { userIsPro } from "@cap/utils"; +import { Storage } from "@cap/web-backend"; +import type { Video } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; +import { Effect } from "effect"; +import { revalidatePath } from "next/cache"; +import { start } from "workflow/api"; +import { runPromise } from "@/lib/server"; +import { getEditSourceKey } from "@/lib/video-edit-processing"; +import { + areEditSpecsEquivalent, + composeEditSpecs, + createIdentityEditSpec, + getEditSpecOutputDuration, + normalizeKeepRanges, +} from "@/lib/video-edits"; +import { decodeStorageVideo } from "@/lib/video-storage"; +import { editVideoWorkflow } from "@/workflows/edit-video"; + +const ACTIVE_UPLOAD_PHASES = new Set([ + "uploading", + "processing", + "generating_thumbnail", + "complete", + "error", +]); + +function isMp4BackedVideo(source: typeof videos.$inferSelect.source) { + return source.type === "desktopMP4" || source.type === "webMP4"; +} + +function getResultKey(ownerId: string, videoId: string) { + return `${ownerId}/${videoId}/result.mp4`; +} + +async function objectExists( + bucket: Awaited>, + key: string, +) { + return await bucket.headObject(key).pipe( + Effect.as(true), + Effect.catchAll(() => Effect.succeed(false)), + runPromise, + ); +} + +async function getVideoBucket(video: typeof videos.$inferSelect) { + const [bucket] = await Storage.getAccessForVideo( + decodeStorageVideo(video), + ).pipe(runPromise); + return bucket; +} + +async function ensureOriginalSourceCopy( + video: typeof videos.$inferSelect, + sourceKey = getEditSourceKey(video.ownerId, video.id), +) { + const bucket = await getVideoBucket(video); + const hasSource = await objectExists(bucket, sourceKey); + + if (!hasSource) { + const resultKey = getResultKey(video.ownerId, video.id); + await bucket + .copyObject(`${bucket.bucketName}/${resultKey}`, sourceKey) + .pipe(runPromise); + } + + return sourceKey; +} + +async function markEditProcessing({ + videoId, + sourceKey, +}: { + videoId: Video.VideoId; + sourceKey: string; +}) { + await db() + .insert(videoUploads) + .values({ + videoId, + uploaded: 0, + total: 0, + mode: "singlepart", + phase: "processing", + processingProgress: 0, + processingMessage: "Starting video edit...", + processingError: null, + rawFileKey: sourceKey, + updatedAt: new Date(), + }) + .onDuplicateKeyUpdate({ + set: { + uploaded: 0, + total: 0, + mode: "singlepart", + phase: "processing", + processingProgress: 0, + processingMessage: "Starting video edit...", + processingError: null, + rawFileKey: sourceKey, + updatedAt: new Date(), + }, + }); +} + +export async function saveVideoEdits( + videoId: Video.VideoId, + editSpec: VideoEditSpec, +) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + if (!userIsPro(user)) throw new Error("Cap Pro is required to edit videos"); + + const [video] = await db() + .select() + .from(videos) + .where(eq(videos.id, videoId)); + + if (!video) throw new Error("Video not found"); + if (video.ownerId !== user.id) throw new Error("Forbidden"); + if (video.isScreenshot) throw new Error("Screenshots cannot be edited"); + if (!isMp4BackedVideo(video.source)) { + throw new Error("Only processed MP4 videos can be edited"); + } + + const [activeUpload] = await db() + .select() + .from(videoUploads) + .where(eq(videoUploads.videoId, videoId)); + + if (activeUpload && ACTIVE_UPLOAD_PHASES.has(activeUpload.phase)) { + const message = + activeUpload.phase === "complete" + ? "Previous edit is finishing up. Try again in a moment." + : activeUpload.phase === "error" + ? "Previous edit failed and is being cleaned up. Try again in a moment." + : "Video is already uploading or processing"; + throw new Error(message); + } + + const [existingEdit] = await db() + .select() + .from(videoEdits) + .where(eq(videoEdits.videoId, videoId)); + + const previousSpec = + existingEdit?.editSpec ?? + createIdentityEditSpec(video.duration ?? editSpec.sourceDuration); + const expectedCurrentDuration = existingEdit + ? getEditSpecOutputDuration(previousSpec) + : (video.duration ?? editSpec.sourceDuration); + const currentOutputSpec = normalizeKeepRanges( + editSpec.keepRanges, + expectedCurrentDuration, + ); + + if (getEditSpecOutputDuration(currentOutputSpec) <= 0) { + throw new Error("Edit must keep at least one playable range"); + } + + const normalizedEditSpec = existingEdit + ? composeEditSpecs(previousSpec, currentOutputSpec) + : currentOutputSpec; + + if (areEditSpecsEquivalent(previousSpec, normalizedEditSpec)) { + revalidatePath(`/s/${videoId}/edit`); + return { success: true, skipped: true }; + } + + const sourceKey = await ensureOriginalSourceCopy( + video, + existingEdit?.sourceKey, + ); + + await markEditProcessing({ videoId, sourceKey }); + + try { + await start(editVideoWorkflow, [ + { + videoId, + userId: user.id, + sourceKey, + previousSpec, + editSpec: normalizedEditSpec, + keepRanges: normalizedEditSpec.keepRanges, + }, + ]); + } catch (error) { + await db().delete(videoUploads).where(eq(videoUploads.videoId, videoId)); + throw error instanceof Error + ? error + : new Error("Video edit could not start"); + } + + revalidatePath(`/s/${videoId}`); + revalidatePath(`/s/${videoId}/edit`); + revalidatePath("/dashboard/caps"); + + return { success: true }; +} diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index 41894598208..f96606d3f1b 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -1,5 +1,6 @@ "use client"; +import type { videos as videosSchema } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; import { Button } from "@cap/ui"; import type { SpaceRuleSource, ViewerSettingKey } from "@cap/web-backend"; @@ -52,6 +53,8 @@ export type VideoData = { }[]; ownerName: string; metadata?: VideoMetadata; + source: typeof videosSchema.$inferSelect.source; + isScreenshot: boolean; hasPassword: boolean; hasInheritedPassword?: boolean; inheritedPasswordSources?: SpaceRuleSource[]; diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx index 278cd4b20eb..d20dec0472b 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -1,5 +1,6 @@ "use client"; +import type { videos as videosSchema } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; import { buildEnv, NODE_ENV } from "@cap/env"; import { @@ -21,6 +22,7 @@ import { faGear, faLink, faLock, + faScissors, faShare, faTrash, faUnlock, @@ -37,6 +39,7 @@ import { toast } from "sonner"; import { ConfirmationDialog } from "@/app/(org)/dashboard/_components/ConfirmationDialog"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; import { useUploadProgress } from "@/app/s/[videoId]/_components/ProgressCircle"; +import { UpgradeModal } from "@/components/UpgradeModal"; import { type ImageLoadingStatus, VideoThumbnail, @@ -92,6 +95,8 @@ export interface CapCardProps extends PropsWithChildren { }[]; ownerName: string | null; metadata?: VideoMetadata; + source?: typeof videosSchema.$inferSelect.source; + isScreenshot?: boolean; hasPassword?: boolean; hasInheritedPassword?: boolean; inheritedPasswordSources?: SpaceRuleSource[]; @@ -155,6 +160,7 @@ export const CapCard = ({ const [isDragging, setIsDragging] = useState(false); const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false); const { user, setUpgradeModalOpen } = useDashboardContext(); + const [editUpgradeModalOpen, setEditUpgradeModalOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); @@ -348,9 +354,28 @@ export const CapCard = ({ : `${webUrl}/s/${cap.id}`, ); }; + const canEditVideo = + isOwner && + !sharedCapCard && + cap.isScreenshot !== true && + !cap.hasActiveUpload && + (cap.source?.type === "desktopMP4" || cap.source?.type === "webMP4") && + Boolean(cap.duration && cap.duration > 0); + const handleEditVideo = () => { + if (!canEditVideo) return; + if (!user.isPro) { + setEditUpgradeModalOpen(true); + return; + } + router.push(`/s/${cap.id}/edit`); + }; return ( <> + setIsSharingDialogOpen(false)} @@ -536,6 +561,18 @@ export const CapCard = ({

Duplicate

+ {canEditVideo && ( + { + e.stopPropagation(); + handleEditVideo(); + }} + className="flex gap-2 items-center rounded-lg" + > + +

Edit video

+
+ )} { if (!user.isPro) setUpgradeModalOpen(true); diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx index 91a52c00ed7..c49ee5be313 100644 --- a/apps/web/app/(org)/dashboard/caps/page.tsx +++ b/apps/web/app/(org)/dashboard/caps/page.tsx @@ -157,6 +157,8 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { name: videos.name, createdAt: videos.createdAt, metadata: videos.metadata, + source: videos.source, + isScreenshot: videos.isScreenshot, duration: videos.duration, public: videos.public, totalComments: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, @@ -206,6 +208,8 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { videos.name, videos.createdAt, videos.metadata, + videos.source, + videos.isScreenshot, videos.duration, videos.public, videos.password, diff --git a/apps/web/app/api/webhooks/media-server/progress/route.ts b/apps/web/app/api/webhooks/media-server/progress/route.ts index 01407a0c1b3..509572aee0f 100644 --- a/apps/web/app/api/webhooks/media-server/progress/route.ts +++ b/apps/web/app/api/webhooks/media-server/progress/route.ts @@ -8,6 +8,7 @@ import { Effect } from "effect"; import { type NextRequest, NextResponse } from "next/server"; import { invalidateGoogleDriveStorageQuotaCache } from "@/lib/google-drive-storage-quota"; import { runPromise } from "@/lib/server"; +import { isEditSourceKey } from "@/lib/video-edit-processing"; import { decodeStorageVideo } from "@/lib/video-storage"; interface ProgressWebhookPayload { @@ -103,6 +104,10 @@ export async function POST(request: NextRequest) { .select() .from(videos) .where(eq(videos.id, payload.videoId as Video.VideoId)); + const [currentUpload] = await db() + .select({ rawFileKey: videoUploads.rawFileKey }) + .from(videoUploads) + .where(eq(videoUploads.videoId, payload.videoId as Video.VideoId)); if (currentVideo?.source?.type === "desktopSegments") { await db() @@ -159,9 +164,30 @@ export async function POST(request: NextRequest) { } } - await db() - .delete(videoUploads) - .where(eq(videoUploads.videoId, payload.videoId as Video.VideoId)); + const isEditUpload = + currentVideo && + isEditSourceKey({ + ownerId: currentVideo.ownerId, + videoId: payload.videoId, + rawFileKey: currentUpload?.rawFileKey, + }); + + if (isEditUpload) { + await db() + .update(videoUploads) + .set({ + phase: "complete", + processingProgress: 100, + processingMessage: payload.message, + processingError: null, + updatedAt: new Date(), + }) + .where(eq(videoUploads.videoId, payload.videoId as Video.VideoId)); + } else { + await db() + .delete(videoUploads) + .where(eq(videoUploads.videoId, payload.videoId as Video.VideoId)); + } await invalidateGoogleDriveStorageQuotaCache( currentVideo?.storageIntegrationId, ); diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 059e6236c1b..59d5e8f8a9b 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -920,3 +920,82 @@ footer a { .messenger-markdown > :last-child { margin-bottom: 0; } + +@supports (view-transition-name: none) { + ::view-transition-old(root), + ::view-transition-new(root) { + animation-duration: 320ms; + animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); + } + + ::view-transition-group(cap-edit-video) { + animation-duration: 420ms; + animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); + z-index: 50; + } + + ::view-transition-image-pair(cap-edit-video) { + isolation: auto; + mix-blend-mode: normal; + } + + html[data-view-transition="edit-enter"]::view-transition-old(root) { + animation-name: cap-fade-out-down; + } + + html[data-view-transition="edit-enter"]::view-transition-new(root) { + animation-name: cap-fade-in-up; + } + + html[data-view-transition="edit-exit"]::view-transition-old(root) { + animation-name: cap-fade-out-up; + } + + html[data-view-transition="edit-exit"]::view-transition-new(root) { + animation-name: cap-fade-in-down; + } + + @keyframes cap-fade-out-down { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.96); + } + } + + @keyframes cap-fade-in-up { + from { + opacity: 0; + transform: scale(1.04); + } + to { + opacity: 1; + transform: scale(1); + } + } + + @keyframes cap-fade-out-up { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(1.04); + } + } + + @keyframes cap-fade-in-down { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } + } +} diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index 5d7e9dd3d7b..1e2b5a851cc 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -149,6 +149,7 @@ interface ShareProps { videoSettings?: OrganizationSettings | null; userOrganizations?: { id: string; name: string }[]; viewerId?: string | null; + isEditProcessing: boolean; initialAiData?: { title?: string | null; summary?: string | null; @@ -261,6 +262,7 @@ export const Share = ({ aiGenerationEnabled, videoSettings, viewerId, + isEditProcessing, }: ShareProps) => { const effectiveDate: Date = data.metadata?.customCreatedAt ? new Date(data.metadata.customCreatedAt) @@ -480,6 +482,7 @@ export const Share = ({ aiGenerationStatus={aiData?.aiGenerationStatus} canRetryProcessing={viewerId === data.owner.id} showPlaybackStatusBadge={viewerId === data.owner.id} + isEditProcessing={isEditProcessing} ref={playerRef} /> diff --git a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx index 970f237aabd..e2d2b50e1c7 100644 --- a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx @@ -88,8 +88,10 @@ interface Props { autoplay?: boolean; enableCrossOrigin?: boolean; hasActiveUpload: boolean | undefined; + blockPlaybackDuringProcessing?: boolean; disableCommentStamps?: boolean; disableReactionStamps?: boolean; + disablePreviewGif?: boolean; comments?: Array<{ id: string; timestamp: number | null; @@ -108,6 +110,7 @@ interface Props { canRetryProcessing?: boolean; duration?: number | null; showPlaybackStatusBadge?: boolean; + onUploadComplete?: () => void; } export function CapVideoPlayer({ @@ -122,9 +125,11 @@ export function CapVideoPlayer({ autoplay = false, enableCrossOrigin = false, hasActiveUpload, + blockPlaybackDuringProcessing = false, comments = [], disableCommentStamps = false, disableReactionStamps = false, + disablePreviewGif = false, onSeek, enhancedAudioUrl: _enhancedAudioUrl, enhancedAudioStatus: _enhancedAudioStatus, @@ -136,6 +141,7 @@ export function CapVideoPlayer({ canRetryProcessing = false, duration: fallbackDuration, showPlaybackStatusBadge = false, + onUploadComplete, }: Props) { const [currentCue, setCurrentCue] = useState(""); const [controlsVisible, setControlsVisible] = useState(false); @@ -170,7 +176,11 @@ export function CapVideoPlayer({ videoId, hasActiveUpload || false, ); - const uploadProgress = videoLoaded ? null : uploadProgressRaw; + const uploadProgress = blockPlaybackDuringProcessing + ? uploadProgressRaw + : videoLoaded + ? null + : uploadProgressRaw; const isUploading = uploadProgress?.status === "uploading"; const isProcessing = uploadProgress?.status === "processing"; const isGeneratingThumbnail = @@ -524,15 +534,16 @@ export function CapVideoPlayer({ } }, [canRetryUploadProcessing, isRetryingProcessing, queryClient, videoId]); - const prevUploadProgress = useRef(uploadProgress); + const prevUploadProgress = + useRef(uploadProgressRaw); useEffect(() => { if ( shouldReloadPlaybackAfterUploadCompletes( prevUploadProgress.current, - uploadProgress, - videoLoaded, + uploadProgressRaw, ) ) { + setVideoLoaded(false); setHasError(false); void queryClient.invalidateQueries({ queryKey: [ @@ -543,18 +554,37 @@ export function CapVideoPlayer({ preferredSource, ], }); + onUploadComplete?.(); } - prevUploadProgress.current = uploadProgress; + prevUploadProgress.current = uploadProgressRaw; }, [ enableCrossOrigin, + onUploadComplete, preferredSource, queryClient, rawFallbackSrc, - uploadProgress, - videoLoaded, + uploadProgressRaw, videoSrc, ]); + const editRecoveryRefreshTriggeredRef = useRef(false); + useEffect(() => { + if ( + !blockPlaybackDuringProcessing || + uploadProgressRaw?.status !== "error" || + editRecoveryRefreshTriggeredRef.current + ) { + return; + } + + editRecoveryRefreshTriggeredRef.current = true; + onUploadComplete?.(); + }, [ + blockPlaybackDuringProcessing, + onUploadComplete, + uploadProgressRaw?.status, + ]); + const showPreparingOverlay = !videoLoaded && !uploadProgress && @@ -573,7 +603,8 @@ export function CapVideoPlayer({ ? "The processed version is unavailable right now, so this page is playing the original uploaded file instead." : "This page is temporarily playing the original uploaded file while Cap finishes processing the optimized version for smoother playback and broader compatibility."; const blockPlaybackControls = - (!videoLoaded && hasActiveProgress) || showUploadFailureOverlay; + ((blockPlaybackDuringProcessing || !videoLoaded) && hasActiveProgress) || + showUploadFailureOverlay; return ( )} - {!videoLoaded && hasActiveProgress && !showUploadFailureOverlay && ( - <> - - - - {getProgressStatusText( - isProcessing - ? "processing" - : isGeneratingThumbnail - ? "generating_thumbnail" - : "uploading", - )} - {uploadProgress?.progress != null && - uploadProgress.progress > 0 && - ` ${Math.round(uploadProgress.progress)}%`} - - - Progress - - - - - - )} + {(blockPlaybackDuringProcessing || !videoLoaded) && + hasActiveProgress && + !showUploadFailureOverlay && ( + <> + + + + {getProgressStatusText( + isProcessing + ? "processing" + : isGeneratingThumbnail + ? "generating_thumbnail" + : "uploading", + )} + {uploadProgress?.progress != null && + uploadProgress.progress > 0 && + ` ${Math.round(uploadProgress.progress)}%`} + + + Progress + + + + + + )} {showPlayButton && videoLoaded && !hasPlayedOnce && diff --git a/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx index e9085b88a23..f5fd7686208 100644 --- a/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx @@ -580,14 +580,13 @@ export function HLSVideoPlayer({ shouldReloadPlaybackAfterUploadCompletes( prevUploadProgress.current, uploadProgress, - videoLoaded, ) ) { reloadPlayback(); setTimeout(reloadPlayback, 1000); } prevUploadProgress.current = uploadProgress; - }, [uploadProgress, videoLoaded, reloadPlayback]); + }, [uploadProgress, reloadPlayback]); return ( STALE_THUMBNAIL_MS) { + return "Video finishing stalled. Retry processing."; + } + return null; } @@ -154,7 +156,13 @@ export function useUploadProgress( processingProgress: query.data.processingProgress, }); - if (phase === "complete") return null; + if (phase === "complete") { + return { + status: "generating_thumbnail", + lastUpdated, + progress: 100, + }; + } if (phase === "error") { return { diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx index 28a89a5380f..e997dac86ec 100644 --- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx @@ -9,7 +9,7 @@ import { faLock, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Check, Clock, Copy, Globe2 } from "lucide-react"; +import { Check, Clock, Copy, Globe2, Scissors } from "lucide-react"; import moment from "moment"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; @@ -23,6 +23,7 @@ import { SignedImageUrl } from "@/components/SignedImageUrl"; import { Tooltip } from "@/components/Tooltip"; import { UpgradeModal } from "@/components/UpgradeModal"; import { usePublicEnv } from "@/utils/public-env"; +import { navigateWithTransition } from "@/utils/view-transition"; import type { VideoData } from "../types"; export const ShareHeader = ({ @@ -234,6 +235,19 @@ export const ShareHeader = ({ }; const userIsOwnerAndNotPro = user?.id === data.owner.id && !data.owner.isPro; + const canEditVideo = + isOwner && + !data.isScreenshot && + !data.hasActiveUpload && + (data.source.type === "desktopMP4" || data.source.type === "webMP4"); + const handleEditVideo = () => { + if (userIsOwnerAndNotPro) { + setUpgradeModalOpen(true); + return; + } + + navigateWithTransition("edit-enter", () => push(`/s/${data.id}/edit`)); + }; return ( <> @@ -377,27 +391,54 @@ export const ShareHeader = ({ )} + {user !== null && canEditVideo && ( + + )} {user !== null && (
{isOwner && ( - - + + )} + - - - + + + )} + ); +} + +function ToolButton({ + active, + disabled, + onClick, + icon, + label, + tone = "default", +}: { + active?: boolean; + disabled?: boolean; + onClick: () => void; + icon: React.ReactNode; + label: string; + tone?: "default" | "danger"; +}) { + return ( + + ); +} + +function useThumbnailCount(ref: React.RefObject) { + const [count, setCount] = useState(8); + + useEffect(() => { + const node = ref.current; + if (!node) return; + + const resizeObserver = new ResizeObserver(([entry]) => { + const width = entry?.contentRect.width ?? 0; + const nextCount = Math.min( + MAX_TIMELINE_THUMBNAILS, + Math.max(6, Math.floor(width / 96)), + ); + setCount((current) => (current === nextCount ? current : nextCount)); + }); + + resizeObserver.observe(node); + return () => resizeObserver.disconnect(); + }, [ref]); + + return count; +} + +function isThumbnailResponse(value: unknown): value is { screen: string } { + return ( + typeof value === "object" && + value !== null && + "screen" in value && + typeof value.screen === "string" + ); +} + +function useTimelineCoverThumbnail(videoId: Video.VideoId) { + const [thumbnailUrl, setThumbnailUrl] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setThumbnailUrl(null); + const timeoutId = window.setTimeout(async () => { + try { + const response = await fetch( + `/api/thumbnail?videoId=${encodeURIComponent(videoId)}`, + { signal: controller.signal }, + ); + if (!response.ok) return; + const body: unknown = await response.json(); + if (!controller.signal.aborted && isThumbnailResponse(body)) { + setThumbnailUrl(body.screen); + } + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + } + }, 0); + + return () => { + window.clearTimeout(timeoutId); + controller.abort(); + }; + }, [videoId]); + + return thumbnailUrl; +} + +function useVisibleTimelineThumbnailRange({ + scrollContainerRef, + timelineRef, + thumbnailCount, +}: { + scrollContainerRef: React.RefObject; + timelineRef: React.RefObject; + thumbnailCount: number; +}) { + const [range, setRange] = useState(() => ({ + start: 0, + end: Math.max(0, Math.min(thumbnailCount - 1, 7)), + })); + + useEffect(() => { + const container = scrollContainerRef.current; + const timeline = timelineRef.current; + if (!container || !timeline || thumbnailCount <= 0) { + setRange({ start: 0, end: -1 }); + return; + } + + let frameId = 0; + const updateRange = () => { + cancelAnimationFrame(frameId); + frameId = requestAnimationFrame(() => { + const timelineWidth = + timeline.scrollWidth || timeline.getBoundingClientRect().width; + const slotWidth = timelineWidth / thumbnailCount; + if (slotWidth <= 0) { + setRange({ start: 0, end: -1 }); + return; + } + + const rawStart = Math.floor(container.scrollLeft / slotWidth) - 1; + const rawEnd = + Math.ceil( + (container.scrollLeft + container.clientWidth) / slotWidth, + ) + 1; + const start = Math.min(Math.max(rawStart, 0), thumbnailCount - 1); + const end = Math.min( + Math.max(rawEnd, start), + start + MAX_VISIBLE_THUMBNAIL_GENERATION - 1, + thumbnailCount - 1, + ); + + setRange((current) => + current.start === start && current.end === end + ? current + : { start, end }, + ); + }); + }; + + updateRange(); + container.addEventListener("scroll", updateRange, { passive: true }); + const resizeObserver = new ResizeObserver(updateRange); + resizeObserver.observe(container); + resizeObserver.observe(timeline); + + return () => { + cancelAnimationFrame(frameId); + container.removeEventListener("scroll", updateRange); + resizeObserver.disconnect(); + }; + }, [scrollContainerRef, thumbnailCount, timelineRef]); + + return range; +} + +function useLazyTimelineThumbnails({ + videoSrc, + sourceDuration, + thumbnailTimes, + visibleRange, + enabled, +}: { + videoSrc: string; + sourceDuration: number; + thumbnailTimes: TimelineThumbnailRequest[]; + visibleRange: { start: number; end: number }; + enabled: boolean; +}) { + const [frames, setFrames] = useState>( + {}, + ); + const processedRef = useRef>(new Set()); + const resetKey = `${videoSrc}:${sourceDuration}`; + const resetKeyRef = useRef(resetKey); + + useEffect(() => { + if (resetKeyRef.current === resetKey) return; + resetKeyRef.current = resetKey; + processedRef.current = new Set(); + setFrames({}); + }, [resetKey]); + + useEffect(() => { + if ( + !enabled || + !videoSrc || + sourceDuration <= 0 || + thumbnailTimes.length <= 0 || + visibleRange.end < visibleRange.start + ) { + return; + } + + const pendingByKey = new Map(); + for (let index = visibleRange.start; index <= visibleRange.end; index++) { + const thumbnailTime = thumbnailTimes[index]; + if (!thumbnailTime) continue; + const { key, time } = thumbnailTime; + if (!processedRef.current.has(key)) { + pendingByKey.set(key, { time }); + } + } + const pending = Array.from(pendingByKey, ([key, value]) => ({ + key, + time: value.time, + })); + if (pending.length === 0) return; + + let cancelled = false; + let video: HTMLVideoElement | null = null; + + const cancelIdle = scheduleIdle(() => { + void (async () => { + video = document.createElement("video"); + video.crossOrigin = "anonymous"; + video.muted = true; + video.playsInline = true; + video.preload = "metadata"; + video.src = videoSrc; + video.load(); + + const hasMetadata = await waitForVideoMetadata(video); + if (cancelled || !hasMetadata) { + releaseThumbnailVideo(video); + video = null; + return; + } + + const safeSourceDuration = Number.isFinite(video.duration) + ? Math.min(video.duration, sourceDuration) + : sourceDuration; + let frameBatch: Record = {}; + let frameBatchSize = 0; + const flushFrameBatch = () => { + if (frameBatchSize === 0) return; + const nextBatch = frameBatch; + frameBatch = {}; + frameBatchSize = 0; + setFrames((current) => { + let changed = false; + const nextFrames = { ...current }; + for (const [key, frame] of Object.entries(nextBatch)) { + if (nextFrames[key]?.src === frame.src) continue; + nextFrames[key] = frame; + changed = true; + } + return changed ? nextFrames : current; + }); + }; + + for (const item of pending) { + if (cancelled) break; + const time = Math.min(item.time, safeSourceDuration); + const seeked = await seekVideoForThumbnail(video, time); + if (cancelled) break; + + processedRef.current.add(item.key); + if (seeked) { + const frame = captureVideoFrameDataUrl({ + video, + width: TIMELINE_THUMBNAIL_WIDTH, + height: TIMELINE_THUMBNAIL_HEIGHT, + quality: 0.55, + }); + if (frame) { + frameBatch[item.key] = { src: frame, time }; + frameBatchSize += 1; + if (frameBatchSize >= THUMBNAIL_FRAME_BATCH_SIZE) { + flushFrameBatch(); + } + } + } + + await waitForNextFrame(); + } + + if (!cancelled) { + flushFrameBatch(); + } + + releaseThumbnailVideo(video); + video = null; + })(); + }); + + return () => { + cancelled = true; + cancelIdle(); + releaseThumbnailVideo(video); + video = null; + }; + }, [ + enabled, + sourceDuration, + thumbnailTimes, + videoSrc, + visibleRange.end, + visibleRange.start, + ]); + + return frames; +} + +export function EditVideoClient({ video }: { video: EditableVideo }) { + const router = useRouter(); + const videoRef = useRef(null); + const timelineRef = useRef(null); + const scrollContainerRef = useRef(null); + const playheadOverlayRef = useRef(null); + const stateRef = useRef( + createTimelineState(video.duration), + ); + const dragDraftRef = useRef(null); + const pendingPlayheadRef = useRef(null); + const playheadFrameRef = useRef(0); + const pendingVideoSeekRef = useRef(null); + const videoSeekFrameRef = useRef(0); + const zoomRef = useRef(1); + const thumbnailCount = useThumbnailCount(timelineRef); + const draftStorageKey = useMemo( + () => getTimelineDraftKey(video.id), + [video.id], + ); + const initialState = useMemo( + () => createTimelineState(video.duration), + [video.duration], + ); + const [history, setHistory] = useState(() => + createTimelineHistory(initialState), + ); + const [hydratedDraftKey, setHydratedDraftKey] = useState(null); + const [draftState, setDraftState] = useState(null); + const [activeHandle, setActiveHandle] = useState(null); + const [splitToggle, setSplitToggle] = useState(false); + const [splitKeyHeld, setSplitKeyHeld] = useState(false); + const [splitButtonHeld, setSplitButtonHeld] = useState(false); + const splitHoldStartRef = useRef(null); + const splitClickedDuringHoldRef = useRef(false); + const splitMode = splitToggle || splitKeyHeld || splitButtonHeld; + const [playhead, setPlayhead] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const [zoom, setZoom] = useState(1); + const [isSaving, setIsSaving] = useState(false); + const [selectedSplitIndex, setSelectedSplitIndex] = useState( + null, + ); + const committedState = history.entries[history.index] ?? initialState; + const state = draftState ?? committedState; + const editSpec = useMemo(() => getTimelineEditSpec(state), [state]); + const initialEditSpec = useMemo( + () => getTimelineEditSpec(initialState), + [initialState], + ); + const hasTimelineChanges = useMemo( + () => !areEditSpecsEquivalent(initialEditSpec, editSpec), + [editSpec, initialEditSpec], + ); + const hasDraftChanges = useMemo( + () => !areTimelineStatesEquivalent(initialState, committedState), + [committedState, initialState], + ); + const keepRanges = useMemo(() => getTimelineKeepRanges(state), [state]); + const segments = useMemo(() => getTimelineSegments(state), [state]); + const visibleSegments = useMemo( + () => segments.filter((segment) => !segment.deleted), + [segments], + ); + const timelineDisplayDuration = useMemo( + () => getTimelineDisplayDuration(state), + [state], + ); + const timelineDisplaySegments = useMemo( + () => getTimelineDisplaySegments(state), + [state], + ); + const timelineDisplaySplitPoints = useMemo( + () => getTimelineDisplaySplitPoints(state), + [state], + ); + const playbackSrc = `/api/playlist?userId=${video.ownerId}&videoId=${video.id}&videoType=mp4`; + const timelineThumbnailUrl = useTimelineCoverThumbnail(video.id); + const visibleThumbnailRange = useVisibleTimelineThumbnailRange({ + scrollContainerRef, + timelineRef, + thumbnailCount, + }); + const thumbnailTimes = useMemo( + () => + Array.from({ length: thumbnailCount }, (_, index) => { + const displayTime = getTimelineThumbnailTime( + index, + thumbnailCount, + timelineDisplayDuration, + ); + const time = mapTimelineDisplayTimeToSourceTime(state, displayTime); + return { + key: getTimelineThumbnailKey(time), + time, + }; + }), + [thumbnailCount, state, timelineDisplayDuration], + ); + const timelineFrames = useLazyTimelineThumbnails({ + videoSrc: playbackSrc, + sourceDuration: video.duration, + thumbnailTimes, + visibleRange: visibleThumbnailRange, + enabled: !isPlaying && activeHandle === null && draftState === null, + }); + const timelineFrameList = useMemo( + () => Object.values(timelineFrames), + [timelineFrames], + ); + const thumbnailSlots = useMemo( + () => + thumbnailTimes.map(({ key, time }, index) => { + const exactFrame = timelineFrames[key]; + const frame = + exactFrame ?? getNearestTimelineFrame(timelineFrameList, time); + return { + key: `thumb-${index}`, + src: frame?.src ?? timelineThumbnailUrl, + }; + }), + [thumbnailTimes, timelineFrameList, timelineFrames, timelineThumbnailUrl], + ); + const canUndo = history.index > 0; + const canRedo = history.index < history.entries.length - 1; + const visibleSegmentCount = timelineDisplaySegments.length; + const trimStartDisplayTime = mapTimelineSourceTimeToDisplayTime( + state, + state.trimStart, + ); + const trimEndDisplayTime = mapTimelineSourceTimeToDisplayTime( + state, + state.trimEnd, + ); + const trimStartPct = getTimePercent( + trimStartDisplayTime, + timelineDisplayDuration, + ); + const trimEndPct = getTimePercent( + trimEndDisplayTime, + timelineDisplayDuration, + ); + const trimWidthPct = Math.max(0, trimEndPct - trimStartPct); + const clampedPlayhead = Math.min( + Math.max(playhead, state.trimStart), + state.trimEnd, + ); + const displayPlayhead = mapTimelineSourceTimeToDisplayTime( + state, + clampedPlayhead, + ); + const isTrimming = activeHandle !== null; + const outputDuration = useMemo( + () => getEditSpecOutputDuration(editSpec), + [editSpec], + ); + const outputPlayhead = useMemo(() => { + const mapped = mapSourceTimeToOutputTime(clampedPlayhead, editSpec); + if (mapped !== null) return mapped; + let cumulative = 0; + for (const range of editSpec.keepRanges) { + if (clampedPlayhead < range.start) return cumulative; + if (clampedPlayhead <= range.end) { + return cumulative + (clampedPlayhead - range.start); + } + cumulative += range.end - range.start; + } + return cumulative; + }, [clampedPlayhead, editSpec]); + + useEffect(() => { + stateRef.current = state; + }, [state]); + + useEffect(() => { + zoomRef.current = zoom; + }, [zoom]); + + useEffect(() => { + const draftStorage = getTimelineDraftStorage(); + const restoredState = draftStorage + ? readTimelineDraft(draftStorage, draftStorageKey, video.duration) + : null; + const nextState = restoredState ?? initialState; + setDraftState(null); + dragDraftRef.current = null; + setHistory(createTimelineHistory(nextState)); + setSelectedSplitIndex(null); + setPlayhead(nextState.trimStart); + setHydratedDraftKey(draftStorageKey); + }, [draftStorageKey, initialState, video.duration]); + + useEffect(() => { + if (hydratedDraftKey !== draftStorageKey) return; + const draftStorage = getTimelineDraftStorage(); + if (!draftStorage) return; + if (hasDraftChanges) { + writeTimelineDraft( + draftStorage, + draftStorageKey, + video.duration, + committedState, + ); + return; + } + clearTimelineDraft(draftStorage, draftStorageKey); + }, [ + committedState, + draftStorageKey, + hasDraftChanges, + hydratedDraftKey, + video.duration, + ]); + + useEffect(() => { + setSelectedSplitIndex((current) => { + if (current === null) return current; + return current >= timelineDisplaySplitPoints.length ? null : current; + }); + }, [timelineDisplaySplitPoints.length]); + + const setPlayheadOnFrame = useCallback((time: number, immediate = false) => { + if (immediate) { + if (playheadFrameRef.current !== 0) { + cancelAnimationFrame(playheadFrameRef.current); + playheadFrameRef.current = 0; + } + pendingPlayheadRef.current = null; + setPlayhead(time); + return; + } + + pendingPlayheadRef.current = time; + if (playheadFrameRef.current !== 0) return; + + playheadFrameRef.current = requestAnimationFrame(() => { + playheadFrameRef.current = 0; + const nextTime = pendingPlayheadRef.current; + pendingPlayheadRef.current = null; + if (nextTime !== null) setPlayhead(nextTime); + }); + }, []); + + const setVideoTimeOnFrame = useCallback((time: number, immediate = false) => { + const applySeek = () => { + const videoElement = videoRef.current; + const nextTime = pendingVideoSeekRef.current; + pendingVideoSeekRef.current = null; + if (!videoElement || nextTime === null) return; + if (Math.abs(videoElement.currentTime - nextTime) > 0.01) { + videoElement.currentTime = nextTime; + } + }; + + pendingVideoSeekRef.current = time; + + if (immediate) { + if (videoSeekFrameRef.current !== 0) { + cancelAnimationFrame(videoSeekFrameRef.current); + videoSeekFrameRef.current = 0; + } + applySeek(); + return; + } + + if (videoSeekFrameRef.current !== 0) return; + + videoSeekFrameRef.current = requestAnimationFrame(() => { + videoSeekFrameRef.current = 0; + applySeek(); + }); + }, []); + + useEffect( + () => () => { + if (playheadFrameRef.current !== 0) { + cancelAnimationFrame(playheadFrameRef.current); + } + if (videoSeekFrameRef.current !== 0) { + cancelAnimationFrame(videoSeekFrameRef.current); + } + }, + [], + ); + + const updatePlayheadOverlay = useCallback(() => { + const container = scrollContainerRef.current; + const overlay = playheadOverlayRef.current; + if (!container || !overlay) return; + const fraction = + timelineDisplayDuration > 0 + ? displayPlayhead / timelineDisplayDuration + : 0; + const x = fraction * container.scrollWidth - container.scrollLeft; + overlay.style.transform = `translate3d(${x}px, 0, 0) translateX(-50%)`; + }, [displayPlayhead, timelineDisplayDuration]); + + const commitState = useCallback((nextState: VideoTimelineState) => { + setDraftState(null); + dragDraftRef.current = null; + setHistory((currentHistory) => + pushTimelineHistory(currentHistory, nextState), + ); + }, []); + + const handleUndo = useCallback(() => { + setDraftState(null); + setHistory(undoTimelineHistory); + }, []); + + const handleRedo = useCallback(() => { + setDraftState(null); + setHistory(redoTimelineHistory); + }, []); + + const handleSplitButtonPointerDown = useCallback(() => { + splitHoldStartRef.current = Date.now(); + splitClickedDuringHoldRef.current = false; + setSplitButtonHeld(true); + }, []); + + const handleSplitButtonPointerUp = useCallback(() => { + const heldFor = Date.now() - (splitHoldStartRef.current ?? Date.now()); + const clickedDuringHold = splitClickedDuringHoldRef.current; + splitHoldStartRef.current = null; + splitClickedDuringHoldRef.current = false; + setSplitButtonHeld(false); + if (heldFor < 250 && !clickedDuringHold) { + setSplitToggle((prev) => !prev); + } + }, []); + + useEffect(() => { + if (!splitButtonHeld) return; + const handleUp = () => { + const heldFor = Date.now() - (splitHoldStartRef.current ?? Date.now()); + const clickedDuringHold = splitClickedDuringHoldRef.current; + splitHoldStartRef.current = null; + splitClickedDuringHoldRef.current = false; + setSplitButtonHeld(false); + if (heldFor < 250 && !clickedDuringHold) { + setSplitToggle((prev) => !prev); + } + }; + document.addEventListener("pointerup", handleUp); + document.addEventListener("pointercancel", handleUp); + return () => { + document.removeEventListener("pointerup", handleUp); + document.removeEventListener("pointercancel", handleUp); + }; + }, [splitButtonHeld]); + + const removeSplitAtIndex = useCallback( + (index: number) => { + commitState(removeTimelineDisplaySplitPoint(stateRef.current, index)); + setSelectedSplitIndex(null); + }, + [commitState], + ); + + const activeSegmentAtPlayhead = useMemo( + () => + visibleSegments.find( + (segment) => + playhead >= segment.start - 0.001 && playhead <= segment.end + 0.001, + ), + [playhead, visibleSegments], + ); + const canDeleteSegment = + activeSegmentAtPlayhead !== undefined && visibleSegmentCount > 1; + + const handleDelete = useCallback(() => { + if (!activeSegmentAtPlayhead) return; + if (visibleSegments.length <= 1) return; + const withSelection = selectTimelineSegment( + stateRef.current, + activeSegmentAtPlayhead.id, + ); + commitState(deleteSelectedTimelineSegment(withSelection)); + }, [activeSegmentAtPlayhead, commitState, visibleSegments.length]); + + const handleBackspace = useCallback(() => { + if (selectedSplitIndex !== null) { + commitState( + removeTimelineDisplaySplitPoint(stateRef.current, selectedSplitIndex), + ); + setSelectedSplitIndex(null); + return; + } + const SPLIT_SNAP = 0.25; + const splitAtPlayheadIndex = timelineDisplaySplitPoints.findIndex( + (splitPoint) => + splitPoint.sourceTimes.some( + (sourceTime) => Math.abs(sourceTime - playhead) <= SPLIT_SNAP, + ), + ); + if (splitAtPlayheadIndex !== -1) { + commitState( + removeTimelineDisplaySplitPoint(stateRef.current, splitAtPlayheadIndex), + ); + return; + } + handleDelete(); + }, [ + commitState, + handleDelete, + playhead, + selectedSplitIndex, + timelineDisplaySplitPoints, + ]); + + const handleDone = useCallback(async () => { + if (isSaving) return; + const draftStorage = getTimelineDraftStorage(); + if (!hasTimelineChanges) { + if (draftStorage) clearTimelineDraft(draftStorage, draftStorageKey); + router.push(`/s/${video.id}`); + return; + } + setIsSaving(true); + try { + await saveVideoEdits(video.id, editSpec); + if (draftStorage) clearTimelineDraft(draftStorage, draftStorageKey); + router.push(`/s/${video.id}`); + router.refresh(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to start video edit", + ); + setIsSaving(false); + } + }, [ + draftStorageKey, + editSpec, + hasTimelineChanges, + isSaving, + router, + video.id, + ]); + + const handleCancel = useCallback(() => { + const draftStorage = getTimelineDraftStorage(); + if (draftStorage) clearTimelineDraft(draftStorage, draftStorageKey); + navigateWithTransition("edit-exit", () => router.push(`/s/${video.id}`)); + }, [draftStorageKey, router, video.id]); + + const seekTo = useCallback( + (time: number, immediate = false) => { + const { trimStart, trimEnd } = stateRef.current; + const trimmedTime = Math.min(Math.max(time, trimStart), trimEnd); + const clamped = getClampedVideoTime( + trimmedTime, + videoRef.current, + trimEnd, + ); + setVideoTimeOnFrame(Math.max(clamped, trimStart), immediate); + setPlayheadOnFrame(Math.max(clamped, trimStart), immediate); + }, + [setPlayheadOnFrame, setVideoTimeOnFrame], + ); + + const togglePlayPause = useCallback(() => { + const videoElement = videoRef.current; + if (!videoElement) return; + if (videoElement.paused) { + void videoElement.play(); + } else { + videoElement.pause(); + } + }, []); + + const startSplitDrag = useCallback( + ( + splitIndex: number, + splitTime: number, + handle: VideoTimelineDisplaySplitDragHandle, + event: React.PointerEvent, + ) => { + event.preventDefault(); + event.stopPropagation(); + const timeline = timelineRef.current; + if (!timeline) return; + + const baseState = stateRef.current; + const sortedSplits = [...baseState.splitPoints].sort((a, b) => a - b); + const sourceSplitIndex = sortedSplits.findIndex( + (value) => Math.abs(value - splitTime) < 0.001, + ); + if (sourceSplitIndex === -1) return; + + const rect = timeline.getBoundingClientRect(); + let dragged = false; + let lastPreviewTime = splitTime; + let draftFrameId = 0; + let pendingClientX = event.clientX; + const computeTime = (clientX: number) => + getTimelineSourceTimeFromClientX(clientX, rect, baseState); + const updateDraft = (clientX: number) => { + const time = + getTimelineDisplaySplitDragTargetTime( + baseState, + splitIndex, + handle, + computeTime(clientX), + ) ?? splitTime; + const nextState = dragTimelineDisplaySplitPoint( + baseState, + splitIndex, + handle, + time, + ); + dragDraftRef.current = nextState; + setDraftState(nextState); + const clamped = getClampedVideoTime( + time, + videoRef.current, + baseState.duration, + ); + lastPreviewTime = clamped; + setVideoTimeOnFrame(clamped); + setPlayheadOnFrame(clamped); + }; + const scheduleDraftUpdate = (clientX: number) => { + pendingClientX = clientX; + if (draftFrameId !== 0) return; + draftFrameId = requestAnimationFrame(() => { + draftFrameId = 0; + updateDraft(pendingClientX); + }); + }; + const handlePointerMove = (moveEvent: PointerEvent) => { + dragged = true; + scheduleDraftUpdate(moveEvent.clientX); + }; + const handlePointerUp = (upEvent: PointerEvent) => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + if (draftFrameId !== 0) { + cancelAnimationFrame(draftFrameId); + draftFrameId = 0; + } + if (dragged) { + updateDraft(upEvent.clientX); + } + if (dragged && dragDraftRef.current) { + setVideoTimeOnFrame(lastPreviewTime, true); + setPlayheadOnFrame(lastPreviewTime, true); + commitState(dragDraftRef.current); + setSelectedSplitIndex(null); + } else { + setDraftState(null); + dragDraftRef.current = null; + seekTo(splitTime, true); + setSelectedSplitIndex((current) => + current === splitIndex ? null : splitIndex, + ); + } + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp, { once: true }); + }, + [commitState, seekTo, setPlayheadOnFrame, setVideoTimeOnFrame], + ); + + const updateZoomAround = useCallback( + (nextZoom: number, anchorClientX?: number) => { + const container = scrollContainerRef.current; + const clamped = clampZoom(nextZoom); + if (!container) { + setZoom(clamped); + return; + } + const rect = container.getBoundingClientRect(); + const anchor = + anchorClientX !== undefined + ? Math.min(Math.max(anchorClientX - rect.left, 0), rect.width) + : rect.width / 2; + const fraction = + container.scrollWidth > 0 + ? (container.scrollLeft + anchor) / container.scrollWidth + : 0; + + setZoom(clamped); + + requestAnimationFrame(() => { + const node = scrollContainerRef.current; + if (!node) return; + const newScrollWidth = node.scrollWidth; + const newPosition = fraction * newScrollWidth; + node.scrollLeft = newPosition - anchor; + }); + }, + [], + ); + + const handleTimelinePointerDown = useCallback( + (event: React.PointerEvent) => { + if (event.button !== 0) return; + const target = event.target as HTMLElement; + if (target.closest("[data-trim-handle]")) return; + + const timeline = timelineRef.current; + if (!timeline) return; + const rect = timeline.getBoundingClientRect(); + if (rect.width <= 0) return; + + const computeTime = (clientX: number) => + getTimelineSourceTimeFromClientX(clientX, rect, stateRef.current); + + const time = computeTime(event.clientX); + setSelectedSplitIndex(null); + + if (splitMode) { + commitState(splitTimelineAt(stateRef.current, time)); + setSplitToggle(false); + splitClickedDuringHoldRef.current = true; + seekTo(time, true); + return; + } + + seekTo(time, true); + let lastTime = time; + + const handleMove = (moveEvent: PointerEvent) => { + lastTime = computeTime(moveEvent.clientX); + seekTo(lastTime); + }; + const handleUp = (upEvent: PointerEvent) => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", handleUp); + lastTime = computeTime(upEvent.clientX); + seekTo(lastTime, true); + }; + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", handleUp, { once: true }); + }, + [commitState, seekTo, splitMode], + ); + + const startHandleDrag = useCallback( + (handle: DragHandle, event: React.PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + const timeline = timelineRef.current; + if (!timeline) return; + + setActiveHandle(handle); + const baseState = stateRef.current; + const rect = timeline.getBoundingClientRect(); + let lastPreviewTime = + handle === "start" ? baseState.trimStart : baseState.trimEnd; + let draftFrameId = 0; + let pendingClientX = event.clientX; + const getTimeFromClientX = (clientX: number) => + getTimelineSourceTimeFromClientX(clientX, rect, baseState); + const updateDraft = (clientX: number) => { + const time = getTimeFromClientX(clientX); + const nextState = + handle === "start" + ? setTimelineTrim(baseState, time, baseState.trimEnd) + : setTimelineTrim(baseState, baseState.trimStart, time); + dragDraftRef.current = nextState; + setDraftState(nextState); + const clamped = getClampedVideoTime( + time, + videoRef.current, + baseState.duration, + ); + lastPreviewTime = clamped; + setVideoTimeOnFrame(clamped); + setPlayheadOnFrame(clamped); + }; + const scheduleDraftUpdate = (clientX: number) => { + pendingClientX = clientX; + if (draftFrameId !== 0) return; + draftFrameId = requestAnimationFrame(() => { + draftFrameId = 0; + updateDraft(pendingClientX); + }); + }; + const handlePointerMove = (moveEvent: PointerEvent) => { + scheduleDraftUpdate(moveEvent.clientX); + }; + const handlePointerUp = (upEvent: PointerEvent) => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + if (draftFrameId !== 0) { + cancelAnimationFrame(draftFrameId); + draftFrameId = 0; + } + updateDraft(upEvent.clientX); + setVideoTimeOnFrame(lastPreviewTime, true); + setPlayheadOnFrame(lastPreviewTime, true); + setActiveHandle(null); + const nextState = dragDraftRef.current; + if (nextState) { + commitState(nextState); + } + }; + + updateDraft(event.clientX); + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp, { once: true }); + }, + [commitState, setPlayheadOnFrame, setVideoTimeOnFrame], + ); + + useEffect(() => { + let frameId = 0; + let detachVideoListeners: (() => void) | null = null; + + const attachVideoListeners = () => { + const videoElement = videoRef.current; + if (!videoElement) { + frameId = requestAnimationFrame(attachVideoListeners); + return; + } + + const syncPlayhead = () => { + if (dragDraftRef.current !== null) { + setPlayheadOnFrame(videoElement.currentTime, true); + return; + } + const nextTime = findNextPlayableTime( + videoElement.currentTime, + editSpec, + ); + + if (nextTime === null) { + videoElement.pause(); + videoElement.currentTime = keepRanges[0]?.start ?? 0; + setPlayheadOnFrame(videoElement.currentTime, true); + return; + } + + if (Math.abs(nextTime - videoElement.currentTime) > 0.04) { + videoElement.currentTime = nextTime; + setPlayheadOnFrame(nextTime, true); + return; + } + + setPlayheadOnFrame(videoElement.currentTime, true); + }; + + const handlePlay = () => setIsPlaying(true); + const handlePause = () => setIsPlaying(false); + + videoElement.addEventListener("timeupdate", syncPlayhead); + videoElement.addEventListener("seeking", syncPlayhead); + videoElement.addEventListener("loadedmetadata", syncPlayhead); + videoElement.addEventListener("play", handlePlay); + videoElement.addEventListener("pause", handlePause); + setIsPlaying(!videoElement.paused); + syncPlayhead(); + + detachVideoListeners = () => { + videoElement.removeEventListener("timeupdate", syncPlayhead); + videoElement.removeEventListener("seeking", syncPlayhead); + videoElement.removeEventListener("loadedmetadata", syncPlayhead); + videoElement.removeEventListener("play", handlePlay); + videoElement.removeEventListener("pause", handlePause); + }; + }; + + attachVideoListeners(); + + return () => { + cancelAnimationFrame(frameId); + detachVideoListeners?.(); + }; + }, [editSpec, keepRanges, setPlayheadOnFrame]); + + useEffect(() => { + const videoElement = videoRef.current; + if (!videoElement) return; + const nextTime = findNextPlayableTime(videoElement.currentTime, editSpec); + if ( + nextTime !== null && + Math.abs(nextTime - videoElement.currentTime) > 0.04 + ) { + videoElement.currentTime = nextTime; + } + }, [editSpec]); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container || zoom <= 1 || isTrimming) return; + const playheadFraction = + timelineDisplayDuration > 0 + ? displayPlayhead / timelineDisplayDuration + : 0; + const playheadX = playheadFraction * container.scrollWidth; + const visibleStart = container.scrollLeft; + const visibleEnd = visibleStart + container.clientWidth; + const padding = 32; + if ( + playheadX < visibleStart + padding || + playheadX > visibleEnd - padding + ) { + container.scrollTo({ + left: Math.max(0, playheadX - container.clientWidth / 2), + behavior: isPlaying ? "auto" : "smooth", + }); + } + }, [displayPlayhead, zoom, isPlaying, isTrimming, timelineDisplayDuration]); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + const handleWheel = (event: WheelEvent) => { + if (!event.ctrlKey && !event.metaKey) return; + event.preventDefault(); + const direction = event.deltaY > 0 ? -1 : 1; + const factor = + 1 + direction * Math.min(Math.abs(event.deltaY) / 120, 1) * 0.25; + updateZoomAround(zoomRef.current * factor, event.clientX); + }; + container.addEventListener("wheel", handleWheel, { passive: false }); + return () => container.removeEventListener("wheel", handleWheel); + }, [updateZoomAround]); + + useEffect(() => { + updatePlayheadOverlay(); + const container = scrollContainerRef.current; + if (!container) return; + let frameId = 0; + const onScroll = () => { + cancelAnimationFrame(frameId); + frameId = requestAnimationFrame(updatePlayheadOverlay); + }; + container.addEventListener("scroll", onScroll, { passive: true }); + const resizeObserver = new ResizeObserver(onScroll); + resizeObserver.observe(container); + return () => { + cancelAnimationFrame(frameId); + container.removeEventListener("scroll", onScroll); + resizeObserver.disconnect(); + }; + }, [updatePlayheadOverlay]); + + useEffect(() => { + const isFormTarget = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + return ( + target?.tagName === "INPUT" || + target?.tagName === "TEXTAREA" || + target?.isContentEditable === true + ); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (isFormTarget(event)) return; + const isMeta = event.metaKey || event.ctrlKey; + + if ( + event.key === "Escape" && + (splitToggle || splitKeyHeld || splitButtonHeld) + ) { + event.preventDefault(); + setSplitToggle(false); + setSplitKeyHeld(false); + setSplitButtonHeld(false); + splitHoldStartRef.current = null; + splitClickedDuringHoldRef.current = false; + return; + } + + if (event.key === " ") { + event.preventDefault(); + togglePlayPause(); + return; + } + + if (event.key === "Backspace" || event.key === "Delete") { + event.preventDefault(); + handleBackspace(); + return; + } + + if (event.key.toLowerCase() === "s" && !isMeta) { + event.preventDefault(); + if (!event.repeat) setSplitKeyHeld(true); + return; + } + + if (event.key === "ArrowLeft") { + event.preventDefault(); + const step = event.shiftKey ? 1 : 0.1; + seekTo(playhead - step, true); + return; + } + + if (event.key === "ArrowRight") { + event.preventDefault(); + const step = event.shiftKey ? 1 : 0.1; + seekTo(playhead + step, true); + return; + } + + if (isMeta && event.key.toLowerCase() === "z") { + event.preventDefault(); + if (event.shiftKey) { + handleRedo(); + } else { + handleUndo(); + } + return; + } + + if (event.key === "+" || event.key === "=") { + event.preventDefault(); + updateZoomAround(zoomRef.current * 1.25); + return; + } + + if (event.key === "-" || event.key === "_") { + event.preventDefault(); + updateZoomAround(zoomRef.current / 1.25); + return; + } + + if (event.key === "0") { + event.preventDefault(); + updateZoomAround(1); + return; + } + }; + + const handleKeyUp = (event: KeyboardEvent) => { + if (event.key.toLowerCase() === "s") { + setSplitKeyHeld(false); + } + }; + + const handleBlur = () => { + setSplitKeyHeld(false); + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + window.addEventListener("blur", handleBlur); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("blur", handleBlur); + }; + }, [ + handleBackspace, + handleRedo, + handleUndo, + playhead, + seekTo, + splitButtonHeld, + splitKeyHeld, + splitToggle, + togglePlayPause, + updateZoomAround, + ]); + + return ( +
+
+
+ +
+

+ {video.name} +

+
+
+ + + + + + + +
+
+
+ +
+
+
+ +
+
+ +
+ + +
+
+
+
+ {thumbnailSlots.map((slot) => ( +
+ {slot.src ? ( +
+ ) : ( +
+ )} +
+ ))} +
+ +
+
+ + {visibleSegmentCount > 1 && + timelineDisplaySegments.map((segment) => { + const isActive = activeSegmentAtPlayhead?.id === segment.id; + return ( +
+ ); + })} + + {timelineDisplaySplitPoints.map((splitPoint, index) => { + const isSelected = selectedSplitIndex === index; + const isAnySelected = selectedSplitIndex !== null; + const dimmed = isAnySelected && !isSelected; + const positionPercent = getTimePercent( + splitPoint.time, + timelineDisplayDuration, + ); + return ( + + + + + ); + })} + +
+
+
+
+ + + +
+
+ +
+ {selectedSplitIndex === null && ( +
+ {formatTimeDetailed(outputPlayhead)} +
+ )} +
+
+
+
+
+ +
+ + +
+
+ + {formatTime(outputPlayhead)} + + / + + {formatTime(outputDuration)} + +
+ +
+ +
+ + + updateZoomAround(Number.parseFloat(event.target.value)) + } + className="h-1 w-28 cursor-pointer appearance-none rounded-full bg-gray-5 accent-gray-12 [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-gray-12 [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-gray-12" + /> + +
+
+ + } + label="Delete" + /> +
+
+
+ ); +} diff --git a/apps/web/app/s/[videoId]/edit/page.tsx b/apps/web/app/s/[videoId]/edit/page.tsx new file mode 100644 index 00000000000..59c6824f051 --- /dev/null +++ b/apps/web/app/s/[videoId]/edit/page.tsx @@ -0,0 +1,79 @@ +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { videos, videoUploads } from "@cap/database/schema"; +import { userIsPro } from "@cap/utils"; +import { Video } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; +import { notFound } from "next/navigation"; +import { reconcileStaleEditUpload } from "@/lib/video-edit-processing"; +import { EditUpgradeGate } from "./EditUpgradeGate"; +import { EditVideoClient } from "./EditVideoClient"; + +function isMp4BackedVideo(source: typeof videos.$inferSelect.source) { + return source.type === "desktopMP4" || source.type === "webMP4"; +} + +export default async function EditVideoPage(props: { + params: Promise<{ videoId: string }>; +}) { + const params = await props.params; + const videoId = Video.VideoId.make(params.videoId); + const user = await getCurrentUser(); + + if (!user) notFound(); + + await reconcileStaleEditUpload(videoId); + + const [video] = await db() + .select({ + id: videos.id, + name: videos.name, + ownerId: videos.ownerId, + duration: videos.duration, + width: videos.width, + height: videos.height, + source: videos.source, + isScreenshot: videos.isScreenshot, + uploadPhase: videoUploads.phase, + }) + .from(videos) + .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) + .where(eq(videos.id, videoId)); + + if ( + !video || + video.ownerId !== user.id || + video.isScreenshot || + !isMp4BackedVideo(video.source) || + !video.duration || + video.duration <= 0 + ) { + notFound(); + } + + if (!userIsPro(user)) { + return ; + } + + if ( + video.uploadPhase && + ["uploading", "processing", "generating_thumbnail"].includes( + video.uploadPhase, + ) + ) { + notFound(); + } + + return ( + + ); +} diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index e150162fba8..bc4acf72345 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -48,6 +48,10 @@ import { SOCIAL_REFERRER_DOMAINS, } from "@/lib/social-crawlers"; import { transcribeVideo } from "@/lib/transcribe"; +import { + isEditSourceKey, + reconcileStaleEditUpload, +} from "@/lib/video-edit-processing"; import { optionFromTOrFirst } from "@/utils/effect"; import { isAiGenerationEnabled } from "@/utils/flags"; import { PasswordOverlay } from "./_components/PasswordOverlay"; @@ -327,6 +331,8 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { const searchParams = await props.searchParams; const videoId = params.videoId as Video.VideoId; + await reconcileStaleEditUpload(videoId); + return Effect.gen(function* () { const videosPolicy = yield* VideosPolicy; @@ -368,6 +374,7 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( Boolean, ), + activeUploadRawFileKey: videoUploads.rawFileKey, owner: users, }) .from(videos) @@ -411,6 +418,7 @@ async function AuthorizedContent({ sharedOrganization: { organizationId: Organisation.OrganisationId } | null; hasPassword: boolean; hasActiveUpload: boolean; + activeUploadRawFileKey: string | null; orgSettings?: OrganizationSettings | null; videoSettings?: OrganizationSettings | null; }; @@ -690,6 +698,11 @@ async function AuthorizedContent({ inheritedSpaceSettings: rules.inheritedSettings, }; }).pipe(runPromise); + const isEditProcessing = isEditSourceKey({ + ownerId: video.owner.id, + videoId, + rawFileKey: video.activeUploadRawFileKey, + }); return ( <> @@ -720,6 +733,7 @@ async function AuthorizedContent({ domainVerified={domainVerified} userOrganizations={userOrganizations} viewerId={user?.id ?? null} + isEditProcessing={isEditProcessing} initialAiData={initialAiData} aiGenerationEnabled={aiGenerationEnabled} /> diff --git a/apps/web/app/s/[videoId]/types.ts b/apps/web/app/s/[videoId]/types.ts index bd6bf8778f7..ad855abc76c 100644 --- a/apps/web/app/s/[videoId]/types.ts +++ b/apps/web/app/s/[videoId]/types.ts @@ -13,6 +13,8 @@ export type VideoData = Omit & { inheritedPasswordSources?: SpaceRuleSource[]; inheritedSpaceSettings?: Partial>; orgSettings?: OrganizationSettings | null; + hasActiveUpload?: boolean; + activeUploadRawFileKey?: string | null; }; export type VideoOwner = { diff --git a/apps/web/components/UpgradeModal.tsx b/apps/web/components/UpgradeModal.tsx index 51958952567..9cb08c9de41 100644 --- a/apps/web/components/UpgradeModal.tsx +++ b/apps/web/components/UpgradeModal.tsx @@ -30,6 +30,7 @@ interface UpgradeModalProps { onboarding?: boolean; onOpenChange: (open: boolean) => void; onCheckout?: () => Promise; + dismissible?: boolean; } const modalVariants = { @@ -64,6 +65,7 @@ const UpgradeModalImpl = ({ onOpenChange, onCheckout, onboarding, + dismissible = true, }: UpgradeModalProps) => { const stripeCtx = useStripeContext(); const [isAnnual, setIsAnnual] = useState(true); @@ -180,11 +182,25 @@ const UpgradeModalImpl = ({ }, }); + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen || dismissible) { + onOpenChange(nextOpen); + } + }; + return ( - + { + if (!dismissible) event.preventDefault(); + }} + onInteractOutside={(event) => { + if (!dismissible) event.preventDefault(); + }} + className={[ + "sm:max-w-[1100px] w-[calc(100%-20px)] custom-scroll bg-gray-2 border border-gray-4 overflow-y-auto md:overflow-hidden max-h-[90vh] p-0", + dismissible ? "" : "[&>button:last-child]:hidden", + ].join(" ")} > {open && ( @@ -287,13 +303,15 @@ const UpgradeModalImpl = ({ ? "Loading..." : "Upgrade to Cap Pro"} - + {dismissible && ( + + )}
diff --git a/apps/web/lib/folder.ts b/apps/web/lib/folder.ts index f855a36b1ec..9c0424f4af2 100644 --- a/apps/web/lib/folder.ts +++ b/apps/web/lib/folder.ts @@ -186,6 +186,8 @@ export const getVideosByFolderId = Effect.fn(function* ( createdAt: videos.createdAt, public: videos.public, metadata: videos.metadata, + source: videos.source, + isScreenshot: videos.isScreenshot, duration: videos.duration, settings: videos.settings, orgId: videos.orgId, @@ -247,6 +249,8 @@ export const getVideosByFolderId = Effect.fn(function* ( videos.createdAt, videos.public, videos.metadata, + videos.source, + videos.isScreenshot, videos.duration, videos.settings, videos.orgId, @@ -332,6 +336,8 @@ export const getVideosByFolderId = Effect.fn(function* ( [key: string]: unknown; } | undefined, + source: video.source, + isScreenshot: video.isScreenshot, hasPassword: video.hasPassword, hasInheritedPassword: rules.hasInheritedPassword, inheritedPasswordSources: rules.inheritedPasswordSources, diff --git a/apps/web/lib/video-edit-drafts.ts b/apps/web/lib/video-edit-drafts.ts new file mode 100644 index 00000000000..8e1506217f2 --- /dev/null +++ b/apps/web/lib/video-edit-drafts.ts @@ -0,0 +1,126 @@ +import type { VideoTimelineState } from "@/lib/video-edits"; +import { normalizeTimelineState } from "@/lib/video-edits"; + +export type TimelineDraftStorage = Pick< + Storage, + "getItem" | "removeItem" | "setItem" +>; + +const TIMELINE_DRAFT_VERSION = 1; + +type StoredTimelineDraft = { + version: typeof TIMELINE_DRAFT_VERSION; + duration: number; + state: VideoTimelineState; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isFiniteNumberValue(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isStoredEditRange(value: unknown) { + return ( + isRecord(value) && + isFiniteNumberValue(value.start) && + isFiniteNumberValue(value.end) + ); +} + +function isStoredTimelineState(value: unknown): value is VideoTimelineState { + return ( + isRecord(value) && + isFiniteNumberValue(value.duration) && + isFiniteNumberValue(value.trimStart) && + isFiniteNumberValue(value.trimEnd) && + Array.isArray(value.splitPoints) && + value.splitPoints.every(isFiniteNumberValue) && + Array.isArray(value.deletedRanges) && + value.deletedRanges.every(isStoredEditRange) && + (value.selectedSegmentId === null || + typeof value.selectedSegmentId === "string") + ); +} + +export function getTimelineDraftKey(videoId: string) { + return `cap:edit-timeline-draft:${videoId}`; +} + +export function getTimelineDraftStorage(): TimelineDraftStorage | null { + try { + return window.localStorage; + } catch { + return null; + } +} + +export function serializeTimelineDraft( + duration: number, + state: VideoTimelineState, +) { + const draft: StoredTimelineDraft = { + version: TIMELINE_DRAFT_VERSION, + duration, + state: normalizeTimelineState({ ...state, duration }), + }; + return JSON.stringify(draft); +} + +export function parseTimelineDraft(raw: string | null, duration: number) { + if (!raw) return null; + + try { + const parsed: unknown = JSON.parse(raw); + if ( + !isRecord(parsed) || + parsed.version !== TIMELINE_DRAFT_VERSION || + !isFiniteNumberValue(parsed.duration) || + Math.abs(parsed.duration - duration) > 0.01 || + !isStoredTimelineState(parsed.state) + ) { + return null; + } + return normalizeTimelineState({ ...parsed.state, duration }); + } catch { + return null; + } +} + +export function readTimelineDraft( + storage: TimelineDraftStorage, + storageKey: string, + duration: number, +) { + try { + return parseTimelineDraft(storage.getItem(storageKey), duration); + } catch { + return null; + } +} + +export function writeTimelineDraft( + storage: TimelineDraftStorage, + storageKey: string, + duration: number, + state: VideoTimelineState, +) { + try { + storage.setItem(storageKey, serializeTimelineDraft(duration, state)); + } catch { + return; + } +} + +export function clearTimelineDraft( + storage: TimelineDraftStorage, + storageKey: string, +) { + try { + storage.removeItem(storageKey); + } catch { + return; + } +} diff --git a/apps/web/lib/video-edit-processing.ts b/apps/web/lib/video-edit-processing.ts new file mode 100644 index 00000000000..ac818cc40f1 --- /dev/null +++ b/apps/web/lib/video-edit-processing.ts @@ -0,0 +1,101 @@ +import { db } from "@cap/database"; +import { videos, videoUploads } from "@cap/database/schema"; +import type { Video } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const STALE_EDIT_PROCESSING_START_MS = 15 * MINUTE; +const STALE_EDIT_PROCESSING_PROGRESS_MS = 10 * MINUTE; +const STALE_EDIT_THUMBNAIL_MS = 5 * MINUTE; + +type UploadPhase = + | "uploading" + | "processing" + | "generating_thumbnail" + | "complete" + | "error"; + +export function getEditSourceKey(ownerId: string, videoId: string) { + return `${ownerId}/${videoId}/source/original.mp4`; +} + +export function isEditSourceKey({ + ownerId, + videoId, + rawFileKey, +}: { + ownerId: string; + videoId: string; + rawFileKey: string | null | undefined; +}) { + return rawFileKey === getEditSourceKey(ownerId, videoId); +} + +function shouldClearEditUpload(input: { + phase: UploadPhase; + updatedAt: Date; + processingProgress: number; +}) { + if (input.phase === "error") { + return true; + } + + const ageMs = Date.now() - input.updatedAt.getTime(); + + if (input.phase === "complete") { + return ageMs > STALE_EDIT_THUMBNAIL_MS; + } + + if (input.phase === "processing") { + if ( + input.processingProgress === 0 && + ageMs > STALE_EDIT_PROCESSING_START_MS + ) { + return true; + } + + return ageMs > STALE_EDIT_PROCESSING_PROGRESS_MS; + } + + if (input.phase === "generating_thumbnail") { + return ageMs > STALE_EDIT_THUMBNAIL_MS; + } + + return false; +} + +export async function reconcileStaleEditUpload(videoId: Video.VideoId) { + const [record] = await db() + .select({ + ownerId: videos.ownerId, + rawFileKey: videoUploads.rawFileKey, + phase: videoUploads.phase, + updatedAt: videoUploads.updatedAt, + processingProgress: videoUploads.processingProgress, + }) + .from(videos) + .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) + .where(eq(videos.id, videoId)); + + if ( + !record?.phase || + !record.updatedAt || + record.processingProgress == null || + !isEditSourceKey({ + ownerId: record.ownerId, + videoId, + rawFileKey: record.rawFileKey, + }) || + !shouldClearEditUpload({ + phase: record.phase, + updatedAt: record.updatedAt, + processingProgress: record.processingProgress, + }) + ) { + return false; + } + + await db().delete(videoUploads).where(eq(videoUploads.videoId, videoId)); + return true; +} diff --git a/apps/web/lib/video-edits.ts b/apps/web/lib/video-edits.ts new file mode 100644 index 00000000000..8e25216a52b --- /dev/null +++ b/apps/web/lib/video-edits.ts @@ -0,0 +1,953 @@ +import type { VideoEditRange, VideoEditSpec } from "@cap/database/types"; + +const EPSILON = 0.001; +const MIN_RANGE_DURATION = 0.05; + +export type VideoTimelineState = { + duration: number; + trimStart: number; + trimEnd: number; + splitPoints: number[]; + deletedRanges: VideoEditRange[]; + selectedSegmentId: string | null; +}; + +export type VideoTimelineSegment = VideoEditRange & { + id: string; + deleted: boolean; + selected: boolean; +}; + +export type VideoTimelineDisplaySegment = VideoTimelineSegment & { + displayStart: number; + displayEnd: number; +}; + +export type VideoTimelineDisplaySplitPoint = { + id: string; + time: number; + sourceTime: number; + sourceTimes: number[]; + splitIndices: number[]; +}; + +export type VideoTimelineDisplaySplitDragHandle = "center" | "left" | "right"; + +export type TimelineHistory = { + entries: VideoTimelineState[]; + index: number; +}; + +const isFiniteNumber = (value: number) => Number.isFinite(value); + +export function roundEditTime(value: number) { + return Math.round(value * 1000) / 1000; +} + +export function clampEditTime(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function normalizeDuration(duration: number) { + return isFiniteNumber(duration) && duration > 0 ? roundEditTime(duration) : 0; +} + +function getSegmentId(start: number, end: number) { + return `${roundEditTime(start)}:${roundEditTime(end)}`; +} + +function getDisplayDeletedRanges(state: VideoTimelineState) { + return normalizeKeepRanges(state.deletedRanges, state.duration).keepRanges; +} + +export function normalizeKeepRanges( + keepRanges: VideoEditRange[], + sourceDuration: number, +): VideoEditSpec { + const duration = normalizeDuration(sourceDuration); + if (duration <= 0) { + return { version: 1, sourceDuration: 0, keepRanges: [] }; + } + + const sortedRanges = keepRanges + .map((range) => { + const start = isFiniteNumber(range.start) + ? clampEditTime(range.start, 0, duration) + : 0; + const end = isFiniteNumber(range.end) + ? clampEditTime(range.end, 0, duration) + : 0; + return { + start: roundEditTime(Math.min(start, end)), + end: roundEditTime(Math.max(start, end)), + }; + }) + .filter((range) => range.end - range.start >= MIN_RANGE_DURATION) + .sort((a, b) => a.start - b.start || a.end - b.end); + + const mergedRanges: VideoEditRange[] = []; + for (const range of sortedRanges) { + const previous = mergedRanges.at(-1); + if (previous && range.start <= previous.end + EPSILON) { + previous.end = roundEditTime(Math.max(previous.end, range.end)); + continue; + } + mergedRanges.push({ ...range }); + } + + return { + version: 1, + sourceDuration: duration, + keepRanges: mergedRanges, + }; +} + +export function createIdentityEditSpec(sourceDuration: number): VideoEditSpec { + const duration = normalizeDuration(sourceDuration); + return normalizeKeepRanges( + duration > 0 ? [{ start: 0, end: duration }] : [], + duration, + ); +} + +export function areEditSpecsEquivalent( + left: VideoEditSpec, + right: VideoEditSpec, +) { + const normalizedLeft = normalizeKeepRanges( + left.keepRanges, + left.sourceDuration, + ); + const normalizedRight = normalizeKeepRanges( + right.keepRanges, + right.sourceDuration, + ); + + if ( + Math.abs(normalizedLeft.sourceDuration - normalizedRight.sourceDuration) > + EPSILON + ) { + return false; + } + + if (normalizedLeft.keepRanges.length !== normalizedRight.keepRanges.length) { + return false; + } + + return normalizedLeft.keepRanges.every((leftRange, index) => { + const rightRange = normalizedRight.keepRanges[index]; + return ( + rightRange !== undefined && + Math.abs(leftRange.start - rightRange.start) <= EPSILON && + Math.abs(leftRange.end - rightRange.end) <= EPSILON + ); + }); +} + +export function areTimelineStatesEquivalent( + left: VideoTimelineState, + right: VideoTimelineState, +) { + const normalizedLeft = normalizeTimelineState(left); + const normalizedRight = normalizeTimelineState(right); + + if ( + Math.abs(normalizedLeft.duration - normalizedRight.duration) > EPSILON || + Math.abs(normalizedLeft.trimStart - normalizedRight.trimStart) > EPSILON || + Math.abs(normalizedLeft.trimEnd - normalizedRight.trimEnd) > EPSILON + ) { + return false; + } + + if ( + normalizedLeft.splitPoints.length !== normalizedRight.splitPoints.length || + normalizedLeft.deletedRanges.length !== normalizedRight.deletedRanges.length + ) { + return false; + } + + return ( + normalizedLeft.splitPoints.every( + (point, index) => + Math.abs(point - (normalizedRight.splitPoints[index] ?? 0)) <= EPSILON, + ) && + normalizedLeft.deletedRanges.every((leftRange, index) => { + const rightRange = normalizedRight.deletedRanges[index]; + return ( + rightRange !== undefined && + Math.abs(leftRange.start - rightRange.start) <= EPSILON && + Math.abs(leftRange.end - rightRange.end) <= EPSILON + ); + }) + ); +} + +export function getEditSpecOutputDuration(editSpec: VideoEditSpec) { + return roundEditTime( + editSpec.keepRanges.reduce( + (total, range) => total + Math.max(0, range.end - range.start), + 0, + ), + ); +} + +export function mapSourceTimeToOutputTime( + sourceTime: number, + editSpec: VideoEditSpec, +) { + if (!isFiniteNumber(sourceTime)) return null; + + const normalized = normalizeKeepRanges( + editSpec.keepRanges, + editSpec.sourceDuration, + ); + let outputTime = 0; + + for (const range of normalized.keepRanges) { + if ( + sourceTime >= range.start - EPSILON && + sourceTime <= range.end + EPSILON + ) { + return roundEditTime( + outputTime + + clampEditTime(sourceTime - range.start, 0, range.end - range.start), + ); + } + outputTime += range.end - range.start; + } + + return null; +} + +export function mapOutputTimeToSourceTime( + outputTime: number, + editSpec: VideoEditSpec, +) { + if (!isFiniteNumber(outputTime)) return null; + + const normalized = normalizeKeepRanges( + editSpec.keepRanges, + editSpec.sourceDuration, + ); + let elapsed = 0; + + for (const range of normalized.keepRanges) { + const rangeDuration = range.end - range.start; + if (outputTime <= elapsed + rangeDuration + EPSILON) { + return roundEditTime( + range.start + clampEditTime(outputTime - elapsed, 0, rangeDuration), + ); + } + elapsed += rangeDuration; + } + + return null; +} + +export function mapOutputRangeToSourceRanges( + outputRange: VideoEditRange, + editSpec: VideoEditSpec, +) { + const normalized = normalizeKeepRanges( + editSpec.keepRanges, + editSpec.sourceDuration, + ); + const sourceRanges: VideoEditRange[] = []; + let outputCursor = 0; + + for (const sourceRange of normalized.keepRanges) { + const sourceRangeDuration = sourceRange.end - sourceRange.start; + const outputStart = outputCursor; + const outputEnd = outputCursor + sourceRangeDuration; + const overlapStart = Math.max(outputRange.start, outputStart); + const overlapEnd = Math.min(outputRange.end, outputEnd); + + if (overlapEnd - overlapStart >= MIN_RANGE_DURATION) { + sourceRanges.push({ + start: roundEditTime(sourceRange.start + overlapStart - outputStart), + end: roundEditTime(sourceRange.start + overlapEnd - outputStart), + }); + } + + outputCursor = outputEnd; + } + + return normalizeKeepRanges(sourceRanges, normalized.sourceDuration) + .keepRanges; +} + +export function composeEditSpecs( + previousSourceSpec: VideoEditSpec, + nextOutputSpec: VideoEditSpec, +) { + const previous = normalizeKeepRanges( + previousSourceSpec.keepRanges, + previousSourceSpec.sourceDuration, + ); + const previousOutputDuration = getEditSpecOutputDuration(previous); + const next = normalizeKeepRanges( + nextOutputSpec.keepRanges, + previousOutputDuration, + ); + const sourceRanges = next.keepRanges.flatMap((range) => + mapOutputRangeToSourceRanges(range, previous), + ); + + return normalizeKeepRanges(sourceRanges, previous.sourceDuration); +} + +export function remapCurrentOutputTimeThroughEdit( + currentOutputTime: number | null, + previousSourceSpec: VideoEditSpec, + nextSourceSpec: VideoEditSpec, +) { + if (currentOutputTime === null) return null; + const sourceTime = mapOutputTimeToSourceTime( + currentOutputTime, + previousSourceSpec, + ); + if (sourceTime === null) return null; + return mapSourceTimeToOutputTime(sourceTime, nextSourceSpec); +} + +export function subtractRanges( + baseRanges: VideoEditRange[], + deletedRanges: VideoEditRange[], + sourceDuration: number, +) { + let ranges = normalizeKeepRanges(baseRanges, sourceDuration).keepRanges; + const deleted = normalizeKeepRanges(deletedRanges, sourceDuration).keepRanges; + + for (const deletedRange of deleted) { + ranges = ranges.flatMap((range) => { + if ( + deletedRange.end <= range.start + EPSILON || + deletedRange.start >= range.end - EPSILON + ) { + return [range]; + } + + const nextRanges: VideoEditRange[] = []; + if (deletedRange.start - range.start >= MIN_RANGE_DURATION) { + nextRanges.push({ + start: range.start, + end: roundEditTime(deletedRange.start), + }); + } + if (range.end - deletedRange.end >= MIN_RANGE_DURATION) { + nextRanges.push({ + start: roundEditTime(deletedRange.end), + end: range.end, + }); + } + return nextRanges; + }); + } + + return normalizeKeepRanges(ranges, sourceDuration).keepRanges; +} + +export function createTimelineState(duration: number): VideoTimelineState { + const normalizedDuration = normalizeDuration(duration); + return { + duration: normalizedDuration, + trimStart: 0, + trimEnd: normalizedDuration, + splitPoints: [], + deletedRanges: [], + selectedSegmentId: null, + }; +} + +export function normalizeTimelineState( + state: VideoTimelineState, +): VideoTimelineState { + const duration = normalizeDuration(state.duration); + const trimStart = clampEditTime(state.trimStart, 0, duration); + const trimEnd = clampEditTime(state.trimEnd, 0, duration); + const start = roundEditTime(Math.min(trimStart, trimEnd)); + const end = roundEditTime(Math.max(trimStart, trimEnd)); + const rawSplitPoints = Array.from( + new Set( + state.splitPoints + .filter((point) => isFiniteNumber(point)) + .map((point) => roundEditTime(clampEditTime(point, start, end))) + .filter( + (point) => + point - start >= MIN_RANGE_DURATION && + end - point >= MIN_RANGE_DURATION, + ), + ), + ).sort((a, b) => a - b); + const deletedRanges = subtractRanges( + normalizeKeepRanges(state.deletedRanges, duration).keepRanges, + [], + duration, + ).filter((range) => range.end > start && range.start < end); + const splitPoints = rawSplitPoints.filter( + (point) => + !deletedRanges.some( + (range) => point > range.start + EPSILON && point < range.end - EPSILON, + ), + ); + const segments = getTimelineSegments({ + ...state, + duration, + trimStart: start, + trimEnd: end, + splitPoints, + deletedRanges, + }); + const selectedSegmentId = + state.selectedSegmentId && + segments.some((segment) => segment.id === state.selectedSegmentId) + ? state.selectedSegmentId + : null; + + return { + duration, + trimStart: start, + trimEnd: end, + splitPoints, + deletedRanges, + selectedSegmentId, + }; +} + +export function getTimelineSegments( + state: VideoTimelineState, +): VideoTimelineSegment[] { + const boundaries = [ + state.trimStart, + ...state.splitPoints.filter( + (point) => point > state.trimStart && point < state.trimEnd, + ), + state.trimEnd, + ] + .map(roundEditTime) + .sort((a, b) => a - b); + + const segments: VideoTimelineSegment[] = []; + for (let index = 0; index < boundaries.length - 1; index++) { + const start = boundaries[index] ?? 0; + const end = boundaries[index + 1] ?? 0; + if (end - start < MIN_RANGE_DURATION) continue; + + const id = getSegmentId(start, end); + const midpoint = start + (end - start) / 2; + const deleted = state.deletedRanges.some( + (range) => + midpoint >= range.start - EPSILON && midpoint <= range.end + EPSILON, + ); + segments.push({ + id, + start, + end, + deleted, + selected: state.selectedSegmentId === id, + }); + } + + return segments; +} + +export function getTimelineDisplayDuration(state: VideoTimelineState) { + const duration = normalizeDuration(state.duration); + const deletedDuration = getDisplayDeletedRanges({ + ...state, + duration, + }).reduce((total, range) => total + Math.max(0, range.end - range.start), 0); + return roundEditTime(Math.max(0, duration - deletedDuration)); +} + +export function mapTimelineSourceTimeToDisplayTime( + state: VideoTimelineState, + sourceTime: number, +) { + const duration = normalizeDuration(state.duration); + if (duration <= 0 || !isFiniteNumber(sourceTime)) return 0; + + const time = clampEditTime(sourceTime, 0, duration); + let deletedBefore = 0; + for (const range of getDisplayDeletedRanges({ ...state, duration })) { + if (time <= range.start + EPSILON) break; + if (time < range.end - EPSILON) { + return roundEditTime(range.start - deletedBefore); + } + deletedBefore += range.end - range.start; + } + + return roundEditTime(Math.max(0, time - deletedBefore)); +} + +export function mapTimelineDisplayTimeToSourceTime( + state: VideoTimelineState, + displayTime: number, +) { + const duration = normalizeDuration(state.duration); + const displayDuration = getTimelineDisplayDuration({ ...state, duration }); + if (duration <= 0 || displayDuration <= 0 || !isFiniteNumber(displayTime)) { + return 0; + } + + const time = clampEditTime(displayTime, 0, displayDuration); + let sourceCursor = 0; + let displayCursor = 0; + for (const range of getDisplayDeletedRanges({ ...state, duration })) { + const keptDuration = Math.max(0, range.start - sourceCursor); + const displayEnd = displayCursor + keptDuration; + if (time <= displayEnd + EPSILON) { + return roundEditTime( + clampEditTime( + sourceCursor + time - displayCursor, + sourceCursor, + range.start, + ), + ); + } + sourceCursor = range.end; + displayCursor = displayEnd; + } + + return roundEditTime( + clampEditTime(sourceCursor + time - displayCursor, sourceCursor, duration), + ); +} + +export function getTimelineDisplaySegments( + state: VideoTimelineState, +): VideoTimelineDisplaySegment[] { + const normalized = normalizeTimelineState(state); + const keepRanges = getTimelineKeepRanges(normalized); + const segments: VideoTimelineDisplaySegment[] = []; + + for (const range of keepRanges) { + const boundaries = [ + range.start, + ...normalized.splitPoints.filter( + (point) => point > range.start + EPSILON && point < range.end - EPSILON, + ), + range.end, + ].sort((a, b) => a - b); + + for (let index = 0; index < boundaries.length - 1; index++) { + const start = boundaries[index] ?? 0; + const end = boundaries[index + 1] ?? 0; + if (end - start < MIN_RANGE_DURATION) continue; + + const id = getSegmentId(start, end); + const displayStart = mapTimelineSourceTimeToDisplayTime( + normalized, + start, + ); + const displayEnd = mapTimelineSourceTimeToDisplayTime(normalized, end); + if (displayEnd - displayStart < MIN_RANGE_DURATION) continue; + + segments.push({ + id, + start, + end, + displayStart, + displayEnd, + deleted: false, + selected: normalized.selectedSegmentId === id, + }); + } + } + + return segments; +} + +export function getTimelineDisplaySplitPoints( + state: VideoTimelineState, +): VideoTimelineDisplaySplitPoint[] { + const normalized = normalizeTimelineState(state); + const segments = getTimelineDisplaySegments(normalized); + const sortedSplitPoints = [...normalized.splitPoints].sort((a, b) => a - b); + const markers: VideoTimelineDisplaySplitPoint[] = []; + + for (let index = 0; index < segments.length - 1; index++) { + const current = segments[index]; + const next = segments[index + 1]; + if (!current || !next) continue; + + const sourceTimes = [current.end]; + if (Math.abs(current.end - next.start) > EPSILON) { + sourceTimes.push(next.start); + } + + const splitIndices = sortedSplitPoints.flatMap((point, splitIndex) => + sourceTimes.some((sourceTime) => Math.abs(point - sourceTime) <= EPSILON) + ? [splitIndex] + : [], + ); + const time = current.displayEnd; + markers.push({ + id: `${roundEditTime(time)}:${sourceTimes.map(roundEditTime).join(":")}`, + time, + sourceTime: current.end, + sourceTimes, + splitIndices, + }); + } + + return markers; +} + +function getTimelineDisplaySplitDragSourceTime( + splitPoint: VideoTimelineDisplaySplitPoint, + handle: VideoTimelineDisplaySplitDragHandle, +) { + if (splitPoint.sourceTimes.length === 1) return splitPoint.sourceTime; + + if (handle === "left") return Math.min(...splitPoint.sourceTimes); + if (handle === "right") return Math.max(...splitPoint.sourceTimes); + + return splitPoint.sourceTime; +} + +export function getTimelineDisplaySplitDragTargetTime( + state: VideoTimelineState, + splitPointIndex: number, + handle: VideoTimelineDisplaySplitDragHandle, + sourceTime: number, +) { + const splitPoint = getTimelineDisplaySplitPoints(state)[splitPointIndex]; + if (!splitPoint || !isFiniteNumber(sourceTime)) return null; + if (splitPoint.sourceTimes.length === 1) return sourceTime; + + const leftSourceTime = Math.min(...splitPoint.sourceTimes); + const rightSourceTime = Math.max(...splitPoint.sourceTimes); + if (handle === "left") return Math.min(sourceTime, leftSourceTime); + if (handle === "right") return Math.max(sourceTime, rightSourceTime); + + if (sourceTime <= leftSourceTime + EPSILON) return sourceTime; + if (sourceTime >= rightSourceTime - EPSILON) return sourceTime; + + return sourceTime - leftSourceTime < rightSourceTime - sourceTime + ? leftSourceTime + : rightSourceTime; +} + +export function dragTimelineDisplaySplitPoint( + state: VideoTimelineState, + splitPointIndex: number, + handle: VideoTimelineDisplaySplitDragHandle, + sourceTime: number, +) { + const splitPoint = getTimelineDisplaySplitPoints(state)[splitPointIndex]; + if (!splitPoint) return state; + + const targetTime = getTimelineDisplaySplitDragTargetTime( + state, + splitPointIndex, + handle, + sourceTime, + ); + if (targetTime === null) return state; + + return dragSplitForShrink( + state, + getTimelineDisplaySplitDragSourceTime(splitPoint, handle), + targetTime, + ); +} + +export function removeTimelineDisplaySplitPoint( + state: VideoTimelineState, + splitPointIndex: number, +): VideoTimelineState { + const splitPoint = getTimelineDisplaySplitPoints(state)[splitPointIndex]; + if (!splitPoint) return state; + + if (splitPoint.sourceTimes.length === 1) { + const splitIndex = splitPoint.splitIndices[0]; + return splitIndex === undefined + ? state + : removeSplitPoint(state, splitIndex); + } + + const restoreStart = Math.min(...splitPoint.sourceTimes); + const restoreEnd = Math.max(...splitPoint.sourceTimes); + const deletedRanges = normalizeKeepRanges( + state.deletedRanges, + state.duration, + ).keepRanges.flatMap((range) => { + if ( + range.end <= restoreStart + EPSILON || + range.start >= restoreEnd - EPSILON + ) { + return [range]; + } + + const nextRanges: VideoEditRange[] = []; + if (restoreStart - range.start >= MIN_RANGE_DURATION) { + nextRanges.push({ start: range.start, end: restoreStart }); + } + if (range.end - restoreEnd >= MIN_RANGE_DURATION) { + nextRanges.push({ start: restoreEnd, end: range.end }); + } + return nextRanges; + }); + const splitPoints = state.splitPoints.filter( + (point) => + !splitPoint.sourceTimes.some( + (sourceTime) => Math.abs(point - sourceTime) <= EPSILON, + ), + ); + + return normalizeTimelineState({ + ...state, + splitPoints, + deletedRanges, + selectedSegmentId: null, + }); +} + +export function selectTimelineSegment( + state: VideoTimelineState, + segmentId: string, +): VideoTimelineState { + const segments = getTimelineSegments(state); + const segment = segments.find((segment) => segment.id === segmentId); + if (!segment || segment.deleted) return state; + return normalizeTimelineState({ ...state, selectedSegmentId: segmentId }); +} + +export function splitTimelineAt( + state: VideoTimelineState, + playheadTime: number, +): VideoTimelineState { + if ( + !isFiniteNumber(playheadTime) || + playheadTime - state.trimStart < MIN_RANGE_DURATION || + state.trimEnd - playheadTime < MIN_RANGE_DURATION + ) { + return state; + } + + const normalizedTime = roundEditTime( + clampEditTime(playheadTime, state.trimStart, state.trimEnd), + ); + const isDuplicate = state.splitPoints.some( + (point) => Math.abs(point - normalizedTime) < MIN_RANGE_DURATION, + ); + const isDeleted = state.deletedRanges.some( + (range) => + normalizedTime > range.start + EPSILON && + normalizedTime < range.end - EPSILON, + ); + + if (isDuplicate || isDeleted) return state; + + return normalizeTimelineState({ + ...state, + splitPoints: [...state.splitPoints, normalizedTime], + selectedSegmentId: null, + }); +} + +export function deleteSelectedTimelineSegment( + state: VideoTimelineState, +): VideoTimelineState { + if (!state.selectedSegmentId) return state; + const segment = getTimelineSegments(state).find( + (segment) => segment.id === state.selectedSegmentId, + ); + if (!segment || segment.deleted) return state; + + return normalizeTimelineState({ + ...state, + deletedRanges: [...state.deletedRanges, segment], + selectedSegmentId: null, + }); +} + +export function setTimelineTrim( + state: VideoTimelineState, + start: number, + end: number, +): VideoTimelineState { + const trimStart = clampEditTime(start, 0, state.duration); + const trimEnd = clampEditTime(end, 0, state.duration); + if (Math.abs(trimEnd - trimStart) < MIN_RANGE_DURATION) return state; + + return normalizeTimelineState({ + ...state, + trimStart, + trimEnd, + selectedSegmentId: null, + }); +} + +export function moveSplitPoint( + state: VideoTimelineState, + splitIndex: number, + newTime: number, +): VideoTimelineState { + const sorted = [...state.splitPoints].sort((a, b) => a - b); + if (splitIndex < 0 || splitIndex >= sorted.length) return state; + const lowerBound = + splitIndex > 0 + ? (sorted[splitIndex - 1] ?? state.trimStart) + : state.trimStart; + const upperBound = + splitIndex < sorted.length - 1 + ? (sorted[splitIndex + 1] ?? state.trimEnd) + : state.trimEnd; + sorted[splitIndex] = clampEditTime( + newTime, + lowerBound + MIN_RANGE_DURATION, + upperBound - MIN_RANGE_DURATION, + ); + return normalizeTimelineState({ + ...state, + splitPoints: sorted, + selectedSegmentId: null, + }); +} + +export function removeSplitPoint( + state: VideoTimelineState, + splitIndex: number, +): VideoTimelineState { + const sorted = [...state.splitPoints].sort((a, b) => a - b); + if (splitIndex < 0 || splitIndex >= sorted.length) return state; + const currentSegments = getTimelineSegments(state); + sorted.splice(splitIndex, 1); + const nextSegments = getTimelineSegments({ ...state, splitPoints: sorted }); + const deletedRanges = nextSegments + .filter((segment) => { + const coveredSegments = currentSegments.filter( + (currentSegment) => + currentSegment.start >= segment.start - EPSILON && + currentSegment.end <= segment.end + EPSILON, + ); + return ( + coveredSegments.length > 0 && + coveredSegments.every((currentSegment) => currentSegment.deleted) + ); + }) + .map(({ start, end }) => ({ start, end })); + return normalizeTimelineState({ + ...state, + splitPoints: sorted, + deletedRanges, + selectedSegmentId: null, + }); +} + +export function dragSplitForShrink( + state: VideoTimelineState, + originalPos: number, + newPos: number, +): VideoTimelineState { + if (Math.abs(newPos - originalPos) < EPSILON) return state; + const lo = clampEditTime( + Math.min(originalPos, newPos), + state.trimStart, + state.trimEnd, + ); + const hi = clampEditTime( + Math.max(originalPos, newPos), + state.trimStart, + state.trimEnd, + ); + if (hi - lo < MIN_RANGE_DURATION) return state; + + const splits = [...state.splitPoints]; + if (!splits.some((p) => Math.abs(p - originalPos) < EPSILON)) { + splits.push(originalPos); + } + if (!splits.some((p) => Math.abs(p - newPos) < EPSILON)) { + splits.push(newPos); + } + + return normalizeTimelineState({ + ...state, + splitPoints: splits, + deletedRanges: [...state.deletedRanges, { start: lo, end: hi }], + selectedSegmentId: null, + }); +} + +export function getTimelineKeepRanges( + state: VideoTimelineState, +): VideoEditRange[] { + const normalized = normalizeTimelineState(state); + return subtractRanges( + [{ start: normalized.trimStart, end: normalized.trimEnd }], + normalized.deletedRanges, + normalized.duration, + ); +} + +export function getTimelineEditSpec(state: VideoTimelineState): VideoEditSpec { + const normalized = normalizeTimelineState(state); + return normalizeKeepRanges( + getTimelineKeepRanges(normalized), + normalized.duration, + ); +} + +export function findNextPlayableTime( + currentTime: number, + editSpec: VideoEditSpec, +) { + const normalized = normalizeKeepRanges( + editSpec.keepRanges, + editSpec.sourceDuration, + ); + if (normalized.keepRanges.length === 0) return null; + + for (const range of normalized.keepRanges) { + if (currentTime < range.start - EPSILON) return range.start; + if ( + currentTime >= range.start - EPSILON && + currentTime < range.end - EPSILON + ) { + return currentTime; + } + } + + return null; +} + +export function createTimelineHistory( + initialState: VideoTimelineState, +): TimelineHistory { + return { + entries: [normalizeTimelineState(initialState)], + index: 0, + }; +} + +export function pushTimelineHistory( + history: TimelineHistory, + nextState: VideoTimelineState, +): TimelineHistory { + const normalized = normalizeTimelineState(nextState); + const current = history.entries[history.index]; + if (current && JSON.stringify(current) === JSON.stringify(normalized)) { + return history; + } + + return { + entries: [...history.entries.slice(0, history.index + 1), normalized], + index: history.index + 1, + }; +} + +export function undoTimelineHistory(history: TimelineHistory): TimelineHistory { + return { + ...history, + index: Math.max(0, history.index - 1), + }; +} + +export function redoTimelineHistory(history: TimelineHistory): TimelineHistory { + return { + ...history, + index: Math.min(history.entries.length - 1, history.index + 1), + }; +} diff --git a/apps/web/public/.well-known/workflow/v1/manifest.json b/apps/web/public/.well-known/workflow/v1/manifest.json index 6c8d916ac00..ef34a31bfc1 100644 --- a/apps/web/public/.well-known/workflow/v1/manifest.json +++ b/apps/web/public/.well-known/workflow/v1/manifest.json @@ -1,7 +1,7 @@ { "version": "1.0.0", "steps": { - "node_modules/.pnpm/workflow@4.2.0-beta.73_@nes_2a6f07c63175c34d8d104665b34a32ad/node_modules/workflow/dist/stdlib.js": { + "node_modules/.pnpm/workflow@4.2.0-beta.73_@nestjs+common@11.1.17_reflect-metadata@0.2.2_rxjs@7.8.2__@nestj_2a6f07c63175c34d8d104665b34a32ad/node_modules/workflow/dist/stdlib.js": { "fetch": { "stepId": "step//workflow@4.2.0-beta.73//fetch" } @@ -54,6 +54,17 @@ "stepId": "step//./workflows/generate-ai//validateAndSetProcessing" } }, + "node_modules/.pnpm/workflow@4.2.0-beta.73_@nestjs+common@11.1.17_reflect-metadata@0.2.2_rxjs@7.8.2__@nestj_2a6f07c63175c34d8d104665b34a32ad/node_modules/workflow/dist/internal/builtins.js": { + "__builtin_response_array_buffer": { + "stepId": "__builtin_response_array_buffer" + }, + "__builtin_response_json": { + "stepId": "__builtin_response_json" + }, + "__builtin_response_text": { + "stepId": "__builtin_response_text" + } + }, "workflows/transcribe.ts": { "_enhanceAndSaveAudio": { "stepId": "step//./workflows/transcribe//_enhanceAndSaveAudio" @@ -86,15 +97,18 @@ "stepId": "step//./workflows/transcribe//validateVideo" } }, - "node_modules/.pnpm/workflow@4.2.0-beta.73_@nes_2a6f07c63175c34d8d104665b34a32ad/node_modules/workflow/dist/internal/builtins.js": { - "__builtin_response_array_buffer": { - "stepId": "__builtin_response_array_buffer" + "workflows/edit-video.ts": { + "renderVideoEditOnMediaServer": { + "stepId": "step//./workflows/edit-video//renderVideoEditOnMediaServer" }, - "__builtin_response_json": { - "stepId": "__builtin_response_json" + "saveMetadataAndComplete": { + "stepId": "step//./workflows/edit-video//saveMetadataAndComplete" }, - "__builtin_response_text": { - "stepId": "__builtin_response_text" + "setProcessingError": { + "stepId": "step//./workflows/edit-video//setProcessingError" + }, + "validateEditRequest": { + "stepId": "step//./workflows/edit-video//validateEditRequest" } } }, @@ -230,6 +244,39 @@ ] } } + }, + "workflows/edit-video.ts": { + "editVideoWorkflow": { + "workflowId": "workflow//./workflows/edit-video//editVideoWorkflow", + "graph": { + "nodes": [ + { + "id": "start", + "type": "workflowStart", + "data": { + "label": "Start: editVideoWorkflow", + "nodeKind": "workflow_start" + } + }, + { + "id": "end", + "type": "workflowEnd", + "data": { + "label": "Return", + "nodeKind": "workflow_end" + } + } + ], + "edges": [ + { + "id": "e_start_end", + "source": "start", + "target": "end", + "type": "default" + } + ] + } + } } }, "classes": {} diff --git a/apps/web/utils/view-transition.ts b/apps/web/utils/view-transition.ts new file mode 100644 index 00000000000..e4d086cb7cf --- /dev/null +++ b/apps/web/utils/view-transition.ts @@ -0,0 +1,23 @@ +export function navigateWithTransition( + transitionName: string, + navigate: () => void, +) { + if (typeof document === "undefined") { + navigate(); + return; + } + if (typeof document.startViewTransition !== "function") { + navigate(); + return; + } + const html = document.documentElement; + html.dataset.viewTransition = transitionName; + const transition = document.startViewTransition(() => { + navigate(); + }); + transition.finished.finally(() => { + if (html.dataset.viewTransition === transitionName) { + delete html.dataset.viewTransition; + } + }); +} diff --git a/apps/web/workflows/edit-video.ts b/apps/web/workflows/edit-video.ts new file mode 100644 index 00000000000..3160d636e22 --- /dev/null +++ b/apps/web/workflows/edit-video.ts @@ -0,0 +1,517 @@ +import { db } from "@cap/database"; +import { + comments, + videoEdits, + videos, + videoUploads, +} from "@cap/database/schema"; +import type { + VideoEditRange, + VideoEditSpec, + VideoMetadata, +} from "@cap/database/types"; +import { serverEnv } from "@cap/env"; +import { Storage } from "@cap/web-backend"; +import { Video } from "@cap/web-domain"; +import { and, eq } from "drizzle-orm"; +import { FatalError } from "workflow"; +import { runPromise } from "@/lib/server"; +import { remapCurrentOutputTimeThroughEdit } from "@/lib/video-edits"; +import { decodeStorageVideo } from "@/lib/video-storage"; + +interface EditVideoWorkflowPayload { + videoId: string; + userId: string; + sourceKey: string; + previousSpec: VideoEditSpec; + editSpec: VideoEditSpec; + keepRanges: VideoEditRange[]; +} + +interface VideoEditRenderResult { + metadata: { + duration: number; + width: number; + height: number; + fps: number; + }; +} + +const MEDIA_SERVER_START_MAX_ATTEMPTS = 6; +const MEDIA_SERVER_START_RETRY_BASE_MS = 2000; +const MEDIA_SERVER_COMPLETION_MAX_ATTEMPTS = 720; +const MEDIA_SERVER_COMPLETION_POLL_INTERVAL_MS = 5000; +const MEDIA_SERVER_PRESIGNED_GET_EXPIRES_SECONDS = 3 * 60 * 60; +const MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS = 3 * 60 * 60; + +function isPositiveNumber(value: number | null): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function getValidDuration(duration: number) { + return Number.isFinite(duration) && duration > 0 ? duration : undefined; +} + +async function waitForRetry(delayMs: number): Promise { + await new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +export async function editVideoWorkflow( + payload: EditVideoWorkflowPayload, +): Promise { + "use workflow"; + + const { videoId, sourceKey, previousSpec, editSpec } = payload; + + try { + await validateEditRequest(videoId, sourceKey); + const result = await renderVideoEditOnMediaServer(payload); + await saveEditResultAndComplete( + videoId, + sourceKey, + previousSpec, + editSpec, + result.metadata, + ); + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + await clearEditProcessingState(videoId, sourceKey); + throw new FatalError(errorMessage); + } +} + +async function validateEditRequest( + videoId: string, + sourceKey: string, +): Promise { + "use step"; + + if (!serverEnv().MEDIA_SERVER_URL) { + throw new FatalError("MEDIA_SERVER_URL is not configured"); + } + + const [video] = await db() + .select() + .from(videos) + .where(eq(videos.id, videoId as Video.VideoId)); + + if (!video) { + throw new FatalError("Video does not exist"); + } + + const [upload] = await db() + .select() + .from(videoUploads) + .where(eq(videoUploads.videoId, videoId as Video.VideoId)); + + if (!upload) { + throw new FatalError("Edit render does not exist"); + } + + if (upload.rawFileKey !== sourceKey) { + throw new FatalError("Edit source key does not match"); + } + + if (upload.phase !== "processing") { + throw new FatalError("Video is not ready for edit rendering"); + } +} + +async function startMediaServerEditJob( + mediaServerUrl: string, + body: { + videoId: string; + userId: string; + sourceUrl: string; + outputPresignedUrl: string; + thumbnailPresignedUrl: string; + previewGifPresignedUrl: string; + webhookUrl: string; + webhookSecret?: string; + keepRanges: VideoEditRange[]; + }, +): Promise { + for (let attempt = 0; attempt < MEDIA_SERVER_START_MAX_ATTEMPTS; attempt++) { + const headers: Record = { + "Content-Type": "application/json", + }; + if (body.webhookSecret) { + headers["x-media-server-secret"] = body.webhookSecret; + } + + const response = await fetch(`${mediaServerUrl}/video/edit`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (response.ok) { + const { jobId } = (await response.json()) as { jobId: string }; + return jobId; + } + + const errorData = (await response.json().catch(() => ({}))) as { + error?: string; + code?: string; + details?: string; + instanceId?: string; + pid?: number; + activeVideoProcesses?: number; + maxConcurrentVideoProcesses?: number; + jobCount?: number; + }; + const baseErrorMessage = + errorData.error || errorData.details || "Video edit failed to start"; + const busyDiagnostics = + errorData.code === "SERVER_BUSY" + ? [ + errorData.instanceId ? `instance=${errorData.instanceId}` : null, + typeof errorData.pid === "number" ? `pid=${errorData.pid}` : null, + typeof errorData.activeVideoProcesses === "number" && + typeof errorData.maxConcurrentVideoProcesses === "number" + ? `active=${errorData.activeVideoProcesses}/${errorData.maxConcurrentVideoProcesses}` + : null, + typeof errorData.jobCount === "number" + ? `jobCount=${errorData.jobCount}` + : null, + ] + .filter(Boolean) + .join(", ") + : ""; + const errorMessage = busyDiagnostics + ? `${baseErrorMessage} (${busyDiagnostics})` + : baseErrorMessage; + const shouldRetry = + response.status === 503 && + (errorData.code === "SERVER_BUSY" || + errorMessage.includes("Server is busy")); + + if (shouldRetry && attempt < MEDIA_SERVER_START_MAX_ATTEMPTS - 1) { + await waitForRetry(MEDIA_SERVER_START_RETRY_BASE_MS * 2 ** attempt); + continue; + } + + throw new Error(errorMessage); + } + + throw new Error("Video edit failed to start"); +} + +async function renderVideoEditOnMediaServer( + payload: EditVideoWorkflowPayload, +): Promise { + "use step"; + + const { videoId, userId, sourceKey, keepRanges } = payload; + const mediaServerUrl = serverEnv().MEDIA_SERVER_URL; + const webhookBaseUrl = + serverEnv().MEDIA_SERVER_WEBHOOK_URL || serverEnv().WEB_URL; + if (!mediaServerUrl) { + throw new FatalError("MEDIA_SERVER_URL is not configured"); + } + + const [video] = await db() + .select() + .from(videos) + .where(eq(videos.id, Video.VideoId.make(videoId))); + + if (!video) { + throw new FatalError("Video does not exist"); + } + + const [bucket] = await Storage.getAccessForVideo( + decodeStorageVideo(video), + ).pipe(runPromise); + + const sourceUrl = await bucket + .getInternalSignedObjectUrl(sourceKey, { + expiresIn: MEDIA_SERVER_PRESIGNED_GET_EXPIRES_SECONDS, + }) + .pipe(runPromise); + + const outputKey = `${userId}/${videoId}/result.mp4`; + const thumbnailKey = `${userId}/${videoId}/screenshot/screen-capture.jpg`; + const previewGifKey = `${userId}/${videoId}/preview/animated-preview.gif`; + + const outputPresignedUrl = await bucket + .getInternalPresignedPutUrl( + outputKey, + { + ContentType: "video/mp4", + }, + { expiresIn: MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS }, + ) + .pipe(runPromise); + + const thumbnailPresignedUrl = await bucket + .getInternalPresignedPutUrl( + thumbnailKey, + { + ContentType: "image/jpeg", + }, + { expiresIn: MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS }, + ) + .pipe(runPromise); + + const previewGifPresignedUrl = await bucket + .getInternalPresignedPutUrl( + previewGifKey, + { + ContentType: "image/gif", + CacheControl: "public, max-age=31536000, immutable", + }, + { expiresIn: MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS }, + ) + .pipe(runPromise); + + const webhookUrl = `${webhookBaseUrl}/api/webhooks/media-server/progress`; + const webhookSecret = serverEnv().MEDIA_SERVER_WEBHOOK_SECRET; + + await startMediaServerEditJob(mediaServerUrl, { + videoId, + userId, + sourceUrl, + outputPresignedUrl, + thumbnailPresignedUrl, + previewGifPresignedUrl, + webhookUrl, + webhookSecret: webhookSecret || undefined, + keepRanges, + }); + + return await waitForEditCompletion(videoId); +} + +function getMetadataFromVideoRow( + video: + | { + duration: number | null; + width: number | null; + height: number | null; + fps: number | null; + } + | undefined, +): VideoEditRenderResult["metadata"] | null { + if ( + !video || + !isPositiveNumber(video.width) || + !isPositiveNumber(video.height) || + !isPositiveNumber(video.fps) + ) { + return null; + } + + return { + duration: isPositiveNumber(video.duration) ? video.duration : 0, + width: video.width, + height: video.height, + fps: video.fps, + }; +} + +async function getCompletedMetadata( + videoId: string, +): Promise { + const [video] = await db() + .select({ + duration: videos.duration, + width: videos.width, + height: videos.height, + fps: videos.fps, + }) + .from(videos) + .where(eq(videos.id, videoId as Video.VideoId)); + + return getMetadataFromVideoRow(video); +} + +function clearAiMetadata(metadata: VideoMetadata | null): VideoMetadata { + const nextMetadata = { ...(metadata ?? {}) }; + delete nextMetadata.aiTitle; + delete nextMetadata.summary; + delete nextMetadata.chapters; + delete nextMetadata.aiGenerationStatus; + return nextMetadata; +} + +async function clearTranscriptObjects(video: typeof videos.$inferSelect) { + const [bucket] = await Storage.getAccessForVideo( + decodeStorageVideo(video), + ).pipe(runPromise); + const prefix = `${video.ownerId}/${video.id}/transcription`; + const listed = await bucket.listObjects({ prefix }).pipe(runPromise); + const objects = (listed.Contents ?? []) + .map((object) => ({ Key: object.Key })) + .filter((object): object is { Key: string } => Boolean(object.Key)); + + if (objects.length > 0) { + await bucket.deleteObjects(objects).pipe(runPromise); + } +} + +async function waitForEditCompletion( + videoId: string, +): Promise { + let lastStatus = "processing"; + + for ( + let attempt = 0; + attempt < MEDIA_SERVER_COMPLETION_MAX_ATTEMPTS; + attempt++ + ) { + await waitForRetry(MEDIA_SERVER_COMPLETION_POLL_INTERVAL_MS); + + const [upload] = await db() + .select({ + phase: videoUploads.phase, + processingProgress: videoUploads.processingProgress, + processingMessage: videoUploads.processingMessage, + processingError: videoUploads.processingError, + }) + .from(videoUploads) + .where(eq(videoUploads.videoId, videoId as Video.VideoId)); + + if (!upload) { + throw new Error("Edit processing state disappeared"); + } + + if (upload.phase === "complete") { + const metadata = await getCompletedMetadata(videoId); + if (!metadata) { + throw new Error("Edit completed but video metadata is missing"); + } + + return { metadata }; + } + + if (upload.phase === "error") { + throw new Error( + upload.processingError || + upload.processingMessage || + "Video edit failed", + ); + } + + lastStatus = [ + upload.phase, + typeof upload.processingProgress === "number" + ? `${upload.processingProgress}%` + : null, + upload.processingMessage, + ] + .filter(Boolean) + .join(" "); + } + + throw new Error(`Video edit timed out while ${lastStatus}`); +} + +async function saveEditResultAndComplete( + videoId: string, + sourceKey: string, + previousSpec: VideoEditSpec, + editSpec: VideoEditSpec, + metadata: { duration: number; width: number; height: number; fps: number }, +): Promise { + "use step"; + + const duration = getValidDuration(metadata.duration); + const [video] = await db() + .select() + .from(videos) + .where(eq(videos.id, videoId as Video.VideoId)); + + if (!video) { + throw new FatalError("Video does not exist"); + } + + const nextMetadata = clearAiMetadata(video.metadata as VideoMetadata | null); + + await db().transaction(async (tx) => { + await tx + .update(videos) + .set({ + width: metadata.width, + height: metadata.height, + fps: metadata.fps, + metadata: nextMetadata, + transcriptionStatus: null, + ...(duration === undefined ? {} : { duration }), + }) + .where(eq(videos.id, videoId as Video.VideoId)); + + await tx + .insert(videoEdits) + .values({ + videoId: videoId as Video.VideoId, + sourceKey, + editSpec, + updatedAt: new Date(), + }) + .onDuplicateKeyUpdate({ + set: { + sourceKey, + editSpec, + updatedAt: new Date(), + }, + }); + + const timestampedComments = await tx + .select({ + id: comments.id, + timestamp: comments.timestamp, + }) + .from(comments) + .where(eq(comments.videoId, videoId as Video.VideoId)); + + for (const comment of timestampedComments) { + if (comment.timestamp === null) continue; + const nextTimestamp = remapCurrentOutputTimeThroughEdit( + comment.timestamp, + previousSpec, + editSpec, + ); + if (nextTimestamp === comment.timestamp) continue; + await tx + .update(comments) + .set({ timestamp: nextTimestamp }) + .where(eq(comments.id, comment.id)); + } + + await tx + .delete(videoUploads) + .where( + and( + eq(videoUploads.videoId, videoId as Video.VideoId), + eq(videoUploads.phase, "complete"), + eq(videoUploads.rawFileKey, sourceKey), + ), + ); + }); + + try { + await clearTranscriptObjects(video); + } catch (error) { + console.warn( + "[editVideoWorkflow] Failed to clear transcript objects", + error, + ); + } +} + +async function clearEditProcessingState( + videoId: string, + sourceKey: string, +): Promise { + "use step"; + + await db() + .delete(videoUploads) + .where( + and( + eq(videoUploads.videoId, videoId as Video.VideoId), + eq(videoUploads.rawFileKey, sourceKey), + ), + ); +} diff --git a/packages/database/migrations/0024_many_speed.sql b/packages/database/migrations/0024_many_speed.sql new file mode 100644 index 00000000000..55604246cf9 --- /dev/null +++ b/packages/database/migrations/0024_many_speed.sql @@ -0,0 +1,10 @@ +CREATE TABLE `video_edits` ( + `videoId` varchar(15) NOT NULL, + `sourceKey` varchar(512) NOT NULL, + `editSpec` json NOT NULL, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `video_edits_videoId` PRIMARY KEY(`videoId`) +); +--> statement-breakpoint +ALTER TABLE `video_edits` ADD CONSTRAINT `video_edits_videoId_videos_id_fk` FOREIGN KEY (`videoId`) REFERENCES `videos`(`id`) ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/database/migrations/meta/0024_snapshot.json b/packages/database/migrations/meta/0024_snapshot.json new file mode 100644 index 00000000000..e4de3b6cba2 --- /dev/null +++ b/packages/database/migrations/meta/0024_snapshot.json @@ -0,0 +1,3251 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "ae365232-4098-4426-a0ba-b8b6aa46fb3b", + "prevId": "a9518362-ccdf-4f5d-bfdc-68e8f45bcba3", + "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": {} + }, + "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 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "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 + } + }, + "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'" + }, + "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 + } + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "imported_videos_orgId_source_source_id_pk": { + "name": "imported_videos_orgId_source_source_id_pk", + "columns": ["orgId", "source", "source_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": {} + }, + "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": {} + }, + "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 + }, + "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 + } + }, + "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": {} + }, + "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'" + } + }, + "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 + } + }, + "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 + }, + "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 + } + }, + "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_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": {}, + "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 + } + }, + "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": {} + } +} diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index cab5ef213d3..77f38a70707 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1778434170430, "tag": "0023_misty_luckman", "breakpoints": true + }, + { + "idx": 24, + "version": "5", + "when": 1778776694053, + "tag": "0024_many_speed", + "breakpoints": true } ] } diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 94563464864..4ee0cfe958c 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -31,7 +31,7 @@ import { import { relations } from "drizzle-orm/relations"; import { nanoIdLength } from "./helpers.ts"; -import type { VideoMetadata } from "./types/index.ts"; +import type { VideoEditSpec, VideoMetadata } from "./types/index.ts"; type GoogleDriveStorageQuotaCache = { limit?: string | null; @@ -378,6 +378,18 @@ export const videos = mysqlTable( ], ); +export const videoEdits = mysqlTable("video_edits", { + videoId: nanoId("videoId") + .notNull() + .primaryKey() + .$type() + .references(() => videos.id, { onDelete: "cascade" }), + sourceKey: varchar("sourceKey", { length: 512 }).notNull(), + editSpec: json("editSpec").notNull().$type(), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), +}); + export const sharedVideos = mysqlTable( "shared_videos", { @@ -868,6 +880,10 @@ export const videosRelations = relations(videos, ({ one, many }) => ({ }), sharedVideos: many(sharedVideos), spaceVideos: many(spaceVideos), + edit: one(videoEdits, { + fields: [videos.id], + references: [videoEdits.videoId], + }), folder: one(folders, { fields: [videos.folderId], references: [folders.id], @@ -878,6 +894,13 @@ export const videosRelations = relations(videos, ({ one, many }) => ({ }), })); +export const videoEditsRelations = relations(videoEdits, ({ one }) => ({ + video: one(videos, { + fields: [videoEdits.videoId], + references: [videos.id], + }), +})); + export const sharedVideosRelations = relations(sharedVideos, ({ one }) => ({ video: one(videos, { fields: [sharedVideos.videoId], diff --git a/packages/database/types/metadata.ts b/packages/database/types/metadata.ts index 05c96b33db1..2a0248f6fdc 100644 --- a/packages/database/types/metadata.ts +++ b/packages/database/types/metadata.ts @@ -36,6 +36,17 @@ export interface VideoMetadata { enhancedAudioStatus?: "PROCESSING" | "COMPLETE" | "ERROR" | "SKIPPED"; } +export type VideoEditRange = { + start: number; + end: number; +}; + +export type VideoEditSpec = { + version: 1; + sourceDuration: number; + keepRanges: VideoEditRange[]; +}; + /** * Space metadata structure */