diff --git a/Cargo.lock b/Cargo.lock index 4d08eb8ddc6..7550380a87d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11443,6 +11443,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index bc3f373e1e4..e77926ccfaa 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -11074,6 +11074,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "libc", "pin-project-lite", "tokio", diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 65eabb37589..a931dac5a3d 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -85,7 +85,6 @@ const ICONS: &[(&str, &[u8])] = assets!("icons": "laptop.svg", "shuffle.svg", "gift.svg", - "history.svg", "hotkeys.svg", "image.svg", "info.svg", @@ -99,7 +98,6 @@ const ICONS: &[(&str, &[u8])] = assets!("icons": "message-square-plus.svg", "mic-off.svg", "microphone.svg", - "minimize.svg", "minus.svg", "monitor.svg", "more-vertical.svg", @@ -186,10 +184,7 @@ const ICONS: &[(&str, &[u8])] = assets!("icons": "copy.svg", "record-fill.svg", "warning-bold.svg", - // The main window's hand-drawn traffic lights: the x and expand glyphs - // `CaptionControlsMacOS.tsx` inlines, shown while the group is hovered. "traffic-close.svg", - "traffic-zoom.svg", // The remaining settings pages (`settings_pages.rs`). `circle-check` is // Cap's own (`packages/ui-solid/icons/circle-check.svg`, hotkeys.tsx's // IconCapCircleCheck); the rest are the Lucide 24x24 originals the pages' @@ -335,6 +330,7 @@ mod tests { // `ui::SelectionHeader` names the check and the trash itself. include_str!("ui/selection_header.rs"), include_str!("ui/radio_cards.rs"), + include_str!("ui/windows_caption.rs"), // The onboarding window's welcome cards and permissions surface; the // per-permission row glyphs are named on `OSPermission::icon`. include_str!("onboarding_window.rs"), diff --git a/apps/web/__tests__/unit/desktop-video-create.test.ts b/apps/web/__tests__/unit/desktop-video-create.test.ts index 6dcf02ed9a1..d2ba7891b7a 100644 --- a/apps/web/__tests__/unit/desktop-video-create.test.ts +++ b/apps/web/__tests__/unit/desktop-video-create.test.ts @@ -138,6 +138,7 @@ const { Storage } = await import("@cap/web-backend"); const { invalidateGoogleDriveStorageQuotaCache } = await import( "@/lib/google-drive-storage-quota" ); +const { maybeStartLiveTranscription } = await import("@/lib/live-transcribe"); function resetMockDb() { for (const key of Object.keys(mockDb)) { @@ -170,6 +171,50 @@ function insertedValues(table: unknown) { | undefined; } +function recordingTransactionFixture(rejectUpload: boolean) { + type Row = Record; + type Rows = { videos: Map; uploads: Map }; + const committed: Rows = { videos: new Map(), uploads: new Map() }; + const transactionInserts: unknown[] = []; + const directInserts: unknown[] = []; + const insertInto = (rows: Rows, inserts: unknown[]) => (table: unknown) => { + inserts.push(table); + return { + values: async (value: Row) => { + if (table === schema.videoUploads && rejectUpload) + throw new Error("Injected upload insert failure"); + const target = + table === schema.videos + ? rows.videos + : table === schema.videoUploads + ? rows.uploads + : null; + if (!target) throw new Error("Unexpected insert table"); + const id = String(table === schema.videos ? value.id : value.videoId); + target.set(id, { ...value }); + }, + }; + }; + mockDb.insert.mockImplementation(insertInto(committed, directInserts)); + mockDb.transaction.mockImplementation( + async ( + run: (tx: { insert: ReturnType }) => Promise, + ) => { + const candidate: Rows = { + videos: new Map(committed.videos), + uploads: new Map(committed.uploads), + }; + const result = await run({ + insert: insertInto(candidate, transactionInserts), + }); + committed.videos = candidate.videos; + committed.uploads = candidate.uploads; + return result; + }, + ); + return { committed, transactionInserts, directInserts }; +} + function stubStorage() { const getOrganizationWritableAccess = Storage.getOrganizationWritableAccess as ReturnType; @@ -420,7 +465,7 @@ describe("GET /create", () => { const response = await app.request("https://cap.test/create"); expect(response.status).toBe(200); - expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(mockDb.transaction).toHaveBeenCalledTimes(2); const orgValues = insertedValues(schema.organizations) as | { id: string; ownerId: string; name: string } @@ -469,7 +514,7 @@ describe("GET /create", () => { const response = await app.request("https://cap.test/create"); expect(response.status).toBe(200); - expect(mockDb.transaction).not.toHaveBeenCalled(); + expect(mockDb.transaction).toHaveBeenCalledTimes(1); expect(insertedValues(schema.videos)).toMatchObject({ orgId: "org-1", @@ -494,7 +539,7 @@ describe("GET /create", () => { const response = await app.request("https://cap.test/create"); expect(response.status).toBe(200); - expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(mockDb.transaction).toHaveBeenCalledTimes(2); const orgValues = insertedValues(schema.organizations) as | { id: string } @@ -524,7 +569,7 @@ describe("GET /create", () => { ); expect(response.status).toBe(200); - expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(mockDb.transaction).toHaveBeenCalledTimes(2); const orgValues = insertedValues(schema.organizations) as | { id: string } @@ -558,7 +603,7 @@ describe("GET /create", () => { ); expect(response.status).toBe(200); - expect(mockDb.transaction).not.toHaveBeenCalled(); + expect(mockDb.transaction).toHaveBeenCalledTimes(1); expect(insertedValues(schema.videos)).toMatchObject({ orgId: "org-1", @@ -585,7 +630,7 @@ describe("GET /create", () => { const response = await app.request("https://cap.test/create?orgId=org-2"); expect(response.status).toBe(200); - expect(mockDb.transaction).not.toHaveBeenCalled(); + expect(mockDb.transaction).toHaveBeenCalledTimes(1); expect(insertedValues(schema.videos)).toMatchObject({ orgId: "org-2", @@ -620,6 +665,84 @@ describe("GET /create", () => { expect(await response.json()).toMatchObject({ id: "0123456789abcde" }); }); + it("commits a progress-capable recording and its upload row in one transaction", async () => { + mockGetCurrentUser.mockResolvedValue({ + id: "fixture-user", + email: "fixture@example.com", + defaultOrgId: "fixture-org", + activeOrganizationId: "fixture-org", + }); + mockDb.where + .mockResolvedValueOnce([ + { + id: "fixture-org", + name: "Fixture organization", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }, + ]) + .mockResolvedValueOnce([]); + const fixture = recordingTransactionFixture(false); + + const response = await app.request( + "https://cap.test/create?recordingMode=desktopMP4", + { headers: { "X-Cap-Desktop-Version": "0.3.68" } }, + ); + + expect(response.status).toBe(200); + expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(fixture.transactionInserts).toEqual([ + schema.videos, + schema.videoUploads, + ]); + expect(fixture.directInserts).toEqual([]); + const body = await response.json(); + expect(fixture.committed.videos.get(body.id)).toMatchObject({ + id: body.id, + ownerId: "fixture-user", + orgId: "fixture-org", + }); + expect(fixture.committed.uploads.get(body.id)).toMatchObject({ + videoId: body.id, + mode: "singlepart", + }); + }); + + it("rolls back a recording and skips transcription when its upload insert fails", async () => { + mockGetCurrentUser.mockResolvedValue({ + id: "fixture-user", + email: "fixture@example.com", + defaultOrgId: "fixture-org", + activeOrganizationId: "fixture-org", + }); + mockDb.where + .mockResolvedValueOnce([ + { + id: "fixture-org", + name: "Fixture organization", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }, + ]) + .mockResolvedValueOnce([]); + const fixture = recordingTransactionFixture(true); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + const response = await app.request( + "https://cap.test/create?recordingMode=desktopSegments", + { headers: { "X-Cap-Desktop-Version": "0.3.68" } }, + ); + + expect(response.status).toBe(500); + expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(fixture.transactionInserts).toEqual([ + schema.videos, + schema.videoUploads, + ]); + expect(fixture.directInserts).toEqual([]); + expect(fixture.committed.videos.size).toBe(0); + expect(fixture.committed.uploads.size).toBe(0); + expect(maybeStartLiveTranscription).not.toHaveBeenCalled(); + }); + it("rejects an invalid client-selected video ID", async () => { mockGetCurrentUser.mockResolvedValue({ id: "user-1", diff --git a/apps/web/__tests__/unit/playback-source.test.ts b/apps/web/__tests__/unit/playback-source.test.ts index a3c9ce976e0..60e249628fe 100644 --- a/apps/web/__tests__/unit/playback-source.test.ts +++ b/apps/web/__tests__/unit/playback-source.test.ts @@ -118,12 +118,37 @@ describe("resolvePlaybackSource", () => { fetchImpl, now: () => 123, }), - ).toEqual({ url: initialUrl, type: "mp4", supportsCrossOrigin: true }); + ).toEqual({ + url: initialUrl, + type: "mp4", + supportsCrossOrigin: true, + fromInitialUrl: true, + }); expect(fetchImpl).toHaveBeenCalledExactlyOnceWith(initialUrl, { headers: { range: "bytes=0-0" }, }); }); + it("retains the initial URL origin after a signed media redirect", async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce( + createResponse("https://media.example.com/redirected.mp4", { + status: 206, + redirected: true, + }), + ); + expect( + await resolvePlaybackSource({ + videoSrc: "/api/playlist?videoType=mp4", + initialUrl: "https://media.example.com/initial.mp4", + fetchImpl, + }), + ).toMatchObject({ + url: "https://media.example.com/redirected.mp4", + fromInitialUrl: true, + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + it.each([401, 403, 404, 500])( "refreshes a failed initial URL through the authorized playlist route (HTTP %s)", async (status) => { diff --git a/apps/web/__tests__/unit/share-player-expired-url.test.ts b/apps/web/__tests__/unit/share-player-expired-url.test.ts new file mode 100644 index 00000000000..8c5f6518238 --- /dev/null +++ b/apps/web/__tests__/unit/share-player-expired-url.test.ts @@ -0,0 +1,408 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, type ComponentProps, createElement, createRef } from "react"; +import { createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { CapVideoPlayer } from "@/app/s/[videoId]/_components/CapVideoPlayer"; + +vi.mock("@cap/ui", () => ({ LogoSpinner: () => null })); +vi.mock("@cap/utils", () => ({ + calculateStrokeDashoffset: () => 0, + getProgressCircleConfig: () => ({ circumference: 100 }), +})); +vi.mock("@fortawesome/react-fontawesome", () => ({ + FontAwesomeIcon: () => null, +})); +vi.mock("lucide-react", () => ({ + AlertTriangleIcon: () => null, + InfoIcon: () => null, +})); +vi.mock("motion/react", async () => { + const { createElement } = await import("react"); + return { + AnimatePresence: ({ children }: { children?: React.ReactNode }) => children, + motion: { + div: ({ children }: { children?: React.ReactNode }) => + createElement("div", null, children), + }, + }; +}); +vi.mock("next/dynamic", () => ({ default: () => () => null })); +vi.mock("@/actions/video/retry-processing", () => ({ + retryVideoProcessing: vi.fn(), +})); +vi.mock("@/app/s/[videoId]/_components/CommentStamp", () => ({ + default: () => null, +})); +vi.mock("@/app/s/[videoId]/_components/VideoPreviewGif", () => ({ + VideoPreviewGif: () => null, +})); +vi.mock("@/app/s/[videoId]/_components/caption-tracks", () => ({ + bindCaptionTrackCueText: () => () => undefined, +})); +vi.mock("@/app/s/[videoId]/_components/mp4-level-patch", () => ({ + AVC_LEVEL_IOS_HARDWARE_CEILING: 42, + createLevelPatchedMp4ObjectUrl: vi.fn(), + isIosSafari: () => false, + probeAvcLevelFromUrl: vi.fn(), +})); +vi.mock("@/app/s/[videoId]/_components/video-frame-thumbnail", () => ({ + captureVideoFrameDataUrl: () => undefined, +})); +vi.mock("@/app/s/[videoId]/_components/video/tooltip", async () => { + const { createElement } = await import("react"); + const WithChildren = ({ children }: { children?: React.ReactNode }) => + createElement("div", null, children); + return { + Tooltip: WithChildren, + TooltipContent: WithChildren, + TooltipTrigger: WithChildren, + }; +}); +vi.mock("@/app/s/[videoId]/_components/video/media-player", async () => { + const { + createContext, + createElement, + forwardRef, + useContext, + useEffect, + useState, + } = await import("react"); + const MediaErrorContext = createContext({ + hasError: false, + setHasError: (_hasError: boolean): void => undefined, + }); + const WithChildren = ({ children }: { children?: React.ReactNode }) => + createElement("div", null, children); + const Empty = () => null; + const MediaPlayer = ({ children }: { children?: React.ReactNode }) => { + const [hasError, setHasError] = useState(false); + return createElement( + MediaErrorContext.Provider, + { value: { hasError, setHasError } }, + createElement("div", null, children), + ); + }; + const Video = forwardRef>( + (props, ref) => { + const { setHasError } = useContext(MediaErrorContext); + useEffect(() => { + setHasError(false); + const video = + typeof ref === "object" && ref !== null ? ref.current : null; + if (!video) return; + const handleError = () => setHasError(true); + video.addEventListener("error", handleError); + return () => video.removeEventListener("error", handleError); + }, [props.src, ref, setHasError]); + return createElement("video", { ...props, ref }, props.children); + }, + ); + const MediaPlayerError = () => { + const { hasError } = useContext(MediaErrorContext); + return hasError + ? createElement("div", { + role: "alert", + "data-testid": "media-player-error", + }) + : null; + }; + return { + MediaPlayer, + MediaPlayerCaptions: Empty, + MediaPlayerControls: WithChildren, + MediaPlayerControlsOverlay: Empty, + MediaPlayerError, + MediaPlayerFullscreen: Empty, + MediaPlayerLoading: Empty, + MediaPlayerPiP: Empty, + MediaPlayerPlay: Empty, + MediaPlayerPlaybackSpeedDial: Empty, + MediaPlayerSeek: Empty, + MediaPlayerSeekBackward: Empty, + MediaPlayerSeekForward: Empty, + MediaPlayerSettings: Empty, + MediaPlayerTime: Empty, + MediaPlayerVideo: Video, + MediaPlayerVolume: Empty, + MediaPlayerVolumeIndicator: Empty, + }; +}); + +const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +function response(url: string, status: number, redirected = false): Response { + const result = new Response(null, { status }); + Object.defineProperties(result, { + url: { value: url }, + redirected: { value: redirected }, + }); + return result; +} + +function waitForQueryNotification() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +function deferred() { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function settleFetch( + fetchImpl: ReturnType>, + count: number, +) { + await act(async () => { + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(count)); + await waitForQueryNotification(); + }); +} + +function createPlayer(initialUrl: string) { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const videoRef = createRef(); + const props: ComponentProps = { + videoSrc: "/api/playlist?videoType=mp4", + initialPlaybackUrl: Promise.resolve(initialUrl), + videoId: "fixture-video" as ComponentProps< + typeof CapVideoPlayer + >["videoId"], + chaptersSrc: "", + captionsSrc: "", + videoRef, + hasActiveUpload: false, + disablePreviewGif: true, + }; + return { client, container, root, videoRef, props }; +} + +describe("share MP4 playback after its initial signature expires", () => { + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + document.body.replaceChildren(); + }); + + afterAll(() => { + delete actEnvironment.IS_REACT_ACT_ENVIRONMENT; + }); + + it.each([false, true])( + "refetches a fresh authorized URL once when the initial probe redirected=%s", + async (initialRedirected) => { + const initialUrl = + "https://media.example.com/initial.mp4?signature=fixture"; + const initialPlayableUrl = initialRedirected + ? "https://media.example.com/redirected.mp4?signature=fixture" + : initialUrl; + const freshUrl = "https://media.example.com/fresh.mp4?signature=fixture"; + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + response(initialPlayableUrl, 206, initialRedirected), + ) + .mockResolvedValueOnce(response(freshUrl, 206, true)); + vi.stubGlobal("fetch", fetchImpl); + const player = createPlayer(initialUrl); + + try { + await act(async () => { + player.root.render( + createElement( + QueryClientProvider, + { client: player.client }, + createElement(CapVideoPlayer, player.props), + ), + ); + }); + await settleFetch(fetchImpl, 1); + await vi.waitFor(() => { + expect(player.videoRef.current?.getAttribute("src")).toBe( + initialPlayableUrl, + ); + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + await act(async () => { + player.videoRef.current?.dispatchEvent(new Event("error")); + }); + await settleFetch(fetchImpl, 2); + await vi.waitFor(() => { + expect(player.videoRef.current?.getAttribute("src")).toBe(freshUrl); + }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(fetchImpl.mock.calls[1]?.[0]).toMatch( + /^\/api\/playlist\?videoType=mp4&_t=\d+$/, + ); + + await act(async () => { + player.videoRef.current?.dispatchEvent(new Event("error")); + }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + } finally { + await act(async () => player.root.unmount()); + player.client.clear(); + } + }, + ); + + it("restores playback after refreshing an expired URL and shows a later media error", async () => { + const initialUrl = + "https://media.example.com/initial.mp4?signature=fixture"; + const freshUrl = "https://media.example.com/fresh.mp4?signature=fixture"; + const refreshResponse = deferred(); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(response(initialUrl, 206)) + .mockReturnValueOnce(refreshResponse.promise); + vi.stubGlobal("fetch", fetchImpl); + const player = createPlayer(initialUrl); + + try { + await act(async () => { + player.root.render( + createElement( + QueryClientProvider, + { client: player.client }, + createElement(CapVideoPlayer, player.props), + ), + ); + }); + await settleFetch(fetchImpl, 1); + await vi.waitFor(() => { + expect(player.videoRef.current?.getAttribute("src")).toBe(initialUrl); + }); + expect(player.videoRef.current).not.toBeNull(); + + const video = player.videoRef.current as HTMLVideoElement; + let currentTime = 2100; + let isPaused = false; + const play = vi.fn(async () => { + isPaused = false; + }); + Object.defineProperties(video, { + currentTime: { + configurable: true, + get: () => currentTime, + set: (time: number) => { + currentTime = time; + }, + }, + paused: { configurable: true, get: () => isPaused }, + ended: { configurable: true, get: () => false }, + play: { configurable: true, value: play }, + }); + + await act(async () => { + video.dispatchEvent(new Event("error")); + }); + await settleFetch(fetchImpl, 2); + expect( + player.container.querySelector('[data-testid="media-player-error"]'), + ).toBeNull(); + expect(fetchImpl.mock.calls[1]?.[0]).toMatch( + /^\/api\/playlist\?videoType=mp4&_t=\d+$/, + ); + + await act(async () => { + refreshResponse.resolve(response(freshUrl, 206, true)); + await refreshResponse.promise; + await waitForQueryNotification(); + }); + await vi.waitFor(() => { + expect(video.getAttribute("src")).toBe(freshUrl); + }); + + await act(async () => { + currentTime = 0; + isPaused = true; + video.dispatchEvent(new Event("loadedmetadata")); + }); + expect(video.currentTime).toBe(2100); + + await act(async () => { + video.dispatchEvent(new Event("seeked")); + }); + expect(play).toHaveBeenCalledOnce(); + expect(isPaused).toBe(false); + expect( + player.container.querySelector('[data-testid="media-player-error"]'), + ).toBeNull(); + + await act(async () => { + video.dispatchEvent(new Event("error")); + }); + expect( + player.container.querySelector('[data-testid="media-player-error"]'), + ).not.toBeNull(); + expect(fetchImpl).toHaveBeenCalledTimes(2); + } finally { + await act(async () => player.root.unmount()); + player.client.clear(); + } + }); + + it("shows a terminal error when the refreshed source is unavailable", async () => { + const initialUrl = + "https://media.example.com/initial.mp4?signature=fixture"; + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(response(initialUrl, 206)) + .mockResolvedValueOnce(response("/api/playlist?videoType=mp4", 404)); + vi.stubGlobal("fetch", fetchImpl); + const player = createPlayer(initialUrl); + + try { + await act(async () => { + player.root.render( + createElement( + QueryClientProvider, + { client: player.client }, + createElement(CapVideoPlayer, player.props), + ), + ); + }); + await settleFetch(fetchImpl, 1); + await vi.waitFor(() => { + expect(player.videoRef.current?.getAttribute("src")).toBe(initialUrl); + }); + + await act(async () => { + player.videoRef.current?.dispatchEvent(new Event("error")); + }); + await settleFetch(fetchImpl, 2); + await vi.waitFor(() => { + expect(player.container.textContent).toContain( + "Could not load a playable video source. Reload to try again.", + ); + }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + } finally { + await act(async () => player.root.unmount()); + player.client.clear(); + } + }); +}); diff --git a/apps/web/__tests__/unit/video-create-atomicity.test.ts b/apps/web/__tests__/unit/video-create-atomicity.test.ts new file mode 100644 index 00000000000..6f2c15fdf5e --- /dev/null +++ b/apps/web/__tests__/unit/video-create-atomicity.test.ts @@ -0,0 +1,285 @@ +import { Organisation, User, Video } from "@cap/web-domain"; +import { Effect, Option } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + type CreateVideoInput, + VideosRepo, +} from "../../../../packages/web-backend/src/Videos/VideosRepo"; + +type Row = Record; +type Store = { + videos: Map; + uploads: Map; +}; + +const mocks = vi.hoisted(() => ({ + store: { + videos: new Map(), + uploads: new Map(), + } as Store, + rejectUpload: false, + transactionCalls: 0, + currentUser: vi.fn(), + organizationAccess: vi.fn(), + createUploadTargetForUser: vi.fn(), + createUploadTargetForVideo: vi.fn(), + revalidatePath: vi.fn(), +})); + +vi.mock("@cap/database", () => { + type Table = { name: "video" | "upload" }; + type Client = { + insert: (table: Table) => { values: (row: Row | Row[]) => Promise }; + select: () => { + from: (table: Table) => { where: () => Promise }; + }; + transaction: (run: (tx: Client) => Promise) => Promise; + }; + + function makeClient(store: Store): Client { + return { + insert: (table) => ({ + values: async (value) => { + if (table.name === "upload" && mocks.rejectUpload) + throw new Error("Injected upload insert failure"); + const rows = table.name === "video" ? store.videos : store.uploads; + for (const row of Array.isArray(value) ? value : [value]) { + const key = String(table.name === "video" ? row.id : row.videoId); + rows.set(key, { ...row }); + } + }, + }), + select: () => ({ + from: (table) => ({ + where: async () => + table.name === "video" ? [...store.videos.values()] : [], + }), + }), + transaction: async (run) => { + mocks.transactionCalls += 1; + const candidate = { + videos: new Map(store.videos), + uploads: new Map(store.uploads), + }; + const result = await run(makeClient(candidate)); + mocks.store = candidate; + return result; + }, + }; + } + + return { db: () => makeClient(mocks.store) }; +}); + +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: mocks.currentUser, +})); +vi.mock("@cap/database/helpers", () => ({ nanoId: () => "fixture-video" })); +vi.mock("@cap/database/schema", () => ({ + videos: { name: "video", id: "id" }, + videoUploads: { name: "upload", videoId: "videoId" }, +})); +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ CAP_VIDEOS_DEFAULT_PUBLIC: true }), +})); +vi.mock("@cap/utils", () => ({ userIsPro: () => true })); +vi.mock("@cap/web-backend", () => ({ + Storage: { + createUploadTargetForUser: mocks.createUploadTargetForUser, + createUploadTargetForVideo: mocks.createUploadTargetForVideo, + }, +})); +vi.mock("@/actions/organization/authorization", () => ({ + requireOrganizationAccess: mocks.organizationAccess, +})); +vi.mock("@/lib/server", async () => ({ + runPromise: (await import("effect")).Effect.runPromise, +})); +vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidatePath })); +vi.mock("drizzle-orm", () => ({ + eq: (_field: unknown, value: unknown) => value, +})); + +import { createVideoForServerProcessing } from "@/actions/video/create-for-processing"; +import { createVideoAndGetUploadUrl } from "@/actions/video/upload"; + +const orgId = Organisation.OrganisationId.make("fixture-org"); +const uploadTarget = { + type: "s3Post" as const, + url: "https://uploads.example.com/fixture", + fields: {}, +}; +const repoCreateData: CreateVideoInput = { + ownerId: User.UserId.make("fixture-user"), + orgId, + name: "Fixture recording", + public: true, + source: { type: "webMP4" }, + bucketId: Option.none(), + storageIntegrationId: Option.none(), + folderId: Option.none(), + metadata: Option.none(), + transcriptionStatus: Option.none(), + width: Option.none(), + height: Option.none(), + duration: Option.none(), +}; + +function createRepoVideo(initialUpload?: { + mode: "singlepart"; + total?: number; +}) { + return Effect.runPromise( + Effect.flatMap(VideosRepo, (repo) => + repo.create(repoCreateData, { + id: Video.VideoId.make("fixture-video"), + initialUpload, + }), + ).pipe(Effect.provide(VideosRepo.Default)), + ); +} + +beforeEach(() => { + mocks.store = { videos: new Map(), uploads: new Map() }; + mocks.rejectUpload = false; + mocks.transactionCalls = 0; + mocks.currentUser.mockResolvedValue({ id: "fixture-user" }); + mocks.organizationAccess.mockResolvedValue(undefined); + mocks.createUploadTargetForUser.mockReturnValue( + Effect.succeed({ + bucketId: Option.none(), + storageIntegrationId: Option.none(), + upload: uploadTarget, + }), + ); + mocks.createUploadTargetForVideo.mockReturnValue( + Effect.succeed(uploadTarget), + ); + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +function expectCommittedPair() { + expect(mocks.transactionCalls).toBe(1); + expect(mocks.store.videos.size).toBe(1); + expect(mocks.store.uploads.size).toBe(1); + const video = [...mocks.store.videos.values()][0]; + const upload = [...mocks.store.uploads.values()][0]; + expect(upload?.videoId).toBe(video?.id); + expect(mocks.revalidatePath.mock.calls.map(([path]) => path)).toEqual([ + "/dashboard/caps", + "/dashboard/folder", + "/dashboard/spaces", + ]); +} + +describe("video creation with upload progress", () => { + it("does not sign or change a recording owned by another user", async () => { + const videoId = Video.VideoId.make("fixture-video"); + const existingVideo = { id: videoId, ownerId: "other-fixture-user" }; + mocks.store.videos.set(videoId, existingVideo); + await expect( + createVideoAndGetUploadUrl({ + orgId, + videoId, + supportsUploadProgress: true, + }), + ).rejects.toThrow("Forbidden"); + expect(mocks.store.videos.get(videoId)).toEqual(existingVideo); + expect(mocks.store.uploads.size).toBe(0); + expect(mocks.transactionCalls).toBe(0); + expect(mocks.createUploadTargetForVideo).not.toHaveBeenCalled(); + expect(mocks.revalidatePath).not.toHaveBeenCalled(); + }); + + it("rolls back a recording when the progress insert fails", async () => { + mocks.rejectUpload = true; + await expect( + createVideoAndGetUploadUrl({ orgId, supportsUploadProgress: true }), + ).rejects.toThrow("Injected upload insert failure"); + expect(mocks.transactionCalls).toBe(1); + expect(mocks.store.videos.size).toBe(0); + expect(mocks.store.uploads.size).toBe(0); + expect(mocks.revalidatePath).not.toHaveBeenCalled(); + }); + + it("commits a recording and its progress row together", async () => { + const result = await createVideoAndGetUploadUrl({ + orgId, + supportsUploadProgress: true, + }); + expect(result.id).toBe("fixture-video"); + expectCommittedPair(); + }); + + it("creates only a recording when progress is disabled", async () => { + const result = await createVideoAndGetUploadUrl({ orgId }); + expect(result.id).toBe("fixture-video"); + expect(mocks.transactionCalls).toBe(1); + expect(mocks.store.videos.size).toBe(1); + expect(mocks.store.uploads.size).toBe(0); + expect(mocks.revalidatePath).toHaveBeenCalledTimes(3); + }); +}); + +describe("video creation for server processing", () => { + it("rolls back a video when the progress insert fails", async () => { + mocks.rejectUpload = true; + await expect(createVideoForServerProcessing({ orgId })).rejects.toThrow( + "Injected upload insert failure", + ); + expect(mocks.transactionCalls).toBe(1); + expect(mocks.store.videos.size).toBe(0); + expect(mocks.store.uploads.size).toBe(0); + expect(mocks.revalidatePath).not.toHaveBeenCalled(); + }); + + it("commits the video and its raw upload metadata together", async () => { + const result = await createVideoForServerProcessing({ orgId }); + expect(result.id).toBe("fixture-video"); + expectCommittedPair(); + const upload = [...mocks.store.uploads.values()][0]; + expect(upload).toMatchObject({ + mode: "singlepart", + phase: "uploading", + processingProgress: 0, + rawFileKey: "fixture-user/fixture-video/raw-upload.mp4", + }); + }); +}); + +describe("VideosRepo.create", () => { + it("commits a video and optional upload row together", async () => { + const id = await createRepoVideo({ mode: "singlepart", total: 123 }); + expect(id).toBe("fixture-video"); + expect(mocks.transactionCalls).toBe(1); + expect(mocks.store.videos.get(id)).toMatchObject({ + id, + ownerId: "fixture-user", + orgId, + }); + expect(mocks.store.uploads.get(id)).toMatchObject({ + videoId: id, + mode: "singlepart", + total: 123, + }); + expect(mocks.revalidatePath).not.toHaveBeenCalled(); + }); + + it("rolls back a video if its optional upload insert fails", async () => { + mocks.rejectUpload = true; + await expect(createRepoVideo({ mode: "singlepart" })).rejects.toThrow( + "Injected upload insert failure", + ); + expect(mocks.transactionCalls).toBe(1); + expect(mocks.store.videos.size).toBe(0); + expect(mocks.store.uploads.size).toBe(0); + }); + + it("commits a video without an upload row when progress is disabled", async () => { + const id = await createRepoVideo(); + expect(id).toBe("fixture-video"); + expect(mocks.transactionCalls).toBe(1); + expect(mocks.store.videos.get(id)?.id).toBe(id); + expect(mocks.store.uploads.size).toBe(0); + }); +}); diff --git a/apps/web/actions/video/create-for-processing.ts b/apps/web/actions/video/create-for-processing.ts index 656d8155eb9..7c8438b84b9 100644 --- a/apps/web/actions/video/create-for-processing.ts +++ b/apps/web/actions/video/create-for-processing.ts @@ -88,9 +88,8 @@ export async function createVideoForServerProcessing({ orgId, ).pipe(runPromise); - await db() - .insert(videos) - .values({ + await db().transaction(async (tx) => { + await tx.insert(videos).values({ id: videoId, name: videoTitle, ownerId: user.id, @@ -102,12 +101,13 @@ export async function createVideoForServerProcessing({ ...(folderId ? { folderId } : {}), }); - await db().insert(videoUploads).values({ - videoId, - mode: "singlepart", - phase: "uploading", - processingProgress: 0, - rawFileKey, + await tx.insert(videoUploads).values({ + videoId, + mode: "singlepart", + phase: "uploading", + processingProgress: 0, + rawFileKey, + }); }); revalidatePath("/dashboard/caps"); diff --git a/apps/web/actions/video/upload.ts b/apps/web/actions/video/upload.ts index 4dbce3b510d..0c82c11e211 100644 --- a/apps/web/actions/video/upload.ts +++ b/apps/web/actions/video/upload.ts @@ -232,12 +232,14 @@ export async function createVideoAndGetUploadUrl({ ...(folderId ? { folderId } : {}), }; - await db().insert(videos).values(videoData); + await db().transaction(async (tx) => { + await tx.insert(videos).values(videoData); - if (supportsUploadProgress) - await db().insert(videoUploads).values({ - videoId: idToUse, - }); + if (supportsUploadProgress) + await tx.insert(videoUploads).values({ + videoId: idToUse, + }); + }); revalidatePath("/dashboard/caps"); revalidatePath("/dashboard/folder"); diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index 469db105e4e..91ca4f730e9 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -303,9 +303,13 @@ app.get( : Storage.getS3WritableAccessForUser(user.id, videoOrgId) ).pipe(runPromise); - await db() - .insert(videos) - .values({ + const clientSupportsUploadProgress = isFromDesktopSemver( + c.req, + UPLOAD_PROGRESS_VERSION, + ); + + await db().transaction(async (tx) => { + await tx.insert(videos).values({ id: idToUse, name: videoName, ownerId: user.id, @@ -329,16 +333,12 @@ app.get( ...(metadata ? { metadata } : {}), }); - const clientSupportsUploadProgress = isFromDesktopSemver( - c.req, - UPLOAD_PROGRESS_VERSION, - ); - - if (clientSupportsUploadProgress && !isScreenshot) - await db().insert(videoUploads).values({ - videoId: idToUse, - mode: "singlepart", - }); + if (clientSupportsUploadProgress && !isScreenshot) + await tx.insert(videoUploads).values({ + videoId: idToUse, + mode: "singlepart", + }); + }); if (recordingMode === "desktopSegments" && !isScreenshot) { // Off the response path: this endpoint gates recording start on the diff --git a/apps/web/app/api/mobile/[...route]/route.ts b/apps/web/app/api/mobile/[...route]/route.ts index 5159960a748..ddd3914244e 100644 --- a/apps/web/app/api/mobile/[...route]/route.ts +++ b/apps/web/app/api/mobile/[...route]/route.ts @@ -2473,30 +2473,29 @@ const createUpload = Effect.fn("Mobile.createUpload")(function* ( user.id, organizationId, ); - const videoId = yield* repo.create({ - ownerId: user.id, - orgId: organizationId, - name: getUploadTitle(input.fileName), - public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, - source: { type: "webMP4" }, - bucketId: writable.bucketId, - storageIntegrationId: writable.storageIntegrationId, - folderId: Option.fromNullable(folderId), - width: Option.fromNullable(input.width), - height: Option.fromNullable(input.height), - duration: Option.fromNullable(input.durationSeconds), - metadata: Option.none(), - transcriptionStatus: Option.none(), - }); - - yield* database.use((db) => - db.insert(Db.videoUploads).values({ - videoId, - total: input.contentLength ?? 0, - mode: "singlepart", - }), + const videoId = yield* repo.create( + { + ownerId: user.id, + orgId: organizationId, + name: getUploadTitle(input.fileName), + public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + source: { type: "webMP4" }, + bucketId: writable.bucketId, + storageIntegrationId: writable.storageIntegrationId, + folderId: Option.fromNullable(folderId), + width: Option.fromNullable(input.width), + height: Option.fromNullable(input.height), + duration: Option.fromNullable(input.durationSeconds), + metadata: Option.none(), + transcriptionStatus: Option.none(), + }, + { + initialUpload: { + total: input.contentLength ?? 0, + mode: "singlepart", + }, + }, ); - const rawFileKey = `${user.id}/${videoId}/raw-upload.${getFileExtension(input)}`; const upload = yield* writable.access.createUploadTarget(rawFileKey, { contentType: input.contentType, @@ -2568,28 +2567,28 @@ const createRecording = Effect.fn("Mobile.createRecording")(function* ( user.id, organizationId, ); - const videoId = yield* repo.create({ - ownerId: user.id, - orgId: organizationId, - name: getUploadTitle(input.fileName), - public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, - source: { type: "desktopSegments" }, - bucketId: writable.bucketId, - storageIntegrationId: writable.storageIntegrationId, - folderId: Option.fromNullable(folderId), - width: Option.some(input.width), - height: Option.some(input.height), - duration: Option.none(), - metadata: Option.some({ source: "mobileCamera", fps: input.fps }), - transcriptionStatus: Option.none(), - }); - - yield* database.use((db) => - db.insert(Db.videoUploads).values({ - videoId, - total: 0, - mode: "singlepart", - }), + const videoId = yield* repo.create( + { + ownerId: user.id, + orgId: organizationId, + name: getUploadTitle(input.fileName), + public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + source: { type: "desktopSegments" }, + bucketId: writable.bucketId, + storageIntegrationId: writable.storageIntegrationId, + folderId: Option.fromNullable(folderId), + width: Option.some(input.width), + height: Option.some(input.height), + duration: Option.none(), + metadata: Option.some({ source: "mobileCamera", fps: input.fps }), + transcriptionStatus: Option.none(), + }, + { + initialUpload: { + total: 0, + mode: "singlepart", + }, + }, ); return { diff --git a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx index c7642519bfe..8965e851d9f 100644 --- a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx @@ -186,6 +186,10 @@ export function CapVideoPlayer({ const [hasPlayedOnce, setHasPlayedOnce] = useState(false); const [isMobile, setIsMobile] = useState(false); const [hasError, setHasError] = useState(false); + const [ + isRefreshingInitialPlaybackSource, + setIsRefreshingInitialPlaybackSource, + ] = useState(false); const [isRetryingProcessing, setIsRetryingProcessing] = useState(false); const [playerDuration, setPlayerDuration] = useState(fallbackDuration ?? 0); const [preferredSource, setPreferredSource] = useState<"mp4" | "raw">("mp4"); @@ -197,6 +201,13 @@ export function CapVideoPlayer({ const initialPlaybackUrlUsed = useRef | undefined>( undefined, ); + const hasRetriedInitialPlaybackSource = useRef(false); + const initialPlaybackSourceRetryPending = useRef(false); + const playbackToRestore = useRef<{ + sourceUrl: string; + time: number; + wasPlaying: boolean; + } | null>(null); useEffect(() => { const checkMobile = () => { @@ -275,6 +286,10 @@ export function CapVideoPlayer({ setShowPlayButton(false); setPreferredSource("mp4"); setHasTriedRawFallback(false); + hasRetriedInitialPlaybackSource.current = false; + initialPlaybackSourceRetryPending.current = false; + playbackToRestore.current = null; + setIsRefreshingInitialPlaybackSource(false); }, [videoSrc, rawFallbackSrc]); useEffect(() => { @@ -422,6 +437,18 @@ export function CapVideoPlayer({ if (!video || resolvedSrc.isPending) return; const handleLoadedData = () => { + if (isRefreshingInitialPlaybackSource) { + const restore = playbackToRestore.current; + if ( + restore && + resolvedSrc.data?.url !== restore.sourceUrl && + !video.seeking && + Math.abs(video.currentTime - restore.time) < 0.25 + ) { + finishPlaybackSourceRestore(); + } + return; + } setVideoLoaded(true); setHasError(false); if (!hasPlayedOnce) { @@ -430,6 +457,10 @@ export function CapVideoPlayer({ }; const handleCanPlay = () => { + if (isRefreshingInitialPlaybackSource) { + handleLoadedData(); + return; + } setVideoLoaded(true); setHasError(false); if (!hasPlayedOnce) { @@ -442,6 +473,45 @@ export function CapVideoPlayer({ }; const handleError = () => { + if (initialPlaybackSourceRetryPending.current) return; + if ( + !rawFallbackSrc && + resolvedSrc.data?.fromInitialUrl && + !hasRetriedInitialPlaybackSource.current + ) { + hasRetriedInitialPlaybackSource.current = true; + initialPlaybackSourceRetryPending.current = true; + playbackToRestore.current = { + sourceUrl: resolvedSrc.data.url, + time: Number.isFinite(video.currentTime) ? video.currentTime : 0, + wasPlaying: !video.paused && !video.ended, + }; + setIsRefreshingInitialPlaybackSource(true); + setVideoLoaded(false); + setHasError(false); + setShowPlayButton(false); + void resolvedSrc + .refetch() + .then((result) => { + if ( + !result.data || + result.data.url === playbackToRestore.current?.sourceUrl + ) { + playbackToRestore.current = null; + setIsRefreshingInitialPlaybackSource(false); + setHasError(true); + } + }) + .catch(() => { + playbackToRestore.current = null; + setIsRefreshingInitialPlaybackSource(false); + setHasError(true); + }) + .finally(() => { + initialPlaybackSourceRetryPending.current = false; + }); + return; + } if ( shouldFallbackToRawPlaybackSource( resolvedSrc.data?.type, @@ -457,22 +527,68 @@ export function CapVideoPlayer({ return; } + playbackToRestore.current = null; + setIsRefreshingInitialPlaybackSource(false); setHasError(true); }; const cleanupCaptionTracks = bindCaptionTrackCueText(video, setCurrentCue); + const finishPlaybackSourceRestore = () => { + const restore = playbackToRestore.current; + if (!restore) return; + playbackToRestore.current = null; + setIsRefreshingInitialPlaybackSource(false); + setVideoLoaded(true); + setHasError(false); + if (restore.wasPlaying) void video.play().catch(() => undefined); + }; const handleLoadedMetadataWithTracks = () => { + const restore = playbackToRestore.current; + if ( + isRefreshingInitialPlaybackSource && + restore && + resolvedSrc.data?.url !== restore.sourceUrl + ) { + const duration = video.duration; + const time = Number.isFinite(duration) + ? Math.min(restore.time, Math.max(0, duration - 0.1)) + : restore.time; + restore.time = time; + if (time > 0) { + try { + video.currentTime = time; + return; + } catch { + finishPlaybackSourceRestore(); + return; + } + } + finishPlaybackSourceRestore(); + return; + } + if (isRefreshingInitialPlaybackSource) return; setVideoLoaded(true); setHasError(false); if (!hasPlayedOnce) { setShowPlayButton(true); } }; + const handleSeeked = () => { + const restore = playbackToRestore.current; + if ( + isRefreshingInitialPlaybackSource && + restore && + resolvedSrc.data?.url !== restore.sourceUrl + ) { + finishPlaybackSourceRestore(); + } + }; video.addEventListener("loadeddata", handleLoadedData); video.addEventListener("canplay", handleCanPlay); video.addEventListener("loadedmetadata", handleLoadedMetadataWithTracks); + video.addEventListener("seeked", handleSeeked); video.addEventListener("play", handlePlay); video.addEventListener("error", handleError as EventListener); @@ -485,6 +601,7 @@ export function CapVideoPlayer({ video.removeEventListener("canplay", handleCanPlay); video.removeEventListener("play", handlePlay); video.removeEventListener("error", handleError as EventListener); + video.removeEventListener("seeked", handleSeeked); video.removeEventListener( "loadedmetadata", handleLoadedMetadataWithTracks, @@ -494,8 +611,12 @@ export function CapVideoPlayer({ }, [ hasPlayedOnce, hasTriedRawFallback, + isRefreshingInitialPlaybackSource, rawFallbackSrc, + resolvedSrc.data?.fromInitialUrl, resolvedSrc.data?.type, + resolvedSrc.data?.url, + resolvedSrc.refetch, resolvedSrc.isPending, videoRef.current, ]); @@ -734,7 +855,7 @@ export function CapVideoPlayer({ src={iosLevelPatchedUrl ?? resolvedSrc.data.url} ref={videoRef} onLoadedData={() => { - setVideoLoaded(true); + if (!isRefreshingInitialPlaybackSource) setVideoLoaded(true); }} onPlay={() => { setShowPlayButton(false); @@ -870,6 +991,7 @@ export function CapVideoPlayer({ )} {!isUploading && + !isRefreshingInitialPlaybackSource && !showUploadFailureOverlay && !showPlaybackResolutionError && } diff --git a/apps/web/app/s/[videoId]/_components/playback-source.ts b/apps/web/app/s/[videoId]/_components/playback-source.ts index 817b93ad127..d2c2b4b4993 100644 --- a/apps/web/app/s/[videoId]/_components/playback-source.ts +++ b/apps/web/app/s/[videoId]/_components/playback-source.ts @@ -4,6 +4,7 @@ export type ResolvedPlaybackSource = { url: string; type: "mp4" | "raw"; supportsCrossOrigin: boolean; + fromInitialUrl?: true; }; type ProbeResult = { @@ -185,6 +186,7 @@ export async function resolvePlaybackSource({ url: initialResult.url, type: "mp4", supportsCrossOrigin: enableCrossOrigin, + fromInitialUrl: true, }; } if ( diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 6b4c98dc167..40f0b8af814 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -43,7 +43,7 @@ sentry.workspace = true futures = { workspace = true } tracing.workspace = true flume.workspace = true -tokio-util = "0.7.15" +tokio-util = { version = "0.7.15", features = ["rt"] } ringbuf = "0.4.8" lru = "0.12" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index f670c27a4fb..3c3052d18a2 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -472,7 +472,7 @@ impl EditorInstance { let completed_track_identity = completed_audio .as_deref() .map(crate::preparing_handoff::completed_track_identity); - let segments_task = tokio::spawn({ + let segments_task = tokio_util::task::AbortOnDropHandle::new(tokio::spawn({ let recording_meta = recording_meta.clone(); let studio_meta = (**meta).clone(); async move { @@ -485,7 +485,7 @@ impl EditorInstance { ) .await } - }); + })); // Open the session's audio output stream now (in the background) so // the first play press doesn't wait on the device — Bluetooth outputs diff --git a/crates/utils/src/export_resources.rs b/crates/utils/src/export_resources.rs index 810b1b506fc..9420c52fc66 100644 --- a/crates/utils/src/export_resources.rs +++ b/crates/utils/src/export_resources.rs @@ -13,7 +13,9 @@ const STOP_PREFIX: &str = "Export stopped to protect your computer: "; enum MemoryPressure { #[default] Unknown, + #[cfg(any(target_os = "macos", test))] Normal, + #[cfg(any(target_os = "macos", test))] Warning, Critical, } @@ -118,10 +120,14 @@ impl ExportResources { disk.description )); } - if matches!( + #[cfg(any(target_os = "macos", test))] + let memory_warning = matches!( sample.memory, MemoryPressure::Warning | MemoryPressure::Critical - ) { + ); + #[cfg(not(any(target_os = "macos", test)))] + let memory_warning = sample.memory == MemoryPressure::Critical; + if memory_warning { warnings.push("Your computer is already under memory pressure. Close other applications before exporting.".to_string()); } if warnings.is_empty() { diff --git a/packages/web-backend/src/Videos/VideosRepo.ts b/packages/web-backend/src/Videos/VideosRepo.ts index 82fb453b84d..41a8e9e236a 100644 --- a/packages/web-backend/src/Videos/VideosRepo.ts +++ b/packages/web-backend/src/Videos/VideosRepo.ts @@ -3,7 +3,6 @@ import { nanoId } from "@cap/database/helpers"; import * as Db from "@cap/database/schema"; import { type User, Video } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; -import type { MySqlInsertBase } from "drizzle-orm/mysql-core"; import { Effect, Option } from "effect"; import type { Schema } from "effect/Schema"; import { Database } from "../Database.ts"; @@ -103,47 +102,53 @@ export class VideosRepo extends Effect.Service()("VideosRepo", { }); }); - const create = (data: CreateVideoInput, options?: { id: Video.VideoId }) => + const create = ( + data: CreateVideoInput, + options?: { + id?: Video.VideoId; + initialUpload?: Omit; + }, + ) => Effect.gen(function* () { const id = options?.id ?? Video.VideoId.make(nanoId()); yield* db.use((db) => db.transaction(async (db) => { - const promises: MySqlInsertBase[] = [ - db.insert(Db.videos).values([ + await db.insert(Db.videos).values([ + { + ...data, + id, + orgId: data.orgId, + bucket: Option.getOrNull(data.bucketId ?? Option.none()), + storageIntegrationId: Option.getOrNull( + data.storageIntegrationId ?? Option.none(), + ), + metadata: Option.getOrNull(data.metadata ?? Option.none()), + transcriptionStatus: Option.getOrNull( + data.transcriptionStatus ?? Option.none(), + ), + folderId: Option.getOrNull(data.folderId ?? Option.none()), + width: Option.getOrNull(data.width ?? Option.none()), + height: Option.getOrNull(data.height ?? Option.none()), + duration: Option.getOrNull(data.duration ?? Option.none()), + }, + ]); + + if (data.importSource) + await db.insert(Db.importedVideos).values([ { - ...data, id, orgId: data.orgId, - bucket: Option.getOrNull(data.bucketId ?? Option.none()), - storageIntegrationId: Option.getOrNull( - data.storageIntegrationId ?? Option.none(), - ), - metadata: Option.getOrNull(data.metadata ?? Option.none()), - transcriptionStatus: Option.getOrNull( - data.transcriptionStatus ?? Option.none(), - ), - folderId: Option.getOrNull(data.folderId ?? Option.none()), - width: Option.getOrNull(data.width ?? Option.none()), - height: Option.getOrNull(data.height ?? Option.none()), - duration: Option.getOrNull(data.duration ?? Option.none()), + source: data.importSource.source, + sourceId: data.importSource.id, }, - ]), - ]; - - if (data.importSource) - promises.push( - db.insert(Db.importedVideos).values([ - { - id, - orgId: data.orgId, - source: data.importSource.source, - sourceId: data.importSource.id, - }, - ]), - ); + ]); - await Promise.all(promises); + if (options?.initialUpload) + await db.insert(Db.videoUploads).values({ + videoId: id, + ...options.initialUpload, + }); }), ); diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts index d308f5e7ac2..03043fe8288 100644 --- a/packages/web-backend/src/Videos/index.ts +++ b/packages/web-backend/src/Videos/index.ts @@ -623,16 +623,12 @@ export class Videos extends Effect.Service()("Videos", { metadata: Option.none(), transcriptionStatus: Option.none(), }; - const videoId = yield* repo.create(createData); - - if (input.supportsUploadProgress ?? true) - yield* db.use((db) => - db.insert(Db.videoUploads).values({ - videoId, - mode: "singlepart", - }), - ); - + const videoId = yield* repo.create(createData, { + initialUpload: + (input.supportsUploadProgress ?? true) + ? { mode: "singlepart" } + : undefined, + }); const fileKey = `${user.id}/${videoId}/result.mp4`; const upload = yield* writable.access.createUploadTarget(fileKey, { contentType: "video/mp4", @@ -647,7 +643,6 @@ export class Videos extends Effect.Service()("Videos", { "x-amz-meta-audiocodec": input.audioCodec ?? "", }, }); - const canonicalShareUrl = `${serverEnv().WEB_URL}/s/${videoId}`; const verifiedCustomDomain = yield* db