diff --git a/.dockerignore b/.dockerignore index 049a67b5429..ffaf7181b21 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,5 @@ target crates **/node_modules **/.next +!apps/web/.next +!apps/web/.next/** diff --git a/.railwayignore b/.railwayignore new file mode 100644 index 00000000000..5a7407fbe32 --- /dev/null +++ b/.railwayignore @@ -0,0 +1,39 @@ +.git +.claude +.config +.cursor +.docs +.growth-agent +.next +.opencode +.sst +.tinyb +.turbo +.vercel +.vinxi +.vscode +.zed +analysis +crates +logs +node_modules +outputs +target +target-agent +target-phase8 +tmp +**/.next +**/.tinyb +**/node_modules +**/target +apps/storybook/storybook-static +apps/web/.next +apps/web/playwright-report +apps/web/test-results +native-deps* +*.local +*.log +*.tsbuildinfo +.DS_Store +.env +.env.* diff --git a/apps/cloudflare-web/Dockerfile b/apps/cloudflare-web/Dockerfile new file mode 100644 index 00000000000..414cd6fab51 --- /dev/null +++ b/apps/cloudflare-web/Dockerfile @@ -0,0 +1,23 @@ +FROM node:24-bookworm-slim AS node + +FROM mysql:8.4 AS runner +WORKDIR /app + +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules + +COPY apps/web/.next/standalone ./ +COPY apps/web/.next/static ./apps/web/.next/static +COPY apps/web/public ./apps/web/public +COPY packages/database/migrations ./apps/web/migrations +COPY packages/database/migrations ./migrations +COPY apps/cloudflare-web/start.sh ./start.sh + +RUN chmod +x ./start.sh + +ENV PORT=8080 +ENV HOSTNAME=0.0.0.0 + +EXPOSE 8080 + +CMD ["./start.sh"] diff --git a/apps/cloudflare-web/package.json b/apps/cloudflare-web/package.json new file mode 100644 index 00000000000..1db5ef4270a --- /dev/null +++ b/apps/cloudflare-web/package.json @@ -0,0 +1,17 @@ +{ + "name": "@cap/cloudflare-web", + "private": true, + "type": "module", + "scripts": { + "deploy:web": "wrangler deploy -c ../../wrangler.cap-web.jsonc", + "deploy:media": "wrangler deploy -c ../../wrangler.cap-media.jsonc", + "containers:list": "wrangler containers list -c ../../wrangler.cap-web.jsonc", + "containers:images": "wrangler containers images list -c ../../wrangler.cap-web.jsonc" + }, + "dependencies": { + "@cloudflare/containers": "^0.3.3" + }, + "devDependencies": { + "wrangler": "^4.90.0" + } +} diff --git a/apps/cloudflare-web/src/media.ts b/apps/cloudflare-web/src/media.ts new file mode 100644 index 00000000000..e2cdb1d5781 --- /dev/null +++ b/apps/cloudflare-web/src/media.ts @@ -0,0 +1,35 @@ +import { Container } from "@cloudflare/containers"; + +interface Env { + CAP_MEDIA: DurableObjectNamespace; + MEDIA_SERVER_WEBHOOK_SECRET?: string; +} + +export class CapMediaContainer extends Container { + defaultPort = 3456; + sleepAfter = "2m"; + enableInternet = true; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.envVars = { + MEDIA_SERVER_WEBHOOK_SECRET: env.MEDIA_SERVER_WEBHOOK_SECRET ?? "", + PORT: "3456", + }; + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === "/cf-health") { + return new Response("OK"); + } + + const id = env.CAP_MEDIA.idFromName("media"); + const container = env.CAP_MEDIA.get(id); + + return container.fetch(request); + }, +}; diff --git a/apps/cloudflare-web/src/web.ts b/apps/cloudflare-web/src/web.ts new file mode 100644 index 00000000000..cd370a86e06 --- /dev/null +++ b/apps/cloudflare-web/src/web.ts @@ -0,0 +1,162 @@ +import { Container } from "@cloudflare/containers"; + +interface Env { + CAP_WEB: DurableObjectNamespace; + CAP_AWS_ACCESS_KEY?: string; + CAP_AWS_BUCKET?: string; + CAP_AWS_REGION?: string; + CAP_AWS_SECRET_KEY?: string; + CAP_STORAGE_LIMIT_BYTES?: string; + CAP_VIDEOS_DEFAULT_PUBLIC?: string; + CLOUDFLARE_EMAIL_FROM_DOMAIN?: string; + CLOUDFLARE_EMAIL_SECRET?: string; + CLOUDFLARE_EMAIL_WORKER_URL?: string; + CRON_SECRET?: string; + DATABASE_ENCRYPTION_KEY?: string; + EMAIL: { + send(message: { + cc?: string | string[]; + from: string | { email: string; name: string }; + html?: string; + replyTo?: string; + subject: string; + text?: string; + to: string | string[]; + }): Promise<{ messageId: string }>; + }; + MEDIA_SERVER_URL?: string; + MEDIA_SERVER_WEBHOOK_SECRET?: string; + MEDIA_SERVER_WEBHOOK_URL?: string; + NEXTAUTH_SECRET?: string; + NEXTAUTH_URL?: string; + NEXT_PUBLIC_WEB_URL?: string; + NODE_ENV?: string; + S3_INTERNAL_ENDPOINT?: string; + S3_PATH_STYLE?: string; + S3_PUBLIC_ENDPOINT?: string; + WEB_URL?: string; + WORKFLOWS_RPC_SECRET?: string; +} + +const copiedKeys = [ + "CAP_AWS_ACCESS_KEY", + "CAP_AWS_BUCKET", + "CAP_AWS_REGION", + "CAP_AWS_SECRET_KEY", + "CAP_STORAGE_LIMIT_BYTES", + "CAP_VIDEOS_DEFAULT_PUBLIC", + "CLOUDFLARE_EMAIL_FROM_DOMAIN", + "CLOUDFLARE_EMAIL_SECRET", + "CLOUDFLARE_EMAIL_WORKER_URL", + "CRON_SECRET", + "DATABASE_ENCRYPTION_KEY", + "MEDIA_SERVER_URL", + "MEDIA_SERVER_WEBHOOK_SECRET", + "MEDIA_SERVER_WEBHOOK_URL", + "NEXTAUTH_SECRET", + "NEXTAUTH_URL", + "NEXT_PUBLIC_WEB_URL", + "NODE_ENV", + "S3_INTERNAL_ENDPOINT", + "S3_PATH_STYLE", + "S3_PUBLIC_ENDPOINT", + "WEB_URL", + "WORKFLOWS_RPC_SECRET", +] as const; + +function buildEnv(env: Env): Record { + const vars: Record = { + DATABASE_URL: "mysql://cap:cap-local-pwd@127.0.0.1:3306/cap", + HOSTNAME: "0.0.0.0", + MYSQL_DATABASE: "cap", + MYSQL_PASSWORD: "cap-local-pwd", + MYSQL_USER: "cap", + CLOUDFLARE_EMAIL_FROM_DOMAIN: + env.CLOUDFLARE_EMAIL_FROM_DOMAIN ?? "shashanksn.xyz", + CLOUDFLARE_EMAIL_WORKER_URL: + env.CLOUDFLARE_EMAIL_WORKER_URL ?? + "https://video.shashanksn.xyz/cf-email/send", + NEXT_PUBLIC_DOCKER_BUILD: "true", + NEXT_PUBLIC_WEB_URL: + env.NEXT_PUBLIC_WEB_URL ?? "https://video.shashanksn.xyz", + NEXTAUTH_URL: env.NEXTAUTH_URL ?? "https://video.shashanksn.xyz", + NODE_ENV: env.NODE_ENV ?? "production", + PORT: "8080", + WEB_URL: env.WEB_URL ?? "https://video.shashanksn.xyz", + }; + + for (const key of copiedKeys) { + const value = env[key]; + + if (typeof value === "string" && value.length > 0) { + vars[key] = value; + } + } + + return vars; +} + +export class CapWebContainer extends Container { + defaultPort = 8080; + sleepAfter = "2m"; + enableInternet = true; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.envVars = buildEnv(env); + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === "/cf-health") { + return new Response("OK"); + } + + if (url.pathname === "/cf-email/send") { + const token = request.headers + .get("authorization") + ?.replace("Bearer ", ""); + + if ( + !env.CLOUDFLARE_EMAIL_SECRET || + token !== env.CLOUDFLARE_EMAIL_SECRET + ) { + return new Response("Unauthorized", { status: 401 }); + } + + const message = (await request.json()) as { + cc?: string | string[]; + from: string | { email: string; name: string }; + html?: string; + replyTo?: string; + subject?: string; + text?: string; + to?: string | string[]; + }; + + if (!message.to || !message.from || !message.subject) { + return new Response("Missing required email fields", { status: 400 }); + } + + const result = await env.EMAIL.send({ + cc: message.cc, + from: message.from, + html: message.html, + replyTo: message.replyTo, + subject: message.subject, + text: message.text, + to: message.to, + }); + + return Response.json(result); + } + + const id = env.CAP_WEB.idFromName("web"); + const container = env.CAP_WEB.get(id); + + return container.fetch(request); + }, +}; diff --git a/apps/cloudflare-web/start.sh b/apps/cloudflare-web/start.sh new file mode 100644 index 00000000000..1907b100a17 --- /dev/null +++ b/apps/cloudflare-web/start.sh @@ -0,0 +1,49 @@ +#!/bin/sh +set -eu + +export MYSQL_DATABASE="${MYSQL_DATABASE:-cap}" +export MYSQL_USER="${MYSQL_USER:-cap}" +export MYSQL_PASSWORD="${MYSQL_PASSWORD:-cap-local-pwd}" +export MYSQL_DATADIR="${MYSQL_DATADIR:-/tmp/mysql-data}" +export MYSQL_SOCKET="${MYSQL_SOCKET:-/tmp/mysql.sock}" +export MYSQL_PORT="${MYSQL_PORT:-3306}" +export DATABASE_URL="${DATABASE_URL:-mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@127.0.0.1:${MYSQL_PORT}/${MYSQL_DATABASE}}" +export HOSTNAME="${HOSTNAME:-0.0.0.0}" +export PORT="${PORT:-8080}" + +mkdir -p "$MYSQL_DATADIR" /run/mysqld +chown -R mysql:mysql "$MYSQL_DATADIR" /run/mysqld + +if [ ! -d "$MYSQL_DATADIR/mysql" ]; then + mysqld --initialize-insecure --datadir="$MYSQL_DATADIR" --user=mysql >/tmp/mysql-install.log 2>&1 +fi + +mysqld --datadir="$MYSQL_DATADIR" --socket="$MYSQL_SOCKET" --pid-file=/tmp/mysql.pid --bind-address=127.0.0.1 --port="$MYSQL_PORT" --user=mysql & +MYSQL_PID="$!" + +cleanup() { + kill "$MYSQL_PID" 2>/dev/null || true + wait "$MYSQL_PID" 2>/dev/null || true +} + +trap cleanup INT TERM EXIT + +for _ in $(seq 1 60); do + if mysqladmin --socket="$MYSQL_SOCKET" ping >/dev/null 2>&1; then + break + fi + sleep 1 +done + +mysql --socket="$MYSQL_SOCKET" -uroot < { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("uses a stable manifest subpath", () => { + expect(getBrowserStudioManifestSubpath()).toBe("studio/manifest.json"); + }); + + it("builds a manifest that references the existing uploaded source", () => { + const manifest = buildBrowserStudioCloudManifest({ + videoId: "video-123", + session, + sourceSubpath: "result.mp4", + assetSourceSubpaths: { + "asset-screen": "studio/assets/asset-screen.mp4", + }, + }); + + expect(manifest).toMatchObject({ + schemaVersion: 1, + videoId: "video-123", + sessionId: "studio-session", + source: "browser-studio-vault", + totalBytes: 12, + chunkCount: 2, + }); + expect(manifest.assets).toHaveLength(1); + expect(manifest.assets[0]?.sourceSubpath).toBe( + "studio/assets/asset-screen.mp4", + ); + expect(manifest.assets[0]?.chunks.map((chunk) => chunk.index)).toEqual([ + 0, 1, + ]); + expect(manifest.project.timeline.tracks[0]?.assetId).toBe("asset-screen"); + }); + + it("uploads the manifest through the server proxy when requested", async () => { + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(JSON.stringify({ success: true }), { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + const upload = vi.fn(); + + const manifest = await uploadBrowserStudioManifest({ + videoId: "video-123", + session, + sourceSubpath: "result.mp4", + upload, + useServerProxy: true, + }); + + expect(manifest.videoId).toBe("video-123"); + expect(upload).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]?.toString()).toBe( + "/api/upload/signed/proxy?videoId=video-123&subpath=studio%2Fmanifest.json", + ); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + }); + }); + + it("uploads source assets through the server proxy when requested", async () => { + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(JSON.stringify({ success: true }), { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + const upload = vi.fn(); + const blob = new Blob(["asset"], { type: "video/mp4" }); + + await uploadBrowserStudioSourceAssets({ + videoId: "video-123", + assets: [ + { + subpath: "studio/assets/asset-screen.mp4", + blob, + fileName: "asset-screen.mp4", + }, + ], + upload, + useServerProxy: true, + }); + + expect(upload).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]?.toString()).toBe( + "/api/upload/signed/proxy?videoId=video-123&subpath=studio%2Fassets%2Fasset-screen.mp4", + ); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "video/mp4" }, + body: blob, + }); + }); +}); diff --git a/apps/web/__tests__/unit/browser-studio-render.test.ts b/apps/web/__tests__/unit/browser-studio-render.test.ts new file mode 100644 index 00000000000..36e28f3bdfd --- /dev/null +++ b/apps/web/__tests__/unit/browser-studio-render.test.ts @@ -0,0 +1,430 @@ +import { describe, expect, it } from "vitest"; +import type { BrowserStudioCloudManifest } from "@/lib/browser-studio"; +import { + appendGradientBackgroundInputToArgs, + appendTextOverlayInputsToArgs, + buildBrowserStudioRenderPlan, + getBrowserStudioRenderLayout, + getBrowserStudioTrimRange, + selectBrowserStudioRenderSources, +} from "@/lib/browser-studio-render"; + +const manifest = { + schemaVersion: 1, + videoId: "video-123", + sessionId: "studio-session", + source: "browser-studio-vault", + createdAt: 100, + updatedAt: 200, + browser: { + userAgent: "Safari", + platform: "MacIntel", + }, + project: { + schemaVersion: 1, + source: "browser-recorder", + title: "Browser recording", + timeline: { + durationMs: 8000, + tracks: [ + { + trackId: "track-screen", + assetId: "asset-screen", + kind: "screen", + label: "Screen", + startMs: 0, + durationMs: 8000, + muted: false, + }, + { + trackId: "track-camera", + assetId: "asset-camera", + kind: "camera", + label: "Camera", + startMs: 0, + durationMs: 8000, + muted: false, + }, + ], + }, + exportSettings: { + format: "mp4", + quality: "source", + }, + }, + assets: [ + { + assetId: "asset-screen", + trackId: "track-screen", + kind: "screen", + label: "Screen", + mimeType: "video/mp4", + fileExtension: "mp4", + width: 1920, + height: 1080, + frameRate: 30, + sampleRate: null, + channelCount: 2, + totalBytes: 1, + chunkCount: 1, + chunks: [], + sourceSubpath: "studio/assets/screen.mp4", + }, + { + assetId: "asset-camera", + trackId: "track-camera", + kind: "camera", + label: "Camera", + mimeType: "video/mp4", + fileExtension: "mp4", + width: 1280, + height: 720, + frameRate: 30, + sampleRate: null, + channelCount: 2, + totalBytes: 1, + chunkCount: 1, + chunks: [], + sourceSubpath: "studio/assets/camera.mp4", + }, + ], + totalBytes: 2, + chunkCount: 2, + edit: { + trim: { + startMs: 1000, + endMs: 6000, + }, + playback: { + speed: 1, + }, + canvas: { + aspectRatio: "1:1", + backgroundMode: "solid", + background: "#183d3d", + backgroundGradient: { + from: "#4785ff", + to: "#ff4766", + angle: 135, + }, + padding: 10, + scale: 1.1, + cameraPosition: "bottom-right", + cameraSize: 22, + cameraShape: "square", + cameraMirror: false, + }, + audio: { + volume: 0.7, + }, + zooms: [], + textOverlays: [], + }, +} satisfies BrowserStudioCloudManifest; + +const sources = [ + { + subpath: "studio/assets/screen.mp4", + url: "https://example.com/screen.mp4", + }, + { + subpath: "studio/assets/camera.mp4", + url: "https://example.com/camera.mp4", + }, +]; + +describe("browser studio render", () => { + it("selects screen as primary and camera as overlay", () => { + const selected = selectBrowserStudioRenderSources(manifest, sources); + + expect(selected.primary.asset.assetId).toBe("asset-screen"); + expect(selected.primary.url).toBe("https://example.com/screen.mp4"); + expect(selected.camera?.asset.assetId).toBe("asset-camera"); + }); + + it("calculates even canvas dimensions for square exports", () => { + const layout = getBrowserStudioRenderLayout({ + sourceWidth: 1920, + sourceHeight: 1080, + aspectRatio: "1:1", + padding: 10, + }); + + expect(layout.outputWidth).toBe(1920); + expect(layout.outputHeight).toBe(1920); + expect(layout.contentWidth).toBe(1536); + expect(layout.contentHeight).toBe(1536); + }); + + it("clamps trim ranges inside media duration", () => { + const range = getBrowserStudioTrimRange( + { + ...manifest.edit, + trim: { + startMs: 7900, + endMs: 12000, + }, + }, + 8000, + ); + + expect(range.startMs).toBe(7900); + expect(range.endMs).toBe(8000); + expect(range.durationMs).toBe(100); + }); + + it("builds an ffmpeg plan that renders mp4 with overlay and audio volume", () => { + const plan = buildBrowserStudioRenderPlan(manifest, sources); + + expect(plan.durationMs).toBe(5000); + expect(plan.outputWidth).toBe(1920); + expect(plan.outputHeight).toBe(1920); + expect(plan.args).toContain("-filter_complex"); + expect(plan.args.join(" ")).toContain("overlay=W-w-76:H-h-76"); + expect(plan.args.join(" ")).toContain("volume=0.7"); + expect(plan.args).toContain("libx264"); + }); + + it("uses camera size in overlay render dimensions", () => { + const plan = buildBrowserStudioRenderPlan( + { + ...manifest, + edit: { + ...manifest.edit, + canvas: { + ...manifest.edit.canvas, + cameraSize: 30, + }, + }, + }, + sources, + ); + + expect(plan.args.join(" ")).toContain("[1:v]scale=576:576"); + }); + + it("renders mirrored source-shaped camera overlays", () => { + const plan = buildBrowserStudioRenderPlan( + { + ...manifest, + edit: { + ...manifest.edit, + canvas: { + ...manifest.edit.canvas, + cameraSize: 30, + cameraShape: "source", + cameraMirror: true, + }, + }, + }, + sources, + ); + + const args = plan.args.join(" "); + + expect(args).toContain("[1:v]hflip[camflip]"); + expect(args).toContain( + "[camflip]scale=576:324:force_original_aspect_ratio=decrease", + ); + }); + + it("renders zoom segments as time-bounded scale and focal point expressions", () => { + const plan = buildBrowserStudioRenderPlan( + { + ...manifest, + edit: { + ...manifest.edit, + zooms: [ + { + id: "zoom-1", + startMs: 2000, + endMs: 4000, + scale: 2, + originX: 0.25, + originY: 0.75, + }, + ], + }, + }, + sources, + ); + + const args = plan.args.join(" "); + + expect(args).toContain("between(t,1,3)"); + expect(args).toContain("if(between(t,1,3),2.2,1.1)"); + expect(args).toContain("w*if(between(t,1,3),0.25,0.5)"); + expect(args).toContain("h*if(between(t,1,3),0.75,0.5)"); + }); + + it("renders blurred source backgrounds from the primary video", () => { + const plan = buildBrowserStudioRenderPlan( + { + ...manifest, + edit: { + ...manifest.edit, + canvas: { + ...manifest.edit.canvas, + backgroundMode: "blur", + }, + }, + }, + sources, + ); + + const args = plan.args.join(" "); + + expect(args).toContain("[0:v]split=2[bgsrc][fgsrc]"); + expect(args).toContain("boxblur=24:1"); + expect(args).toContain("[fgsrc]scale="); + }); + + it("renders timed text overlays after video composition", () => { + const plan = buildBrowserStudioRenderPlan( + { + ...manifest, + edit: { + ...manifest.edit, + textOverlays: [ + { + id: "text-1", + startMs: 2000, + endMs: 4500, + text: "Look here: 100%", + x: 0.5, + y: 0.2, + size: 48, + color: "#ffffff", + background: "#00000099", + }, + ], + }, + }, + sources, + ); + + const overlay = plan.edit.textOverlays.at(0); + + expect(overlay).toBeDefined(); + + if (!overlay) { + throw new Error("Expected text overlay"); + } + + const args = appendTextOverlayInputsToArgs( + plan.args, + [ + { + path: "/tmp/text-overlay.png", + overlay, + }, + ], + plan.trimStartSeconds, + plan.outputWidth, + plan.outputHeight, + ).join(" "); + + expect(args).toContain("-loop 1 -i /tmp/text-overlay.png"); + expect(args).toContain("[2:v]overlay"); + expect(args).toContain("enable='between(t,1,3.5)'"); + expect(args).toContain("-map [vtext0]"); + }); + + it("keeps text overlay timing aligned after playback speed changes", () => { + const plan = buildBrowserStudioRenderPlan( + { + ...manifest, + edit: { + ...manifest.edit, + playback: { + speed: 2, + }, + textOverlays: [ + { + id: "text-1", + startMs: 2000, + endMs: 4000, + text: "Fast section", + x: 0.5, + y: 0.2, + size: 48, + color: "#ffffff", + background: "#00000099", + }, + ], + }, + }, + sources, + ); + const overlay = plan.edit.textOverlays.at(0); + + if (!overlay) { + throw new Error("Expected text overlay"); + } + + const args = appendTextOverlayInputsToArgs( + plan.args, + [ + { + path: "/tmp/text-overlay.png", + overlay, + }, + ], + plan.trimStartSeconds, + plan.outputWidth, + plan.outputHeight, + plan.edit.playback.speed, + ).join(" "); + + expect(args).toContain("enable='between(t,0.5,1.5)'"); + }); + + it("renders gradient backgrounds through a generated image input", () => { + const plan = buildBrowserStudioRenderPlan( + { + ...manifest, + edit: { + ...manifest.edit, + canvas: { + ...manifest.edit.canvas, + backgroundMode: "gradient", + }, + }, + }, + sources, + ); + + const args = appendGradientBackgroundInputToArgs( + plan.args, + { path: "/tmp/gradient-background.png" }, + plan.outputWidth, + plan.outputHeight, + ).join(" "); + + expect(args).toContain("-loop 1 -i /tmp/gradient-background.png"); + expect(args).toContain("[2:v]scale=1920:1920,setsar=1[bg]"); + expect(args).toContain("[bg][v0]overlay"); + }); + + it("renders playback speed into video timing, audio tempo, and duration", () => { + const plan = buildBrowserStudioRenderPlan( + { + ...manifest, + edit: { + ...manifest.edit, + playback: { + speed: 2, + }, + }, + }, + sources, + ); + + const args = plan.args.join(" "); + + expect(plan.durationMs).toBe(2500); + expect(args).toContain("setpts=0.5*PTS"); + expect(args).toContain("atempo=2,volume=0.7"); + expect(plan.argsWithoutAudio.join(" ")).toContain("setpts=0.5*PTS"); + }); +}); diff --git a/apps/web/__tests__/unit/browser-studio-vault.test.ts b/apps/web/__tests__/unit/browser-studio-vault.test.ts new file mode 100644 index 00000000000..b4a574e1c68 --- /dev/null +++ b/apps/web/__tests__/unit/browser-studio-vault.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, it } from "vitest"; +import { + BrowserStudioVault, + type BrowserStudioVaultBackend, + BrowserStudioVaultBackpressureError, + type BrowserStudioVaultChunk, + type BrowserStudioVaultSession, + deleteBrowserStudioVaultSession, + deleteUploadedBrowserStudioVaultSessions, + recoverBrowserStudioVaultSessions, +} from "@/app/(org)/dashboard/caps/components/web-recorder-dialog/browser-studio-vault"; + +type MemoryChunk = { + index: number; + blob: Blob; + metadata: BrowserStudioVaultChunk; +}; + +class MemoryBrowserStudioVaultBackend implements BrowserStudioVaultBackend { + private readonly sessions = new Map(); + private readonly chunks = new Map(); + private readonly failureAtIndex: number | null; + + constructor(options?: { failureAtIndex?: number | null }) { + this.failureAtIndex = options?.failureAtIndex ?? null; + } + + async initialize() {} + + async createSession(session: BrowserStudioVaultSession) { + this.sessions.set(session.sessionId, this.cloneSession(session)); + } + + async updateSession(session: BrowserStudioVaultSession) { + this.sessions.set(session.sessionId, this.cloneSession(session)); + } + + async appendChunk( + session: BrowserStudioVaultSession, + assetId: string, + index: number, + chunk: Blob, + metadata: BrowserStudioVaultChunk, + ) { + if (this.failureAtIndex === index) { + throw new Error(`Failed to persist Browser Studio chunk ${index}`); + } + + this.sessions.set(session.sessionId, this.cloneSession(session)); + const key = this.chunkKey(session.sessionId, assetId); + const existingChunks = this.chunks.get(key) ?? []; + existingChunks.push({ index, blob: chunk, metadata }); + this.chunks.set(key, existingChunks); + } + + async readChunks(sessionId: string, assetId: string) { + return [...(this.chunks.get(this.chunkKey(sessionId, assetId)) ?? [])] + .sort((left, right) => left.index - right.index) + .map((entry) => entry.blob); + } + + async listSessions() { + return [...this.sessions.values()].map((session) => + this.cloneSession(session), + ); + } + + async deleteSession(sessionId: string) { + this.sessions.delete(sessionId); + for (const key of this.chunks.keys()) { + if (key.startsWith(`${sessionId}:`)) { + this.chunks.delete(key); + } + } + } + + getSessionCount() { + return this.sessions.size; + } + + getChunkCount(sessionId: string, assetId: string) { + return this.chunks.get(this.chunkKey(sessionId, assetId))?.length ?? 0; + } + + private chunkKey(sessionId: string, assetId: string) { + return `${sessionId}:${assetId}`; + } + + private cloneSession(session: BrowserStudioVaultSession) { + return JSON.parse(JSON.stringify(session)) as BrowserStudioVaultSession; + } +} + +const blobToText = async (blob: Blob) => + new TextDecoder().decode(await blob.arrayBuffer()); + +describe("BrowserStudioVault", () => { + it("persists separate editable assets with checksummed chunks", async () => { + const backend = new MemoryBrowserStudioVaultBackend(); + const vault = await BrowserStudioVault.create( + { + sessionId: "studio-session-1", + browser: { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15", + platform: "MacIntel", + }, + }, + backend, + ); + + const screen = await vault.createAsset({ + assetId: "screen-asset", + trackId: "screen-track", + kind: "screen", + label: "Screen", + mimeType: "video/mp4", + fileExtension: "mp4", + width: 1920, + height: 1080, + frameRate: 30, + }); + const camera = await vault.createAsset({ + assetId: "camera-asset", + trackId: "camera-track", + kind: "camera", + label: "Camera", + mimeType: "video/mp4", + fileExtension: "mp4", + width: 1280, + height: 720, + frameRate: 30, + }); + + await Promise.all([ + vault.appendChunk(screen.assetId, new Blob(["screen-1|"])), + vault.appendChunk(screen.assetId, new Blob(["screen-2"])), + vault.appendChunk(camera.assetId, new Blob(["camera"])), + ]); + + const session = vault.getSession(); + const [screenAsset, cameraAsset] = session.assets; + + expect(session.totalBytes).toBe(23); + expect(session.chunkCount).toBe(3); + expect( + session.project.timeline.tracks.map((track) => track.assetId), + ).toEqual(["screen-asset", "camera-asset"]); + expect(screenAsset?.chunkCount).toBe(2); + expect(cameraAsset?.chunkCount).toBe(1); + expect(screenAsset?.chunks.map((chunk) => chunk.index)).toEqual([0, 1]); + expect(screenAsset?.chunks[0]?.checksum).toMatch(/^[a-f0-9]{64}$/); + expect(backend.getChunkCount("studio-session-1", "screen-asset")).toBe(2); + + const screenBlob = await vault.recoverAssetBlob("screen-asset"); + const cameraBlob = await vault.recoverAssetBlob("camera-asset"); + + expect(screenBlob?.type).toBe("video/mp4"); + expect(cameraBlob?.type).toBe("video/mp4"); + expect(await blobToText(screenBlob as Blob)).toBe("screen-1|screen-2"); + expect(await blobToText(cameraBlob as Blob)).toBe("camera"); + }); + + it("finalizes a project manifest without flattening source assets", async () => { + const backend = new MemoryBrowserStudioVaultBackend(); + const vault = await BrowserStudioVault.create( + { sessionId: "studio-session-finalize" }, + backend, + ); + const screen = await vault.createAsset({ + assetId: "screen-asset", + trackId: "screen-track", + kind: "screen", + label: "Screen", + mimeType: "video/mp4", + fileExtension: "mp4", + }); + + await vault.appendChunk(screen.assetId, new Blob(["screen"])); + const session = await vault.finalize({ + durationMs: 5400, + title: "Browser Studio recording", + }); + + expect(session.status).toBe("ready"); + expect(session.project.title).toBe("Browser Studio recording"); + expect(session.project.timeline.durationMs).toBe(5400); + expect(session.project.timeline.tracks[0]?.durationMs).toBe(5400); + expect(session.assets[0]?.chunkCount).toBe(1); + }); + + it("attaches cloud video state after local finalization", async () => { + const backend = new MemoryBrowserStudioVaultBackend(); + const vault = await BrowserStudioVault.create( + { sessionId: "studio-session-video-state" }, + backend, + ); + + await vault.attachVideo("video-123"); + await vault.updateStatus("uploading"); + await vault.updateStatus("uploaded"); + + const [session] = await backend.listSessions(); + + expect(session?.videoId).toBe("video-123"); + expect(session?.status).toBe("uploaded"); + }); + + it("recovers sessions newest first and deletes only explicit dismissals", async () => { + const backend = new MemoryBrowserStudioVaultBackend(); + const older = await BrowserStudioVault.create( + { sessionId: "older-studio-session" }, + backend, + ); + const olderAsset = await older.createAsset({ + assetId: "older-screen", + kind: "screen", + mimeType: "video/mp4", + fileExtension: "mp4", + }); + await older.appendChunk(olderAsset.assetId, new Blob(["older"])); + const newer = await BrowserStudioVault.create( + { sessionId: "newer-studio-session" }, + backend, + ); + const newerAsset = await newer.createAsset({ + assetId: "newer-screen", + kind: "screen", + mimeType: "video/mp4", + fileExtension: "mp4", + }); + await newer.appendChunk(newerAsset.assetId, new Blob(["newer"])); + + const recovered = await recoverBrowserStudioVaultSessions(backend); + + expect(recovered.map((session) => session.sessionId)).toEqual([ + "newer-studio-session", + "older-studio-session", + ]); + expect(backend.getSessionCount()).toBe(2); + + await deleteBrowserStudioVaultSession("older-studio-session", backend); + + expect(backend.getSessionCount()).toBe(1); + expect(backend.getChunkCount("older-studio-session", "older-screen")).toBe( + 0, + ); + expect(backend.getChunkCount("newer-studio-session", "newer-screen")).toBe( + 1, + ); + }); + + it("deletes uploaded cloud sessions while preserving failed local backups", async () => { + const backend = new MemoryBrowserStudioVaultBackend(); + const uploaded = await BrowserStudioVault.create( + { sessionId: "uploaded-studio-session" }, + backend, + ); + const uploadedAsset = await uploaded.createAsset({ + assetId: "uploaded-screen", + kind: "screen", + mimeType: "video/mp4", + fileExtension: "mp4", + }); + await uploaded.appendChunk(uploadedAsset.assetId, new Blob(["uploaded"])); + await uploaded.attachVideo("video-uploaded"); + await uploaded.updateStatus("uploaded"); + + const failed = await BrowserStudioVault.create( + { sessionId: "failed-studio-session" }, + backend, + ); + const failedAsset = await failed.createAsset({ + assetId: "failed-screen", + kind: "screen", + mimeType: "video/mp4", + fileExtension: "mp4", + }); + await failed.appendChunk(failedAsset.assetId, new Blob(["failed"])); + await failed.attachVideo("video-failed"); + await failed.updateStatus("failed"); + + const deleted = await deleteUploadedBrowserStudioVaultSessions(backend); + + expect(deleted).toBe(1); + expect(backend.getSessionCount()).toBe(1); + expect( + backend.getChunkCount("uploaded-studio-session", "uploaded-screen"), + ).toBe(0); + expect( + backend.getChunkCount("failed-studio-session", "failed-screen"), + ).toBe(1); + }); + + it("keeps pending chunks recoverable after a storage failure", async () => { + const backend = new MemoryBrowserStudioVaultBackend({ failureAtIndex: 1 }); + const vault = await BrowserStudioVault.create( + { sessionId: "studio-session-failure" }, + backend, + ); + const screen = await vault.createAsset({ + assetId: "screen-asset", + kind: "screen", + mimeType: "video/mp4", + fileExtension: "mp4", + }); + + await vault.appendChunk(screen.assetId, new Blob(["first|"])); + await expect( + vault.appendChunk(screen.assetId, new Blob(["second"])), + ).rejects.toThrow("Failed to persist Browser Studio chunk 1"); + await expect(vault.flush()).rejects.toThrow( + "Failed to persist Browser Studio chunk 1", + ); + + const recovered = await vault.recoverAssetBlob(screen.assetId); + + expect(await blobToText(recovered as Blob)).toBe("first|second"); + }); + + it("fails fast when local persistence falls behind capture", async () => { + const backend = new MemoryBrowserStudioVaultBackend(); + const vault = await BrowserStudioVault.create( + { + sessionId: "studio-session-backpressure", + maxPendingChunkBytes: 3, + }, + backend, + ); + const screen = await vault.createAsset({ + assetId: "screen-asset", + kind: "screen", + mimeType: "video/mp4", + fileExtension: "mp4", + }); + + await expect( + vault.appendChunk(screen.assetId, new Blob(["1234"])), + ).rejects.toBeInstanceOf(BrowserStudioVaultBackpressureError); + expect( + backend.getChunkCount("studio-session-backpressure", "screen-asset"), + ).toBe(0); + }); +}); diff --git a/apps/web/__tests__/unit/recording-upload.test.ts b/apps/web/__tests__/unit/recording-upload.test.ts new file mode 100644 index 00000000000..5d26f7500e1 --- /dev/null +++ b/apps/web/__tests__/unit/recording-upload.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { uploadThumbnail } from "@/app/(org)/dashboard/caps/components/web-recorder-dialog/recording-upload"; +import type { + UploadTarget, + VideoId, +} from "@/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-types"; +import type { UploadStatus } from "@/app/(org)/dashboard/caps/UploadingContext"; + +describe("recording thumbnail upload", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("uses the server proxy for Safari thumbnail uploads", async () => { + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(JSON.stringify({ success: true }), { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + + const statuses: Array = []; + const target = { + type: "s3Put", + url: "https://storage.example/screen-capture.jpg", + headers: {}, + } as unknown as UploadTarget; + const blob = new Blob(["thumbnail"], { type: "image/jpeg" }); + + await uploadThumbnail({ + blob, + target, + currentVideoId: "video-1" as VideoId, + setUploadStatus: (status) => statuses.push(status), + useServerProxy: true, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]?.toString()).toBe( + "/api/upload/signed/proxy?videoId=video-1&subpath=screenshot%2Fscreen-capture.jpg", + ); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "image/jpeg" }, + body: blob, + }); + expect( + statuses.map((status) => + status && "progress" in status ? status.progress : undefined, + ), + ).toEqual([90, 100]); + }); +}); diff --git a/apps/web/__tests__/unit/signed-upload-content-type.test.ts b/apps/web/__tests__/unit/signed-upload-content-type.test.ts new file mode 100644 index 00000000000..79c0ce5b989 --- /dev/null +++ b/apps/web/__tests__/unit/signed-upload-content-type.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { contentTypeForSubpath } from "@/lib/upload-content-type"; + +describe("signed upload content types", () => { + it("uses video content types for browser studio video sources", () => { + expect(contentTypeForSubpath("studio/assets/screen.webm")).toBe( + "video/webm", + ); + expect(contentTypeForSubpath("studio/assets/camera.mp4")).toBe("video/mp4"); + }); + + it("uses audio content types for separate audio sources", () => { + expect(contentTypeForSubpath("studio/assets/microphone.m4a")).toBe( + "audio/mp4", + ); + expect(contentTypeForSubpath("studio/assets/system.aac")).toBe("audio/aac"); + }); +}); diff --git a/apps/web/__tests__/unit/video-title.test.ts b/apps/web/__tests__/unit/video-title.test.ts new file mode 100644 index 00000000000..5dd795cb92a --- /dev/null +++ b/apps/web/__tests__/unit/video-title.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + getDefaultVideoTitle, + isDefaultVideoTitle, + removeCapFromVideoTitle, +} from "@/lib/video-title"; + +describe("video title branding", () => { + it("creates unbranded default titles", () => { + expect(getDefaultVideoTitle("recording", "2026-05-10 18:53:26")).toBe( + "Recording - 2026-05-10 18:53:26", + ); + expect(getDefaultVideoTitle("screenshot", "2026-05-10 18:53:26")).toBe( + "Screenshot - 2026-05-10 18:53:26", + ); + expect(getDefaultVideoTitle("upload", "2026-05-10 18:53:26")).toBe( + "Upload - 2026-05-10 18:53:26", + ); + }); + + it("hides legacy Cap prefixes from default recording titles", () => { + expect(removeCapFromVideoTitle("Cap Recording - 2026-05-10")).toBe( + "Recording - 2026-05-10", + ); + expect(removeCapFromVideoTitle("Cap Screenshot - 2026-05-10")).toBe( + "Screenshot - 2026-05-10", + ); + expect(removeCapFromVideoTitle("Cap Upload - 2026-05-10")).toBe( + "Upload - 2026-05-10", + ); + }); + + it("detects old and new default titles for AI replacement", () => { + expect(isDefaultVideoTitle("Cap Recording - 2026-05-10 18:53:26")).toBe( + true, + ); + expect(isDefaultVideoTitle("Recording - 2026-05-10 18:53:26")).toBe(true); + expect(isDefaultVideoTitle("Customer demo")).toBe(false); + }); +}); diff --git a/apps/web/__tests__/unit/videos-policy.test.ts b/apps/web/__tests__/unit/videos-policy.test.ts index bbf4e9a064f..e9a92beedf2 100644 --- a/apps/web/__tests__/unit/videos-policy.test.ts +++ b/apps/web/__tests__/unit/videos-policy.test.ts @@ -18,6 +18,7 @@ function makeVideo( public: boolean; ownerId: string; orgId: string; + expiresAt: Option.Option; }> = {}, ) { return Video.Video.make({ @@ -37,6 +38,7 @@ function makeVideo( duration: Option.none(), createdAt: new Date(), updatedAt: new Date(), + expiresAt: overrides.expiresAt ?? Option.none(), }); } @@ -436,6 +438,28 @@ describe("VideosPolicy.canView", () => { }); }); + describe("expiry", () => { + it("denies expired videos", async () => { + const deps = makeDeps({ + video: makeVideo({ + expiresAt: Option.some(new Date(Date.now() - 1000)), + }), + }); + + expect(await runCanView(deps, noUser)).toBe("denied"); + }); + + it("allows videos with a future expiry", async () => { + const deps = makeDeps({ + video: makeVideo({ + expiresAt: Option.some(new Date(Date.now() + 60_000)), + }), + }); + + expect(await runCanView(deps, noUser)).toBe("allowed"); + }); + }); + describe("email restriction does NOT apply to private video access", () => { it("private video denied for non-member even if email matches restriction", async () => { const deps = makeDeps({ diff --git a/apps/web/__tests__/unit/web-recorder-utils.test.ts b/apps/web/__tests__/unit/web-recorder-utils.test.ts index 4f83a6dc946..d44f1382be4 100644 --- a/apps/web/__tests__/unit/web-recorder-utils.test.ts +++ b/apps/web/__tests__/unit/web-recorder-utils.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { + canRequestSystemAudioForMode, + isSafariBrowser, openShareUrlInNewTab, selectRecordingPipelineFromSupport, shouldPreferStreamingUpload, @@ -100,6 +102,53 @@ describe("selectRecordingPipelineFromSupport", () => { }); }); +describe("isSafariBrowser", () => { + it("detects safari without matching chromium browsers that include safari in the user agent", () => { + expect( + isSafariBrowser({ + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + }), + ).toBe(true); + expect( + isSafariBrowser({ + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", + }), + ).toBe(false); + expect( + isSafariBrowser({ + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0", + }), + ).toBe(false); + }); +}); + +describe("canRequestSystemAudioForMode", () => { + it("blocks system audio for safari display recording modes", () => { + const safari = { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", + }; + + expect(canRequestSystemAudioForMode(safari, "fullscreen")).toBe(false); + expect(canRequestSystemAudioForMode(safari, "tab")).toBe(false); + expect(canRequestSystemAudioForMode(safari, "camera")).toBe(false); + }); + + it("allows system audio for chromium display recording modes", () => { + const chrome = { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", + }; + + expect(canRequestSystemAudioForMode(chrome, "fullscreen")).toBe(true); + expect(canRequestSystemAudioForMode(chrome, "tab")).toBe(true); + expect(canRequestSystemAudioForMode(chrome, "camera")).toBe(false); + }); +}); + describe("shouldPreferStreamingUpload", () => { it("enables streaming uploads for chromium-like browsers", () => { expect( diff --git a/apps/web/actions/folders/add-videos.ts b/apps/web/actions/folders/add-videos.ts index 817da61914d..2b59008cce9 100644 --- a/apps/web/actions/folders/add-videos.ts +++ b/apps/web/actions/folders/add-videos.ts @@ -51,7 +51,6 @@ export async function addVideosToFolder( const isAllSpacesEntry = spaceId === user.activeOrganizationId; - //if we're adding videos to a folder from Caps page, then insert the videos into the folder if (isAllSpacesEntry && folder.spaceId) { await db() .insert(sharedVideos) diff --git a/apps/web/actions/loom.ts b/apps/web/actions/loom.ts index 90ea4ab967f..6fa6b900e29 100644 --- a/apps/web/actions/loom.ts +++ b/apps/web/actions/loom.ts @@ -5,8 +5,8 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId } from "@cap/database/helpers"; import { importedVideos, videos, videoUploads } from "@cap/database/schema"; -import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; -import { dub, userIsPro } from "@cap/utils"; +import { serverEnv } from "@cap/env"; +import { userIsPro } from "@cap/utils"; import { Storage } from "@cap/web-backend"; import type { Organisation } from "@cap/web-domain"; import { Video } from "@cap/web-domain"; @@ -229,7 +229,7 @@ export async function importFromLoom({ if (!userIsPro(user)) { return { success: false, - error: "Importing from Loom requires a Cap Pro subscription.", + error: "Importing from Loom requires a Pro subscription.", }; } @@ -336,16 +336,6 @@ export async function importFromLoom({ const rawFileKey = `${user.id}/${videoId}/raw-upload.mp4`; - if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") { - await dub() - .links.create({ - url: `${serverEnv().WEB_URL}/s/${videoId}`, - domain: "cap.link", - key: videoId, - }) - .catch(() => {}); - } - await start(importLoomVideoWorkflow, [ { videoId, diff --git a/apps/web/actions/organization/get-subscription-details.ts b/apps/web/actions/organization/get-subscription-details.ts index 2b0b2df3815..4061f2d35a6 100644 --- a/apps/web/actions/organization/get-subscription-details.ts +++ b/apps/web/actions/organization/get-subscription-details.ts @@ -63,7 +63,7 @@ export async function getSubscriptionDetails( interval === "year" ? unitAmount / 100 / 12 : unitAmount / 100; return { - planName: "Cap Pro", + planName: "Pro", status: subscription.status, billingInterval: interval, pricePerSeat, diff --git a/apps/web/actions/organization/send-invites.ts b/apps/web/actions/organization/send-invites.ts index 6ec05bb770a..715b5ca65d6 100644 --- a/apps/web/actions/organization/send-invites.ts +++ b/apps/web/actions/organization/send-invites.ts @@ -118,7 +118,7 @@ export async function sendOrganizationInvites( const inviteUrl = `${serverEnv().WEB_URL}/invite/${record.id}`; return sendEmail({ email: record.email, - subject: `Invitation to join ${organization.name} on Cap`, + subject: `Invitation to join ${organization.name}`, react: OrganizationInvite({ email: record.email, url: inviteUrl, diff --git a/apps/web/actions/send-download-link.ts b/apps/web/actions/send-download-link.ts index 6d3c4a64c26..f5702b38466 100644 --- a/apps/web/actions/send-download-link.ts +++ b/apps/web/actions/send-download-link.ts @@ -33,10 +33,13 @@ export async function sendDownloadLink(email: string) { } const headersList = await headers(); - const request = new Request("https://cap.so/api/send-download-link", { - method: "POST", - headers: headersList, - }); + const request = new Request( + "https://video.shashanksn.xyz/api/send-download-link", + { + method: "POST", + headers: headersList, + }, + ); const { rateLimited } = await checkRateLimit("rl_send_download_link", { request, @@ -52,7 +55,7 @@ export async function sendDownloadLink(email: string) { try { await sendEmail({ email: sanitized, - subject: "Your Cap download links", + subject: "Your download links", react: DownloadLink({ email: sanitized }), marketing: true, }); diff --git a/apps/web/actions/video/create-for-processing.ts b/apps/web/actions/video/create-for-processing.ts index 3bcc07857b8..d42206380cb 100644 --- a/apps/web/actions/video/create-for-processing.ts +++ b/apps/web/actions/video/create-for-processing.ts @@ -4,8 +4,8 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId } from "@cap/database/helpers"; import { videos, videoUploads } from "@cap/database/schema"; -import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; -import { dub, userIsPro } from "@cap/utils"; +import { serverEnv } from "@cap/env"; +import { userIsPro } from "@cap/utils"; import { Storage as StorageService } from "@cap/web-backend"; import { type Folder, @@ -16,6 +16,7 @@ import { import { Option } from "effect"; import { revalidatePath } from "next/cache"; import { runPromise } from "@/lib/server"; +import { getDefaultVideoTitle } from "@/lib/video-title"; export interface CreateForProcessingResult { id: Video.VideoId; @@ -74,7 +75,7 @@ export async function createVideoForServerProcessing({ .insert(videos) .values({ id: videoId, - name: `Cap Upload - ${formattedDate}`, + name: getDefaultVideoTitle("upload", formattedDate), ownerId: user.id, orgId, source: { type: "webMP4" as const }, @@ -90,18 +91,6 @@ export async function createVideoForServerProcessing({ processingProgress: 0, }); - if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") { - await dub() - .links.create({ - url: `${serverEnv().WEB_URL}/s/${videoId}`, - domain: "cap.link", - key: videoId, - }) - .catch((err) => { - console.error("Dub link create failed", err); - }); - } - revalidatePath("/dashboard/caps"); revalidatePath("/dashboard/folder"); revalidatePath("/dashboard/spaces"); diff --git a/apps/web/actions/video/upload.ts b/apps/web/actions/video/upload.ts index a61e035e044..15f63632b45 100644 --- a/apps/web/actions/video/upload.ts +++ b/apps/web/actions/video/upload.ts @@ -4,8 +4,8 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId } from "@cap/database/helpers"; import { videos, videoUploads } from "@cap/database/schema"; -import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; -import { dub, userIsPro } from "@cap/utils"; +import { serverEnv } from "@cap/env"; +import { userIsPro } from "@cap/utils"; import { Storage as StorageService } from "@cap/web-backend"; import { type Folder, @@ -17,6 +17,7 @@ import { eq } from "drizzle-orm"; import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; import { runPromise } from "@/lib/server"; +import { getDefaultVideoTitle } from "@/lib/video-title"; const MAX_S3_DELETE_ATTEMPTS = 3; const S3_DELETE_RETRY_BACKOFF_MS = 250; @@ -149,6 +150,7 @@ export async function createVideoAndGetUploadUrl({ storageIntegrationId: existingVideo.storageIntegrationId, createdAt: existingVideo.createdAt.toISOString(), updatedAt: existingVideo.updatedAt.toISOString(), + expiresAt: existingVideo.expiresAt?.toISOString() ?? null, metadata: existingVideo.metadata, }); const fileKey = `${user.id}/${videoId}/${ @@ -189,9 +191,10 @@ export async function createVideoAndGetUploadUrl({ const videoData = { id: idToUse, - name: `Cap ${ - isScreenshot ? "Screenshot" : isUpload ? "Upload" : "Recording" - } - ${formattedDate}`, + name: getDefaultVideoTitle( + isScreenshot ? "screenshot" : isUpload ? "upload" : "recording", + formattedDate, + ), ownerId: user.id, orgId, source: { type: "webMP4" as const }, @@ -209,18 +212,6 @@ export async function createVideoAndGetUploadUrl({ videoId: idToUse, }); - if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") { - await dub() - .links.create({ - url: `${serverEnv().WEB_URL}/s/${idToUse}`, - domain: "cap.link", - key: idToUse, - }) - .catch((err) => { - console.error("Dub link create failed", err); - }); - } - revalidatePath("/dashboard/caps"); revalidatePath("/dashboard/folder"); revalidatePath("/dashboard/spaces"); @@ -262,6 +253,7 @@ export async function deleteVideoResultFile({ createdAt: video.createdAt.toISOString(), updatedAt: video.updatedAt.toISOString(), metadata: video.metadata, + expiresAt: video.expiresAt?.toISOString() ?? null, }); const fileKey = `${video.ownerId}/${video.id}/result.mp4`; const logContext = { diff --git a/apps/web/actions/videos/get-og-image.tsx b/apps/web/actions/videos/get-og-image.tsx index 468458faf8a..ac94162cf1a 100644 --- a/apps/web/actions/videos/get-og-image.tsx +++ b/apps/web/actions/videos/get-og-image.tsx @@ -25,7 +25,7 @@ export async function generateVideoOgImage(videoId: Video.VideoId) { "radial-gradient(90.01% 80.01% at 53.53% 49.99%,#d3e5ff 30.65%,#4785ff 88.48%,#fff 100%)", }} > -

