diff --git a/apps/web/__tests__/unit/desktop-recording-output-replacement.test.ts b/apps/web/__tests__/unit/desktop-recording-output-replacement.test.ts index 270f3297a23..3692b6e0cdd 100644 --- a/apps/web/__tests__/unit/desktop-recording-output-replacement.test.ts +++ b/apps/web/__tests__/unit/desktop-recording-output-replacement.test.ts @@ -13,6 +13,9 @@ const mocks = vi.hoisted(() => ({ fetch: vi.fn(), cloudfront: vi.fn(), auth: vi.fn(), + storedTranscript: vi.fn(), + putObject: vi.fn(), + deleteObjects: vi.fn(), })); vi.mock("@cap/database", () => ({ db: mocks.db })); vi.mock("@cap/database/auth/session", () => ({ getCurrentUser: mocks.auth })); @@ -66,7 +69,7 @@ vi.mock("@/lib/video-storage", () => ({ decodeStorageVideo: (video: unknown) => video, })); vi.mock("@/lib/edit-transcript-storage", () => ({ - decryptEditTranscriptObject: () => null, + decryptEditTranscriptObject: (value: string) => value, })); vi.mock("@/lib/generate-ai", () => ({ startAiGeneration: vi.fn() })); vi.mock("@/lib/transcribe", () => ({ transcribeVideo: vi.fn() })); @@ -102,6 +105,7 @@ let video: { audioLevelSourceKey?: string; }; metadata: Record; + transcriptionStatus?: string | null; }; let events: string[]; let updates: Record[]; @@ -239,11 +243,20 @@ beforeEach(() => { Effect.succeed( `https://storage.test/${options?.resolvePublishedOutput === false ? "canonical" : "published"}/${key}`, ), - getObject: () => Effect.succeed(Option.none()), - listObjects: () => Effect.succeed({ Contents: [] }), + getObject: () => + Effect.succeed(Option.fromNullable(mocks.storedTranscript())), + listObjects: () => + Effect.succeed({ + Contents: [{ Key: "user/video/transcription.edit.v3.json" }], + }), + putObject: mocks.putObject, + deleteObjects: mocks.deleteObjects, }, ]), ); + mocks.storedTranscript.mockReturnValue(null); + mocks.putObject.mockReturnValue(Effect.void); + mocks.deleteObjects.mockReturnValue(Effect.void); mocks.retire.mockImplementation(async () => { events.push("retire-job"); }); @@ -306,6 +319,7 @@ describe("desktop reupload publication", () => { "lock-video", "lock-upload", "retire-job", + "delete-edits", ]); expect(mocks.retire).toHaveBeenCalledWith(tx, { videoId: "video", @@ -315,6 +329,16 @@ describe("desktop reupload publication", () => { expect(mocks.access).not.toHaveBeenCalled(); }); + it("forgets web edits made to the replaced recording", async () => { + const replacement = await prepareDesktopReupload( + transaction(), + original(), + tokenFor(), + ); + expect(replacement).not.toBeNull(); + expect(events).toContain("delete-edits"); + }); + it.each(["desktopSegments", "webMP4"])( "publishes a %s replacement with its immutable MP4 key", async (type) => { @@ -393,6 +417,7 @@ describe("desktop reupload publication", () => { "lock-video", "lock-upload", "retire-job", + "delete-edits", ]); }); @@ -491,6 +516,7 @@ describe("desktop reupload publication", () => { "lock-video", "lock-upload", "retire-job", + "delete-edits", "publication-failed", "rollback", ]); @@ -637,6 +663,48 @@ describe("edited recording publication", () => { }, ); + describe.each([ + ["COMPLETE", true], + [null, false], + ["PROCESSING", false], + ])("with transcription status %s", (transcriptionStatus, remapped) => { + it(`${remapped ? "remaps" : "discards"} the stored words`, async () => { + video.transcriptionStatus = transcriptionStatus; + mocks.storedTranscript.mockReturnValue( + JSON.stringify({ + version: 3, + speechModelUsed: "test", + durationMs: 10_000, + languageCode: "en", + words: [], + }), + ); + video.metadata.editProcessing = { + ...operation, + ownerId: video.ownerId, + bucket: video.bucket, + storageIntegrationId: video.storageIntegrationId, + sourceKey, + source: JSON.stringify(video.source), + dispatch: "accepted", + }; + await expect( + saveEditResultAndComplete( + "video", + sourceKey, + editSpec, + editSpec, + metadata, + operation, + ), + ).resolves.toEqual({ transcriptRemapped: remapped }); + expect(video.transcriptionStatus).toBe( + remapped ? transcriptionStatus : null, + ); + expect(mocks.deleteObjects).toHaveBeenCalledTimes(remapped ? 0 : 1); + }); + }); + it("checks a reprocessed canonical object before clearing its previous immutable publication", async () => { await saveMetadataAndComplete("video", metadata); expect(mocks.access).toHaveBeenCalledWith(expect.any(Object), { diff --git a/apps/web/__tests__/unit/save-video-edits-original-source.test.ts b/apps/web/__tests__/unit/save-video-edits-original-source.test.ts index 4218049e8a6..8e9e06523b6 100644 --- a/apps/web/__tests__/unit/save-video-edits-original-source.test.ts +++ b/apps/web/__tests__/unit/save-video-edits-original-source.test.ts @@ -13,6 +13,7 @@ const state = vi.hoisted(() => ({ const headObjectMock = vi.fn(); const copyObjectMock = vi.fn(); +const deleteObjectMock = vi.fn(); const clearPendingEditMock = vi.fn(); const startMock = vi.fn(); @@ -64,6 +65,7 @@ vi.mock("@cap/web-backend", () => ({ bucketName: "cap-bucket", headObject: headObjectMock, copyObject: copyObjectMock, + deleteObject: deleteObjectMock, }, ]), }, @@ -142,6 +144,7 @@ describe("saveVideoEdits original source copy", () => { vi.clearAllMocks(); state.rows.clear(); copyObjectMock.mockReturnValue(Effect.succeed({})); + deleteObjectMock.mockReturnValue(Effect.void); }); it("keeps the preserved original when its HEAD request fails transiently", async () => { @@ -184,7 +187,7 @@ describe("saveVideoEdits original source copy", () => { }); it("treats a Google Drive 404 as a missing original", async () => { - setUpVideo({ edited: false }); + setUpVideo({ edited: true }); headObjectMock.mockReturnValue( Effect.fail( storageError( @@ -196,17 +199,47 @@ describe("saveVideoEdits original source copy", () => { ), ); - await expect(saveTrim(300)).resolves.toEqual({ success: true }); + await expect(saveTrim(60)).resolves.toEqual({ success: true }); expect(copyObjectMock).toHaveBeenCalledOnce(); }); + it("replaces a leftover original when the video has no edit record", async () => { + setUpVideo({ edited: false }); + headObjectMock.mockReturnValue(Effect.succeed({ ContentLength: 1 })); + + await expect(saveTrim(300)).resolves.toEqual({ success: true }); + + expect(deleteObjectMock).toHaveBeenCalledWith(sourceKey); + expect(copyObjectMock).toHaveBeenCalledWith( + "cap-bucket/user-1/video-1/result.mp4", + sourceKey, + ); + expect(deleteObjectMock.mock.invocationCallOrder[0]).toBeLessThan( + copyObjectMock.mock.invocationCallOrder[0] ?? 0, + ); + }); + + it("does not start the edit when a leftover original cannot be cleared", async () => { + setUpVideo({ edited: false }); + deleteObjectMock.mockReturnValue( + Effect.fail(storageError(s3Error("SlowDown", 503))), + ); + + await expect(saveTrim(300)).rejects.toBeDefined(); + + expect(copyObjectMock).not.toHaveBeenCalled(); + expect(startMock).not.toHaveBeenCalled(); + expect(clearPendingEditMock).toHaveBeenCalledOnce(); + }); + it("does not copy when the preserved original exists", async () => { setUpVideo({ edited: true }); headObjectMock.mockReturnValue(Effect.succeed({ ContentLength: 1 })); await expect(saveTrim(60)).resolves.toEqual({ success: true }); + expect(deleteObjectMock).not.toHaveBeenCalled(); expect(copyObjectMock).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/__tests__/unit/video-edit-actions.test.ts b/apps/web/__tests__/unit/video-edit-actions.test.ts index c1ba0f9aabe..97baeab03dc 100644 --- a/apps/web/__tests__/unit/video-edit-actions.test.ts +++ b/apps/web/__tests__/unit/video-edit-actions.test.ts @@ -140,6 +140,7 @@ beforeEach(() => { bucketName: "bucket", headObject: mocks.head, copyObject: mocks.copy, + deleteObject: () => Effect.void, getInternalSignedObjectUrl: () => Effect.succeed("https://storage.test/original.mp4"), }, diff --git a/apps/web/actions/videos/save-edits.ts b/apps/web/actions/videos/save-edits.ts index b58f687a8bc..fd94203091f 100644 --- a/apps/web/actions/videos/save-edits.ts +++ b/apps/web/actions/videos/save-edits.ts @@ -68,19 +68,19 @@ async function getVideoBucket(video: typeof videos.$inferSelect) { async function ensureOriginalSourceCopy( video: typeof videos.$inferSelect, - sourceKey = getEditSourceKey(video.ownerId, video.id), + sourceKey: string, + isEdited: boolean, ) { const bucket = await getVideoBucket(video); - const hasSource = await objectExists(bucket, sourceKey); - - if (!hasSource) { - const resultKey = getResultKey(video.ownerId, video.id); - await bucket - .copyObject(`${bucket.bucketName}/${resultKey}`, sourceKey) - .pipe(runPromise); - } - - return sourceKey; + if (isEdited && (await objectExists(bucket, sourceKey))) return; + + // Publishing an edit writes its record, so an unedited video's output is its + // original. Any copy already here is from a failed edit or a replaced upload. + if (!isEdited) await bucket.deleteObject(sourceKey).pipe(runPromise); + const resultKey = getResultKey(video.ownerId, video.id); + await bucket + .copyObject(`${bucket.bucketName}/${resultKey}`, sourceKey) + .pipe(runPromise); } async function markEditProcessing({ @@ -276,7 +276,7 @@ export async function saveVideoEdits( previousSpec, }); try { - await ensureOriginalSourceCopy(video, sourceKey); + await ensureOriginalSourceCopy(video, sourceKey, Boolean(existingEdit)); } catch (error) { await clearPendingEdit(videoId, sourceKey, operation); throw error; diff --git a/apps/web/lib/desktop-reupload.ts b/apps/web/lib/desktop-reupload.ts index 7ce70d31452..76de0e96301 100644 --- a/apps/web/lib/desktop-reupload.ts +++ b/apps/web/lib/desktop-reupload.ts @@ -4,6 +4,7 @@ import { } from "@aws-sdk/client-cloudfront"; import type { db } from "@cap/database"; import { + videoEdits, videoProcessingJobs, videos, videoUploads, @@ -72,6 +73,7 @@ export async function prepareDesktopReupload( videoId: video.id, userId: video.ownerId, }); + await tx.delete(videoEdits).where(eq(videoEdits.videoId, video.id)); const metadata = { ...(locked.metadata ?? {}) }; delete metadata.desktopRecordingUpload; delete metadata.summary; diff --git a/apps/web/workflows/edit-video.ts b/apps/web/workflows/edit-video.ts index 1f182eb5059..afe3f2ef31c 100644 --- a/apps/web/workflows/edit-video.ts +++ b/apps/web/workflows/edit-video.ts @@ -605,6 +605,10 @@ async function loadOriginalEditTranscript( video: typeof videos.$inferSelect, editSpec: VideoEditSpec, ) { + // A reupload clears the status when it publishes, and until the new pass + // finishes the stored words can still be the replaced recording's. + if (video.transcriptionStatus !== "COMPLETE") return null; + const [bucket] = await Storage.getAccessForVideo( decodeStorageVideo(video), ).pipe(runWorkflowPromise);