Cap not found

+

Video not found

The video you are looking for does not exist or has moved.

diff --git a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx index eaf58e5e5ad..b4ac38436e0 100644 --- a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx +++ b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx @@ -16,12 +16,12 @@ import { } from "react"; import { SignedImageUrl } from "@/components/SignedImageUrl"; import { useDashboardContext } from "../Contexts"; -import { CapIcon, CogIcon, LayersIcon } from "./AnimatedIcons"; +import { CogIcon, LayersIcon, RecordIcon } from "./AnimatedIcons"; import { updateActiveOrganization } from "./Navbar/server"; const Tabs = [ { icon: , href: "/dashboard/spaces/browse" }, - { icon: , href: "/dashboard/caps" }, + { icon: , href: "/dashboard/caps" }, { icon: , href: "/dashboard/settings/organization", diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/CapAIBox.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/CapAIBox.tsx index 7e490ad0990..a0afa6c10a7 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/CapAIBox.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/CapAIBox.tsx @@ -27,7 +27,7 @@ const CapAIBox = ({ className="hidden p-3 mb-6 w-[calc(100%-12px)] mx-auto rounded-xl border transition-colors cursor-pointer md:block hover:bg-gray-2 h-fit border-gray-3" >
-

Cap AI

+

AI

Available now

diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/CapAIDialog.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/CapAIDialog.tsx index 981fef8d01a..3850894e737 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/CapAIDialog.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/CapAIDialog.tsx @@ -34,7 +34,7 @@ const CapAIDialog = ({ setOpen }: { setOpen: (open: boolean) => void }) => { > }> - Cap AI + AI Pro @@ -44,8 +44,8 @@ const CapAIDialog = ({ setOpen }: { setOpen: (open: boolean) => void }) => {

- Cap AI automatically processes your recordings to make them more - useful and accessible. + AI automatically processes your recordings to make them more useful + and accessible.

Features include: diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Desktop.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Desktop.tsx index 0d43dcfcc4a..9b3d4ccc647 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Desktop.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Desktop.tsx @@ -1,5 +1,5 @@ "use client"; -import { Button, Logo } from "@cap/ui"; +import { Button } from "@cap/ui"; import clsx from "clsx"; import { motion } from "framer-motion"; import { useDetectPlatform } from "hooks/useDetectPlatform"; @@ -10,6 +10,7 @@ import { Tooltip } from "@/components/Tooltip"; import { useDashboardContext } from "../../Contexts"; import { DeveloperSidebarContent } from "../../developers/_components/DeveloperSidebarContent"; import AdminNavItems from "./Items"; +import { StorageUsageIndicator } from "./StorageUsageIndicator"; export const DesktopNav = () => { const { toggleSidebarCollapsed, sidebarCollapsed, isDeveloperSection } = @@ -63,17 +64,14 @@ export const DesktopNav = () => { )} ) : ( - - + + {sidebarCollapsed ? "V" : "Dashboard"} )} { )}

+ {!isDeveloperSection && ( + + )} ); diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index 0e7849d6910..a2ec1d0ec04 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -18,11 +18,7 @@ import { PopoverTrigger, } from "@cap/ui"; import { classNames } from "@cap/utils"; -import { - faBuilding, - faCircleInfo, - faLink, -} from "@fortawesome/free-solid-svg-icons"; +import { faBuilding, faLink } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; import { motion } from "framer-motion"; @@ -33,10 +29,8 @@ import { cloneElement, type RefObject, useRef, useState } from "react"; import { NewOrganization } from "@/components/forms/NewOrganization"; import { SignedImageUrl } from "@/components/SignedImageUrl"; import { Tooltip } from "@/components/Tooltip"; -import { UsageButton } from "@/components/UsageButton"; import { useDashboardContext } from "../../Contexts"; import { - CapIcon, ChartLineIcon, CodeIcon, CogIcon, @@ -45,7 +39,6 @@ import { } from "../AnimatedIcons"; import type { CogIconHandle } from "../AnimatedIcons/Cog"; import { MemberAvatars } from "./MemberAvatars"; -import SpacesList from "./SpacesList"; import { updateActiveOrganization } from "./server"; interface Props { @@ -57,7 +50,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { const [open, setOpen] = useState(false); const { user, sidebarCollapsed, userCapsCount } = useDashboardContext(); - const DEVELOPER_DASHBOARD_ALLOWED_EMAILS = ["richie@cap.so"]; + const DEVELOPER_DASHBOARD_ALLOWED_EMAILS = ["human@shashanksn.xyz"]; const showDeveloperDashboard = buildEnv.NEXT_PUBLIC_IS_CAP && @@ -65,10 +58,10 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { const manageNavigation = [ { - name: "My Caps", + name: "Videos", href: `/dashboard/caps`, extraText: userCapsCount, - icon: , + icon: , subNav: [], }, { @@ -79,7 +72,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { subNav: [], }, { - name: "Record a Cap", + name: "Record Video", href: `/dashboard/caps/record`, icon: , subNav: [], @@ -161,6 +154,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { )} role="combobox" aria-expanded={open} + tabIndex={0} >
{ )} />
-
+
{!sidebarCollapsed && (

@@ -200,29 +199,19 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { /> )}

- {!sidebarCollapsed && ( + {!sidebarCollapsed && isDomainSetupVerified && (

- {isDomainSetupVerified - ? activeOrg?.organization.customDomain - : "No custom domain set"} + {activeOrg.organization.customDomain}

)} @@ -303,10 +292,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { -
-
- toggleMobileNav?.()} - subscribed={user.isPro} - /> - {buildEnv.NEXT_PUBLIC_IS_CAP && ( -
- - Earn 40% Referral - -
- )} -

- Cap Software, Inc. {new Date().getFullYear()}. -

diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Mobile.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Mobile.tsx index b996e13eaf0..b090cadd682 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Mobile.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Mobile.tsx @@ -1,6 +1,5 @@ "use client"; -import { LogoBadge } from "@cap/ui"; import { useClickAway } from "@uidotdev/usehooks"; import { AnimatePresence, motion } from "framer-motion"; import { X } from "lucide-react"; @@ -50,8 +49,11 @@ export const AdminMobileNav = () => {
- - + + Dashboard
diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/StorageUsageIndicator.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/StorageUsageIndicator.tsx new file mode 100644 index 00000000000..f3d77a4fe65 --- /dev/null +++ b/apps/web/app/(org)/dashboard/_components/Navbar/StorageUsageIndicator.tsx @@ -0,0 +1,99 @@ +"use client"; + +import clsx from "clsx"; +import { HardDrive } from "lucide-react"; +import { useEffect, useState } from "react"; + +type StorageUsage = { + bucket: string; + usedBytes: number; + limitBytes: number; + usedPercent: number; + objectCount: number; +}; + +const formatBytes = (bytes: number) => { + if (bytes === 0) return "0 B"; + + const units = ["B", "KB", "MB", "GB", "TB"]; + const index = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + units.length - 1, + ); + const value = bytes / 1024 ** index; + const unit = units[index] ?? "B"; + + return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${unit}`; +}; + +export function StorageUsageIndicator({ collapsed }: { collapsed: boolean }) { + const [usage, setUsage] = useState(null); + + useEffect(() => { + let active = true; + + fetch("/api/storage/r2-usage", { cache: "no-store" }) + .then((response) => (response.ok ? response.json() : null)) + .then((data: StorageUsage | null) => { + if (active) setUsage(data); + }) + .catch(() => { + if (active) setUsage(null); + }); + + return () => { + active = false; + }; + }, []); + + if (!usage) { + return null; + } + + const percent = Math.max(0, Math.min(100, usage.usedPercent)); + const label = `${formatBytes(usage.usedBytes)} / ${formatBytes( + usage.limitBytes, + )}`; + + return ( +
+
+
+ + {!collapsed && ( +
+

+ Cloudflare R2 +

+

{label}

+
+ )} +
+ {!collapsed && ( +

+ {Math.round(percent)}% +

+ )} +
+ {!collapsed && ( +
+
+
+ )} +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx index 714f00e4d91..79c1d73e45a 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx @@ -15,7 +15,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useClickAway } from "@uidotdev/usehooks"; import clsx from "clsx"; import { AnimatePresence } from "framer-motion"; -import { MoreVertical } from "lucide-react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; import { signOut } from "next-auth/react"; @@ -36,10 +35,8 @@ import { UpgradeModal } from "@/components/UpgradeModal"; import { useDashboardContext, useTheme } from "../../Contexts"; import { ArrowUpIcon, - DownloadIcon, HomeIcon, LogoutIcon, - MessageCircleMoreIcon, ReferIcon, SettingsGearIcon, } from "../AnimatedIcons"; @@ -50,7 +47,7 @@ const Top = () => { const { activeSpace, anyNewNotifications, isDeveloperSection } = useDashboardContext(); const [toggleNotifications, setToggleNotifications] = useState(false); - const bellRef = useRef(null); + const bellRef = useRef(null); const { theme, setThemeHandler } = useTheme(); const queryClient = useQueryClient(); @@ -58,10 +55,8 @@ const Top = () => { const params = useParams(); const titles: Record = { - "/dashboard/caps": "Caps", - "/dashboard/folder": "Caps", - "/dashboard/shared-caps": "Shared Caps", - "/dashboard/caps/record": "Record a Cap", + "/dashboard/shared-caps": "Shared Videos", + "/dashboard/caps/record": "Record Video", "/dashboard/settings/organization": "Organization Settings", "/dashboard/settings/organization/preferences": "Organization Settings", "/dashboard/settings/organization/billing": "Organization Settings", @@ -70,8 +65,7 @@ const Top = () => { "/dashboard/spaces": "Spaces", "/dashboard/spaces/browse": "Browse Spaces", "/dashboard/analytics": "Analytics", - [`/dashboard/folder/${params.id}`]: "Caps", - [`/dashboard/analytics/s/${params.id}`]: "Analytics: Cap video title", + [`/dashboard/analytics/s/${params.id}`]: "Analytics: Video title", "/dashboard/developers": "Developers", "/dashboard/developers/apps": "Developer Apps", "/dashboard/developers/usage": "Developer Usage", @@ -118,14 +112,17 @@ const Top = () => { className="relative flex-shrink-0 size-5" /> )} -

- {title} -

+ {title && ( +

+ {title} +

+ )}
{buildEnv.NEXT_PUBLIC_IS_CAP && } -
{ @@ -143,8 +140,6 @@ const Top = () => { setToggleNotifications(!toggleNotifications); } }} - tabIndex={0} - role="button" aria-label={`Notifications${ anyNewNotifications ? " (new notifications available)" : "" }`} @@ -167,9 +162,11 @@ const Top = () => { {toggleNotifications && } -
+ {!isDeveloperSection && ( -
{ if (document.startViewTransition) { document.startViewTransition(() => { @@ -182,7 +179,7 @@ const Top = () => { className="hidden justify-center items-center rounded-full transition-colors cursor-pointer bg-gray-3 lg:flex hover:bg-gray-5 size-9" > -
+ )}
@@ -231,20 +228,6 @@ const User = () => { iconClassName: "text-gray-11 group-hover:text-gray-12", showCondition: true, }, - { - name: "Chat Support", - icon: , - onClick: () => window.open("https://cap.link/discord", "_blank"), - iconClassName: "text-gray-11 group-hover:text-gray-12", - showCondition: true, - }, - { - name: "Download App", - icon: , - onClick: () => window.open("https://cap.so/download", "_blank"), - iconClassName: "text-gray-11 group-hover:text-gray-12", - showCondition: true, - }, { name: "Sign Out", icon: , @@ -266,7 +249,7 @@ const User = () => {
{ {user.name ?? "User"}
-
@@ -359,7 +338,20 @@ const ReferButton = () => { useDashboardContext(); return ( - + { + setReferClickedStateHandler(true); + }} + onMouseEnter={() => { + iconRef.current?.startAnimation(); + }} + onMouseLeave={() => { + iconRef.current?.stopAnimation(); + }} + > {!referClickedState && (
@@ -369,23 +361,10 @@ const ReferButton = () => {
)} -
{ - setReferClickedStateHandler(true); - }} - onMouseEnter={() => { - iconRef.current?.startAnimation(); - }} - onMouseLeave={() => { - iconRef.current?.stopAnimation(); - }} - className="flex justify-center items-center rounded-full transition-colors cursor-pointer bg-gray-3 hover:bg-gray-5 size-9" - > - {cloneElement(, { - ref: iconRef, - className: "text-gray-12 size-3.5", - })} -
+ {cloneElement(, { + ref: iconRef, + className: "text-gray-12 size-3.5", + })} ); }; diff --git a/apps/web/app/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx b/apps/web/app/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx index 9bc2ba2b015..be87c4675bc 100644 --- a/apps/web/app/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx +++ b/apps/web/app/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx @@ -88,7 +88,7 @@ export function AnalyticsDashboard() { } if (data.subscription === true) { - toast.success("You are already on the Cap Pro plan"); + toast.success("You are already on the Pro plan"); return; } @@ -231,7 +231,7 @@ export function AnalyticsDashboard() {

- Upgrade to unlock Cap Analytics + Upgrade to unlock analytics

@@ -314,7 +314,7 @@ export function AnalyticsDashboard() { > {proCheckoutMutation.isPending ? "Loading..." - : "Upgrade to Cap Pro"} + : "Upgrade to Pro"}

diff --git a/apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx b/apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx index 56c46efb51b..3c9509d87c6 100644 --- a/apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx +++ b/apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx @@ -25,7 +25,7 @@ const chartConfig = { color: "#f97316", }, caps: { - label: "Caps", + label: "Videos", color: "var(--gray-12)", }, } satisfies ChartConfig; diff --git a/apps/web/app/(org)/dashboard/analytics/components/Header.tsx b/apps/web/app/(org)/dashboard/analytics/components/Header.tsx index a0dd840c30a..f82eed49e0b 100644 --- a/apps/web/app/(org)/dashboard/analytics/components/Header.tsx +++ b/apps/web/app/(org)/dashboard/analytics/components/Header.tsx @@ -229,8 +229,8 @@ export default function Header({ ? selectedSpace.name : isMyCapsSelected ? user?.name - ? `${user.name}'s Caps` - : "My Caps" + ? `${user.name}'s Videos` + : "Videos" : selectedOrg?.organization.name || "Select organization"; const displayIcon = selectedSpace @@ -242,7 +242,7 @@ export default function Header({ const displayIconName = selectedSpace ? selectedSpace.name : isMyCapsSelected - ? user?.name || selectedOrg?.organization.name || "My Caps" + ? user?.name || selectedOrg?.organization.name || "Videos" : selectedOrg?.organization.name || "Select organization"; if (!activeOrganization) { @@ -323,7 +323,7 @@ export default function Header({ className="size-5 flex-shrink-0" /> - {user?.name ? `${user.name}'s Caps` : "My Caps"} + {user?.name ? `${user.name}'s Videos` : "Videos"} {filteredSpaces && filteredSpaces.length > 0 && ( diff --git a/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx b/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx index d2e630b1265..100811cf451 100644 --- a/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx +++ b/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx @@ -183,12 +183,12 @@ export default function OtherStats({ data, isLoading }: OtherStatsProps) { {data.topCaps && data.topCaps.length > 0 && (
(null); - const viewsBoxRef = useRef(null); - const chatsBoxRef = useRef(null); - const reactionsBoxRef = useRef(null); + const capsBoxRef = useRef(null); + const viewsBoxRef = useRef(null); + const chatsBoxRef = useRef(null); + const reactionsBoxRef = useRef(null); const toggleHandler = (box: boxes) => { setSelectedBoxes((prev) => { @@ -114,12 +114,12 @@ export default function StatsBox({ toggleHandler("caps")} isSelected={selectedBoxes.has("caps")} - title="Caps" + title="Videos" value={formattedCounts.caps} metric="caps" onMouseEnter={() => capsBoxRef.current?.startAnimation()} onMouseLeave={() => capsBoxRef.current?.stopAnimation()} - icon={} + icon={} /> )} diff --git a/apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx b/apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx index 8a3034ec87b..23f151d9e52 100644 --- a/apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx +++ b/apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx @@ -1,7 +1,6 @@ "use client"; import { - LogoBadge, Table, TableBody, TableCell, @@ -289,7 +288,7 @@ const getIconForRow = ( ); } case "cap": - return ; + return ; default: return null; } diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index 4106cc732df..e932b9004d0 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -31,6 +31,7 @@ export type VideoData = { ownerId: string; name: string; createdAt: Date; + expiresAt?: Date | null; public: boolean; totalComments: number; totalReactions: number; @@ -120,14 +121,14 @@ export const Caps = ({ } else { return yield* Effect.fail( new Error( - `Failed to delete ${errorCount} cap${errorCount === 1 ? "" : "s"}`, + `Failed to delete ${errorCount} video${errorCount === 1 ? "" : "s"}`, ), ); } }), onMutate: (ids: Video.VideoId[]) => { toast.loading( - `Deleting ${ids.length} cap${ids.length === 1 ? "" : "s"}...`, + `Deleting ${ids.length} video${ids.length === 1 ? "" : "s"}...`, ); }, onSuccess: (data: { success: number; error?: number }) => { @@ -135,15 +136,15 @@ export const Caps = ({ router.refresh(); if (data.error) { toast.success( - `Successfully deleted ${data.success} cap${ + `Successfully deleted ${data.success} video${ data.success === 1 ? "" : "s" - }, but failed to delete ${data.error} cap${ + }, but failed to delete ${data.error} video${ data.error === 1 ? "" : "s" }`, ); } else { toast.success( - `Successfully deleted ${data.success} cap${ + `Successfully deleted ${data.success} video${ data.success === 1 ? "" : "s" }`, ); @@ -153,7 +154,7 @@ export const Caps = ({ const message = error instanceof Error ? error.message - : "An error occurred while deleting caps"; + : "An error occurred while deleting videos"; toast.error(message); }, }); @@ -163,10 +164,10 @@ export const Caps = ({ yield* rpc.VideoDelete(id); }), onSuccess: () => { - toast.success("Cap deleted successfully"); + toast.success("Video deleted successfully"); router.refresh(); }, - onError: (_error: unknown) => toast.error("Failed to delete cap"), + onError: (_error: unknown) => toast.error("Failed to delete video"), }); useEffect(() => { @@ -267,39 +268,31 @@ export const Caps = ({ )} {visibleVideos.length > 0 && ( - <> -
-

Videos

-
- -
- {isUploading && ( - - )} - {visibleVideos.map((video) => { - const videoAnalytics = analytics[video.id]; - return ( - { - if (selectedCaps.length > 0) { - deleteCaps(selectedCaps); - } else { - deleteCap(video.id); - } - }} - userId={user?.id} - isLoadingAnalytics={isLoadingAnalytics} - isSelected={selectedCaps.includes(video.id)} - anyCapSelected={anyCapSelected} - onSelectToggle={() => handleCapSelection(video.id)} - /> - ); - })} -
- +
+ {isUploading && } + {visibleVideos.map((video) => { + const videoAnalytics = analytics[video.id]; + return ( + { + if (selectedCaps.length > 0) { + deleteCaps(selectedCaps); + } else { + deleteCap(video.id); + } + }} + userId={user?.id} + isLoadingAnalytics={isLoadingAnalytics} + isSelected={selectedCaps.includes(video.id)} + anyCapSelected={anyCapSelected} + onSelectToggle={() => handleCapSelection(video.id)} + /> + ); + })} +
)} {(data.length > limit || data.length === limit || page !== 1) && (
diff --git a/apps/web/app/(org)/dashboard/caps/[videoId]/studio/BrowserStudioEditor.tsx b/apps/web/app/(org)/dashboard/caps/[videoId]/studio/BrowserStudioEditor.tsx new file mode 100644 index 00000000000..551b9b2e6f3 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/[videoId]/studio/BrowserStudioEditor.tsx @@ -0,0 +1,1796 @@ +"use client"; + +import { + faArrowLeft, + faCheck, + faMagnifyingGlassPlus, + faPause, + faPlay, + faUpload, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import clsx from "clsx"; +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { + type BrowserStudioCanvasAspectRatio, + type BrowserStudioCloudManifest, + type BrowserStudioEditSettings, + type BrowserStudioSource, + getBrowserStudioEditSettings, + normalizeBrowserStudioManifest, +} from "@/lib/browser-studio"; +import { deleteUploadedBrowserStudioVaultSessions } from "../../components/web-recorder-dialog/browser-studio-vault"; + +type BrowserStudioEditorProps = { + videoId: string; + title: string; + shareUrl: string; +}; + +type BrowserStudioPayload = { + manifest: BrowserStudioCloudManifest; + sources: BrowserStudioSource[]; +}; + +const aspectRatioOptions = [ + { value: "source", label: "Source" }, + { value: "16:9", label: "16:9" }, + { value: "1:1", label: "1:1" }, + { value: "9:16", label: "9:16" }, +] satisfies { + value: BrowserStudioCanvasAspectRatio; + label: string; +}[]; + +const backgroundOptions = [ + "#111111", + "#f8f8f7", + "#183d3d", + "#7c2d12", + "#1d4ed8", + "#701a75", +]; + +const backgroundModeOptions = [ + { value: "solid", label: "Solid" }, + { value: "blur", label: "Blur" }, + { value: "gradient", label: "Gradient" }, +] satisfies Array<{ + value: BrowserStudioEditSettings["canvas"]["backgroundMode"]; + label: string; +}>; + +const gradientPresets = [ + { from: "#0f3443", to: "#34e89e" }, + { from: "#22c1c3", to: "#fdbb2d" }, + { from: "#4568dc", to: "#b06ab3" }, + { from: "#fc5c7d", to: "#6a82fb" }, + { from: "#ff5e00", to: "#ff2a68" }, + { from: "#2c3e50", to: "#3498db" }, +]; + +const playbackSpeedOptions = [0.5, 0.75, 1, 1.25, 1.5, 2]; + +const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + +const formatTime = (ms: number | null | undefined) => { + if (!ms || ms <= 0) return "0:00"; + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +}; + +const getAspectRatioValue = ( + aspectRatio: BrowserStudioCanvasAspectRatio, + sourceRatio: number, +) => { + if (aspectRatio === "16:9") return 16 / 9; + if (aspectRatio === "1:1") return 1; + if (aspectRatio === "9:16") return 9 / 16; + return sourceRatio; +}; + +export function BrowserStudioEditor({ + videoId, + title, + shareUrl, +}: BrowserStudioEditorProps) { + const videoRef = useRef(null); + const backgroundVideoRef = useRef(null); + const cameraVideoRef = useRef(null); + const previewRef = useRef(null); + const [manifest, setManifest] = useState( + null, + ); + const [sources, setSources] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [exporting, setExporting] = useState(false); + const [error, setError] = useState(null); + const [mediaDurationMs, setMediaDurationMs] = useState(null); + const [playing, setPlaying] = useState(false); + const [activeAssetId, setActiveAssetId] = useState(null); + const [currentMs, setCurrentMs] = useState(0); + const [zoomToolArmed, setZoomToolArmed] = useState(false); + const [selectedZoomId, setSelectedZoomId] = useState(null); + const [selectedTextId, setSelectedTextId] = useState(null); + + useEffect(() => { + let cancelled = false; + + const loadStudio = async () => { + setLoading(true); + setError(null); + + try { + const response = await fetch( + `/api/video/studio?videoId=${encodeURIComponent(videoId)}`, + { credentials: "same-origin" }, + ); + + if (!response.ok) { + throw new Error("Studio project could not be loaded"); + } + + const payload = (await response.json()) as BrowserStudioPayload; + if (cancelled) return; + + const nextManifest = normalizeBrowserStudioManifest(payload.manifest); + setManifest(nextManifest); + setSources(payload.sources); + setActiveAssetId(nextManifest.assets[0]?.assetId ?? null); + } catch (loadError) { + if (cancelled) return; + console.error("Failed to load Browser Studio", loadError); + setError("Studio project could not be loaded."); + } finally { + if (!cancelled) setLoading(false); + } + }; + + void loadStudio(); + + return () => { + cancelled = true; + }; + }, [videoId]); + + const edit = useMemo( + () => (manifest ? getBrowserStudioEditSettings(manifest) : null), + [manifest], + ); + const activeAsset = useMemo( + () => + manifest?.assets.find((asset) => asset.assetId === activeAssetId) ?? + manifest?.assets[0] ?? + null, + [manifest, activeAssetId], + ); + const activeSource = useMemo(() => { + if (!manifest) return sources[0] ?? null; + const subpath = activeAsset?.sourceSubpath; + return ( + sources.find((source) => source.subpath === subpath) ?? sources[0] ?? null + ); + }, [manifest, sources, activeAsset]); + const primaryAsset = useMemo( + () => + manifest?.assets.find( + (asset) => asset.kind === "screen" || asset.kind === "mixed", + ) ?? + manifest?.assets[0] ?? + null, + [manifest], + ); + const primarySource = useMemo( + () => + sources.find( + (source) => source.subpath === primaryAsset?.sourceSubpath, + ) ?? activeSource, + [sources, primaryAsset, activeSource], + ); + const cameraAsset = useMemo( + () => manifest?.assets.find((asset) => asset.kind === "camera") ?? null, + [manifest], + ); + const cameraTrack = useMemo( + () => + manifest?.project.timeline.tracks.find( + (track) => track.assetId === cameraAsset?.assetId, + ) ?? null, + [manifest, cameraAsset], + ); + const cameraSource = useMemo( + () => + cameraTrack?.muted + ? null + : (sources.find( + (source) => source.subpath === cameraAsset?.sourceSubpath, + ) ?? null), + [sources, cameraAsset, cameraTrack], + ); + const cameraSourceRatio = + cameraAsset?.width && cameraAsset.height && cameraAsset.width > 0 + ? `${cameraAsset.width} / ${cameraAsset.height}` + : "1 / 1"; + const durationMs = + manifest?.project.timeline.durationMs ?? mediaDurationMs ?? 0; + const trimStartMs = edit?.trim.startMs ?? 0; + const trimEndMs = edit?.trim.endMs ?? durationMs; + const sourceRatio = + activeAsset?.width && activeAsset.height + ? activeAsset.width / activeAsset.height + : 16 / 9; + const canvasRatio = getAspectRatioValue( + edit?.canvas.aspectRatio ?? "source", + sourceRatio, + ); + const sortedZooms = useMemo( + () => [...(edit?.zooms ?? [])].sort((a, b) => a.startMs - b.startMs), + [edit], + ); + const selectedZoom = useMemo( + () => sortedZooms.find((zoom) => zoom.id === selectedZoomId) ?? null, + [sortedZooms, selectedZoomId], + ); + const activeZoom = useMemo(() => { + for (let index = sortedZooms.length - 1; index >= 0; index -= 1) { + const zoom = sortedZooms[index]; + if (zoom && currentMs >= zoom.startMs && currentMs <= zoom.endMs) { + return zoom; + } + } + return null; + }, [sortedZooms, currentMs]); + const previewScale = edit ? edit.canvas.scale * (activeZoom?.scale ?? 1) : 1; + const previewOriginX = activeZoom?.originX ?? 0.5; + const previewOriginY = activeZoom?.originY ?? 0.5; + const sortedTextOverlays = useMemo( + () => [...(edit?.textOverlays ?? [])].sort((a, b) => a.startMs - b.startMs), + [edit], + ); + const selectedTextOverlay = useMemo( + () => + sortedTextOverlays.find((overlay) => overlay.id === selectedTextId) ?? + null, + [sortedTextOverlays, selectedTextId], + ); + const activeTextOverlays = useMemo( + () => + sortedTextOverlays.filter( + (overlay) => currentMs >= overlay.startMs && currentMs <= overlay.endMs, + ), + [sortedTextOverlays, currentMs], + ); + + const updateEdit = useCallback( + ( + updater: (edit: BrowserStudioEditSettings) => BrowserStudioEditSettings, + ) => { + setManifest((current) => + current + ? { + ...current, + updatedAt: Date.now(), + edit: updater(getBrowserStudioEditSettings(current)), + } + : current, + ); + }, + [], + ); + + const setTrimStart = (value: number) => { + updateEdit((current) => { + const maxStart = Math.max(0, (current.trim.endMs ?? durationMs) - 500); + return { + ...current, + trim: { + ...current.trim, + startMs: clamp(value, 0, maxStart), + }, + }; + }); + }; + + const setTrimEnd = (value: number) => { + updateEdit((current) => ({ + ...current, + trim: { + ...current.trim, + endMs: clamp(value, current.trim.startMs + 500, durationMs), + }, + })); + }; + + const updateZoom = ( + zoomId: string, + updater: ( + zoom: BrowserStudioEditSettings["zooms"][number], + ) => BrowserStudioEditSettings["zooms"][number], + ) => { + updateEdit((current) => ({ + ...current, + zooms: current.zooms.map((zoom) => + zoom.id === zoomId ? updater(zoom) : zoom, + ), + })); + }; + + const removeZoom = (zoomId: string) => { + updateEdit((current) => ({ + ...current, + zooms: current.zooms.filter((zoom) => zoom.id !== zoomId), + })); + setSelectedZoomId((current) => (current === zoomId ? null : current)); + }; + + const updateTextOverlay = ( + overlayId: string, + updater: ( + overlay: BrowserStudioEditSettings["textOverlays"][number], + ) => BrowserStudioEditSettings["textOverlays"][number], + ) => { + updateEdit((current) => ({ + ...current, + textOverlays: current.textOverlays.map((overlay) => + overlay.id === overlayId ? updater(overlay) : overlay, + ), + })); + }; + + const addTextOverlay = () => { + const playheadMs = clamp( + Math.round( + videoRef.current?.currentTime + ? videoRef.current.currentTime * 1000 + : currentMs, + ), + trimStartMs, + trimEndMs, + ); + const startMs = clamp(playheadMs, trimStartMs, trimEndMs - 500); + const endMs = clamp(startMs + 3000, startMs + 500, trimEndMs); + const id = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `text-${Date.now()}`; + + updateEdit((current) => ({ + ...current, + textOverlays: [ + ...current.textOverlays, + { + id, + startMs, + endMs, + text: "Add context", + x: 0.5, + y: 0.12, + size: 42, + color: "#ffffff", + background: "#00000099", + }, + ], + })); + setSelectedTextId(id); + }; + + const removeTextOverlay = (overlayId: string) => { + updateEdit((current) => ({ + ...current, + textOverlays: current.textOverlays.filter( + (overlay) => overlay.id !== overlayId, + ), + })); + setSelectedTextId((current) => (current === overlayId ? null : current)); + }; + + const createZoomAtPoint = (originX: number, originY: number) => { + if (!edit) return; + + const playheadMs = clamp( + Math.round( + videoRef.current?.currentTime + ? videoRef.current.currentTime * 1000 + : currentMs, + ), + trimStartMs, + trimEndMs, + ); + const startMs = clamp(playheadMs - 500, trimStartMs, trimEndMs - 500); + const endMs = clamp(startMs + 2500, startMs + 500, trimEndMs); + const id = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `zoom-${Date.now()}`; + + updateEdit((current) => ({ + ...current, + zooms: [ + ...current.zooms, + { + id, + startMs, + endMs, + scale: 1.8, + originX: clamp(originX, 0.05, 0.95), + originY: clamp(originY, 0.05, 0.95), + }, + ], + })); + setSelectedZoomId(id); + setZoomToolArmed(false); + toast.success("Zoom segment added"); + }; + + const handlePreviewClick = (event: React.MouseEvent) => { + if (!zoomToolArmed || !previewRef.current) return; + + const rect = previewRef.current.getBoundingClientRect(); + createZoomAtPoint( + (event.clientX - rect.left) / rect.width, + (event.clientY - rect.top) / rect.height, + ); + }; + + const seekToMs = useCallback( + (value: number) => { + const nextMs = clamp(value, trimStartMs, trimEndMs || durationMs); + const nextSeconds = nextMs / 1000; + if (videoRef.current) { + videoRef.current.currentTime = nextSeconds; + } + if (backgroundVideoRef.current) { + backgroundVideoRef.current.currentTime = nextSeconds; + } + if (cameraVideoRef.current) { + cameraVideoRef.current.currentTime = nextSeconds; + } + setCurrentMs(nextMs); + }, + [durationMs, trimEndMs, trimStartMs], + ); + + const toggleTrackMuted = (trackId: string) => { + setManifest((current) => + current + ? { + ...current, + updatedAt: Date.now(), + project: { + ...current.project, + timeline: { + ...current.project.timeline, + tracks: current.project.timeline.tracks.map((track) => + track.trackId === trackId + ? { ...track, muted: !track.muted } + : track, + ), + }, + }, + } + : current, + ); + }; + + const saveProject = async () => { + if (!manifest || saving || exporting) return; + + setSaving(true); + + try { + const response = await fetch("/api/video/studio", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ videoId, manifest }), + }); + + if (!response.ok) { + throw new Error("Studio project could not be saved"); + } + + const payload = (await response.json()) as { + manifest: BrowserStudioCloudManifest; + }; + const nextManifest = normalizeBrowserStudioManifest(payload.manifest); + setManifest(nextManifest); + setActiveAssetId( + (current) => current ?? nextManifest.assets[0]?.assetId ?? null, + ); + await deleteUploadedBrowserStudioVaultSessions().catch((cleanupError) => { + console.error( + "Failed to clean uploaded Browser Studio sessions", + cleanupError, + ); + }); + toast.success("Studio project saved"); + } catch (saveError) { + console.error("Failed to save Browser Studio project", saveError); + toast.error("Could not save Studio project"); + } finally { + setSaving(false); + } + }; + + const exportProject = async () => { + if (!manifest || saving || exporting) return; + + setExporting(true); + + try { + const response = await fetch("/api/video/studio/render", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ videoId, manifest }), + }); + + if (!response.ok) { + throw new Error("Studio project could not be exported"); + } + + const payload = (await response.json()) as { + manifest: BrowserStudioCloudManifest; + }; + const nextManifest = normalizeBrowserStudioManifest(payload.manifest); + setManifest(nextManifest); + setActiveAssetId( + (current) => current ?? nextManifest.assets[0]?.assetId ?? null, + ); + await deleteUploadedBrowserStudioVaultSessions().catch((cleanupError) => { + console.error( + "Failed to clean uploaded Browser Studio sessions", + cleanupError, + ); + }); + toast.success("Share video updated"); + } catch (exportError) { + console.error("Failed to export Browser Studio project", exportError); + toast.error("Could not update share video"); + } finally { + setExporting(false); + } + }; + + const togglePlayback = async () => { + const video = videoRef.current; + if (!video) return; + + if (video.paused) { + const currentMs = video.currentTime * 1000; + if (currentMs < trimStartMs || currentMs >= trimEndMs) { + video.currentTime = trimStartMs / 1000; + if (backgroundVideoRef.current) { + backgroundVideoRef.current.currentTime = trimStartMs / 1000; + } + if (cameraVideoRef.current) { + cameraVideoRef.current.currentTime = trimStartMs / 1000; + } + } + await video.play(); + await backgroundVideoRef.current?.play().catch(() => undefined); + await cameraVideoRef.current?.play().catch(() => undefined); + setPlaying(true); + return; + } + + video.pause(); + backgroundVideoRef.current?.pause(); + cameraVideoRef.current?.pause(); + setPlaying(false); + }; + + useEffect(() => { + const video = videoRef.current; + if (!video || !edit) return; + + const handleTimeUpdate = () => { + const currentMs = video.currentTime * 1000; + setCurrentMs(currentMs); + const backgroundVideo = backgroundVideoRef.current; + if ( + backgroundVideo && + Math.abs(backgroundVideo.currentTime - video.currentTime) > 0.25 + ) { + backgroundVideo.currentTime = video.currentTime; + } + const cameraVideo = cameraVideoRef.current; + if ( + cameraVideo && + Math.abs(cameraVideo.currentTime - video.currentTime) > 0.25 + ) { + cameraVideo.currentTime = video.currentTime; + } + if (currentMs >= (edit.trim.endMs ?? durationMs)) { + video.pause(); + backgroundVideo?.pause(); + cameraVideo?.pause(); + video.currentTime = edit.trim.startMs / 1000; + if (backgroundVideo) { + backgroundVideo.currentTime = edit.trim.startMs / 1000; + } + if (cameraVideo) { + cameraVideo.currentTime = edit.trim.startMs / 1000; + } + setCurrentMs(edit.trim.startMs); + setPlaying(false); + } + }; + const handlePlay = () => setPlaying(true); + const handlePause = () => { + backgroundVideoRef.current?.pause(); + cameraVideoRef.current?.pause(); + setPlaying(false); + }; + + video.addEventListener("timeupdate", handleTimeUpdate); + video.addEventListener("play", handlePlay); + video.addEventListener("pause", handlePause); + + return () => { + video.removeEventListener("timeupdate", handleTimeUpdate); + video.removeEventListener("play", handlePlay); + video.removeEventListener("pause", handlePause); + }; + }, [edit, durationMs]); + + useEffect(() => { + const video = videoRef.current; + if (!video || !edit) return; + video.volume = edit.audio.volume; + }, [edit]); + + useEffect(() => { + if (!edit) return; + const speed = clamp(edit.playback.speed, 0.5, 2); + if (videoRef.current) { + videoRef.current.playbackRate = speed; + } + if (backgroundVideoRef.current) { + backgroundVideoRef.current.playbackRate = speed; + } + if (cameraVideoRef.current) { + cameraVideoRef.current.playbackRate = speed; + } + }, [edit]); + + if (loading) { + return ( +
+
+
+
+
+
+
+ ); + } + + if (error || !manifest || !edit || !activeSource || !primarySource) { + return ( +
+
+

+ {error ?? "Studio project is unavailable."} +

+ + + Back to videos + +
+
+ ); + } + + return ( +
+
+
+ + + +
+

{title}

+

Studio

+
+
+
+ + Open share + + + +
+
+ +
+
+ + +
+
+ +
+ {formatTime(currentMs)} / {formatTime(trimEndMs)} +
+ + seekToMs(Number(event.currentTarget.value)) + } + className="min-w-48 flex-1" + aria-label="Scrub timeline" + /> + +
+
+ + Speed + + {playbackSpeedOptions.map((speed) => ( + + ))} +
+ +
+ {manifest.project.timeline.tracks.map((track) => { + const trackDuration = track.durationMs ?? durationMs; + const widthPercent = + durationMs > 0 ? (trackDuration / durationMs) * 100 : 100; + const isActive = track.assetId === activeAsset?.assetId; + + return ( +
+
+ +
+ {track.kind} + +
+
+ +
+ ); + })} +
+
+
+ + +
+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/caps/[videoId]/studio/page.tsx b/apps/web/app/(org)/dashboard/caps/[videoId]/studio/page.tsx new file mode 100644 index 00000000000..6c540d3ba3f --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/[videoId]/studio/page.tsx @@ -0,0 +1,49 @@ +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { videos } from "@cap/database/schema"; +import { Video } from "@cap/web-domain"; +import { and, eq } from "drizzle-orm"; +import type { Metadata } from "next"; +import { notFound, redirect } from "next/navigation"; +import { removeCapFromVideoTitle } from "@/lib/video-title"; +import { BrowserStudioEditor } from "./BrowserStudioEditor"; + +export const metadata: Metadata = { + title: "Studio", +}; + +export default async function BrowserStudioPage({ + params, +}: { + params: Promise<{ videoId: string }>; +}) { + const { videoId } = await params; + const user = await getCurrentUser(); + + if (!user?.id) redirect("/login"); + + const [video] = await db() + .select({ + id: videos.id, + name: videos.name, + ownerId: videos.ownerId, + }) + .from(videos) + .where( + and( + eq(videos.id, Video.VideoId.make(videoId)), + eq(videos.ownerId, user.id), + ), + ) + .limit(1); + + if (!video) notFound(); + + return ( + + ); +} 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 7f77fffae6e..032acc13e6a 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -14,6 +14,7 @@ import { HttpClient } from "@effect/platform"; import { faChartSimple, faCheck, + faClock, faCopy, faDownload, faEllipsis, @@ -44,6 +45,7 @@ import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; import { ThumbnailRequest } from "@/lib/Requests/ThumbnailRequest"; import { usePublicEnv } from "@/utils/public-env"; +import { ExpiryDialog } from "../ExpiryDialog"; import { PasswordDialog } from "../PasswordDialog"; import { SettingsDialog } from "../SettingsDialog"; import { SharingDialog } from "../SharingDialog"; @@ -90,6 +92,7 @@ export interface CapCardProps extends PropsWithChildren { metadata?: VideoMetadata; hasPassword?: boolean; hasActiveUpload: boolean | undefined; + expiresAt?: Date | null; duration?: number; settings?: { disableComments?: boolean; @@ -134,6 +137,7 @@ export const CapCard = ({ const [isSharingDialogOpen, setIsSharingDialogOpen] = useState(false); const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false); + const [isExpiryDialogOpen, setIsExpiryDialogOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [passwordProtected, setPasswordProtected] = useState( cap.hasPassword || false, @@ -342,9 +346,7 @@ export const CapCard = ({ ? `${webUrl}/s/${cap.id}` : buildEnv.NEXT_PUBLIC_IS_CAP && customDomain && domainVerified ? `https://${customDomain}/s/${cap.id}` - : buildEnv.NEXT_PUBLIC_IS_CAP && !customDomain && !domainVerified - ? `https://cap.link/${cap.id}` - : `${webUrl}/s/${cap.id}`, + : `${webUrl}/s/${cap.id}`, ); }; @@ -374,8 +376,14 @@ export const CapCard = ({ hasPassword={passwordProtected} onPasswordUpdated={handlePasswordUpdated} /> -
setIsExpiryDialogOpen(false)} + videoId={cap.id} + expiresAt={cap.expiresAt} + onExpiryUpdated={() => router.refresh()} + /> +
{anyCapSelected && !sharedCapCard && ( -
+ )}
@@ -641,6 +678,8 @@ export const CapCard = ({
-
+
); }; diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCardContent.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCardContent.tsx index 34e7c2ca4a1..739b610cfe2 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCardContent.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCardContent.tsx @@ -8,6 +8,7 @@ import { toast } from "sonner"; import { editDate } from "@/actions/videos/edit-date"; import { editTitle } from "@/actions/videos/edit-title"; import { Tooltip } from "@/components/Tooltip"; +import { removeCapFromVideoTitle } from "@/lib/video-title"; import type { CapCardProps } from "./CapCard"; interface CapContentProps { @@ -37,7 +38,8 @@ export const CapCardContent: React.FC = ({ ); const [isDateEditing, setIsDateEditing] = useState(false); const [showFullDate, setShowFullDate] = useState(false); - const [title, setTitle] = useState(cap.name); + const displayName = removeCapFromVideoTitle(cap.name); + const [title, setTitle] = useState(displayName); const [isEditing, setIsEditing] = useState(false); const handleTitleBlur = async (capName: string) => { @@ -76,6 +78,20 @@ export const CapCardContent: React.FC = ({ } }; + const handleSharedStatusKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setIsSharingDialogOpen(true); + } + }; + + const handleTitleDisplayKeyDown = (e: React.KeyboardEvent) => { + if ((e.key === "Enter" || e.key === " ") && !sharedCapCard) { + e.preventDefault(); + if (userId === cap.ownerId) setIsEditing(true); + } + }; + const handleDateChange = (e: React.ChangeEvent) => { setDateValue(e.target.value); }; @@ -129,6 +145,13 @@ export const CapCardContent: React.FC = ({ } }; + const handleDateDisplayKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleDateClick(); + } + }; + const renderSharedStatus = () => { const baseClassName = clsx( "text-sm text-gray-10 transition-colors duration-200 flex items-center mb-1", @@ -143,23 +166,33 @@ export const CapCardContent: React.FC = ({ if (!hasSpaceSharing && !isPublic) { return ( -

setIsSharingDialogOpen(true)} + onKeyDown={handleSharedStatusKeyDown} > Not shared{" "} -

+ ); } else { return ( -

setIsSharingDialogOpen(true)} + onKeyDown={handleSharedStatusKeyDown} > Shared{" "} -

+ ); } } else { @@ -170,14 +203,12 @@ export const CapCardContent: React.FC = ({ return (
- {" "} - {/* Fixed height container */} {isEditing && !sharedCapCard ? (