From a662dc7dc71312f11b5f1016ee3ea160b7202af3 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:29:15 +0000 Subject: [PATCH 1/2] fix(web): forget web edits when a desktop reupload replaces the recording --- ...sktop-recording-output-replacement.test.ts | 13 ++++++ .../__tests__/unit/multipart-presign.test.ts | 41 ++++++++++++++++++- .../app/api/upload/[...route]/multipart.ts | 10 ++++- apps/web/lib/desktop-reupload.ts | 2 + 4 files changed, 62 insertions(+), 4 deletions(-) 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..67c51a292dc 100644 --- a/apps/web/__tests__/unit/desktop-recording-output-replacement.test.ts +++ b/apps/web/__tests__/unit/desktop-recording-output-replacement.test.ts @@ -306,6 +306,7 @@ describe("desktop reupload publication", () => { "lock-video", "lock-upload", "retire-job", + "delete-edits", ]); expect(mocks.retire).toHaveBeenCalledWith(tx, { videoId: "video", @@ -315,6 +316,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 +404,7 @@ describe("desktop reupload publication", () => { "lock-video", "lock-upload", "retire-job", + "delete-edits", ]); }); @@ -491,6 +503,7 @@ describe("desktop reupload publication", () => { "lock-video", "lock-upload", "retire-job", + "delete-edits", "publication-failed", "rollback", ]); diff --git a/apps/web/__tests__/unit/multipart-presign.test.ts b/apps/web/__tests__/unit/multipart-presign.test.ts index 0e4b0625680..74d5634cebd 100644 --- a/apps/web/__tests__/unit/multipart-presign.test.ts +++ b/apps/web/__tests__/unit/multipart-presign.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ prepareReplacement: vi.fn(), invalidate: vi.fn(), head: vi.fn(), + deleteObject: vi.fn(), queueTranscription: vi.fn(), shouldQueueTranscription: vi.fn(), complete: vi.fn(), @@ -203,10 +204,10 @@ describe("desktop reupload completion", () => { }), }), }), - transaction: async (run: (tx: unknown) => Promise) => { + transaction: async (run: (tx: unknown) => Promise) => { const pending: Record[] = []; let removeUpload = false; - await run({ + const result = await run({ update: () => ({ set: (values: Record) => ({ where: async () => { @@ -230,6 +231,7 @@ describe("desktop reupload completion", () => { video.source = values.source as typeof video.source; } if (removeUpload) uploadKey = null; + return result; }, insert: () => ({ values: (values: { rawFileKey?: string | null }) => ({ @@ -273,6 +275,12 @@ describe("desktop reupload completion", () => { ? Effect.succeed(value) : Effect.fail(new Error("Object missing")); }); + mocks.deleteObject.mockImplementation((key: string) => + Effect.sync(() => { + events.push("delete-edit-source"); + objects.delete(key); + }), + ); mocks.storage.mockImplementation(() => Effect.succeed([ { @@ -285,6 +293,7 @@ describe("desktop reupload completion", () => { getPresignedUploadPartUrl: mocks.sign, }, headObject: (key: string) => Effect.suspend(() => mocks.head(key)), + deleteObject: mocks.deleteObject, copyObject: () => Effect.succeed({ CopyObjectResult: { ETag: "new-video" } }), }, @@ -392,6 +401,7 @@ describe("desktop reupload completion", () => { "retire", "publish", "delete-upload", + "delete-edit-source", "invalidate", ]); expect(mocks.complete).toHaveBeenCalledWith( @@ -405,6 +415,21 @@ describe("desktop reupload completion", () => { ContentLength: 90, }); }); + it("drops the preserved edit original of the replaced recording", async () => { + const editSourceKey = "owner/video/source/original.mp4"; + objects.set(editSourceKey, { ETag: "pre-replacement", ContentLength: 80 }); + expect((await complete()).status).toBe(200); + expect(mocks.deleteObject).toHaveBeenCalledWith(editSourceKey); + expect(objects.has(editSourceKey)).toBe(false); + expect(objects.get(oldKey)?.ETag).toBe("original"); + }); + it("publishes the replacement even when the edit original cannot be dropped", async () => { + mocks.deleteObject.mockReturnValueOnce( + Effect.fail(new Error("Storage unavailable")), + ); + expect((await complete()).status).toBe(200); + expect(video.source.outputKey).toBe(outputKey); + }); it("uses a new playback key even when CDN invalidation fails", async () => { mocks.invalidate.mockRejectedValueOnce(new Error("CloudFront unavailable")); const response = await complete(); @@ -450,6 +475,7 @@ describe("desktop reupload completion", () => { expect(video.source.outputKey).toBe(oldKey); expect(objects.get(oldKey)?.ETag).toBe("original"); expect(objects.get(outputKey)?.ETag).toBe("new-video"); + expect(mocks.deleteObject).not.toHaveBeenCalled(); rejectCommit = false; mocks.complete.mockReturnValueOnce(Effect.fail(new Error("NoSuchUpload"))); expect((await complete()).status).toBe(200); @@ -476,6 +502,17 @@ describe("desktop reupload completion", () => { expect(mocks.complete).toHaveBeenCalledTimes(1); expect(mocks.prepareReplacement).toHaveBeenCalledTimes(1); }); + it("keeps the edit original when a late duplicate finds the replacement published", async () => { + const stale = { ...video, source: { ...video.source } }; + expect((await complete()).status).toBe(200); + mocks.getOwnedById.mockImplementationOnce(() => + Effect.succeed(Option.some([stale])), + ); + mocks.deleteObject.mockClear(); + expect((await complete()).status).toBe(200); + expect(mocks.prepareReplacement).toHaveBeenCalledTimes(2); + expect(mocks.deleteObject).not.toHaveBeenCalled(); + }); it("rejects competing replacements after another operation publishes", async () => { const competing = createDesktopReuploadToken(video, { uploadId: "competing", diff --git a/apps/web/app/api/upload/[...route]/multipart.ts b/apps/web/app/api/upload/[...route]/multipart.ts index 495d8efba14..b2f091705c1 100644 --- a/apps/web/app/api/upload/[...route]/multipart.ts +++ b/apps/web/app/api/upload/[...route]/multipart.ts @@ -36,6 +36,7 @@ import { matchesCompletedRecordingParts, } from "@/lib/recording-multipart-integrity"; import { runPromise } from "@/lib/server"; +import { getEditSourceKey } from "@/lib/video-edit-processing"; import { startVideoProcessingWorkflow } from "@/lib/video-processing"; import { stringOrNumberOptional } from "@/utils/zod"; import { @@ -623,14 +624,14 @@ app.post( ), ); const head = yield* verify; - yield* db.use((db) => + const published = yield* db.use((db) => db.transaction(async (tx) => { const publication = await prepareDesktopReupload( tx, video, replacement, ); - if (!publication) return; + if (!publication) return false; await tx .update(Db.videos) .set({ @@ -657,8 +658,13 @@ app.post( eq(Db.videoUploads.rawFileKey, outputKey), ), ); + return true; }), ); + if (published) + yield* bucket + .deleteObject(getEditSourceKey(video.ownerId, video.id)) + .pipe(Effect.catchAll(Effect.logWarning)); yield* Effect.tryPromise(() => invalidateGoogleDriveStorageQuotaCache( Option.getOrNull(video.storageIntegrationId), 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; From 7827036b1d43c511c87cb979eff4baae802ad933 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:21:36 +0000 Subject: [PATCH 2/2] fix(web): rebuild the edit original and ignore stale words after a reupload --- ...sktop-recording-output-replacement.test.ts | 61 ++++++++++++++++++- .../__tests__/unit/multipart-presign.test.ts | 41 +------------ .../save-video-edits-original-source.test.ts | 37 ++++++++++- .../__tests__/unit/video-edit-actions.test.ts | 1 + apps/web/actions/videos/save-edits.ts | 24 ++++---- .../app/api/upload/[...route]/multipart.ts | 10 +-- apps/web/workflows/edit-video.ts | 4 ++ 7 files changed, 114 insertions(+), 64 deletions(-) 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 67c51a292dc..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"); }); @@ -650,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/multipart-presign.test.ts b/apps/web/__tests__/unit/multipart-presign.test.ts index 74d5634cebd..0e4b0625680 100644 --- a/apps/web/__tests__/unit/multipart-presign.test.ts +++ b/apps/web/__tests__/unit/multipart-presign.test.ts @@ -12,7 +12,6 @@ const mocks = vi.hoisted(() => ({ prepareReplacement: vi.fn(), invalidate: vi.fn(), head: vi.fn(), - deleteObject: vi.fn(), queueTranscription: vi.fn(), shouldQueueTranscription: vi.fn(), complete: vi.fn(), @@ -204,10 +203,10 @@ describe("desktop reupload completion", () => { }), }), }), - transaction: async (run: (tx: unknown) => Promise) => { + transaction: async (run: (tx: unknown) => Promise) => { const pending: Record[] = []; let removeUpload = false; - const result = await run({ + await run({ update: () => ({ set: (values: Record) => ({ where: async () => { @@ -231,7 +230,6 @@ describe("desktop reupload completion", () => { video.source = values.source as typeof video.source; } if (removeUpload) uploadKey = null; - return result; }, insert: () => ({ values: (values: { rawFileKey?: string | null }) => ({ @@ -275,12 +273,6 @@ describe("desktop reupload completion", () => { ? Effect.succeed(value) : Effect.fail(new Error("Object missing")); }); - mocks.deleteObject.mockImplementation((key: string) => - Effect.sync(() => { - events.push("delete-edit-source"); - objects.delete(key); - }), - ); mocks.storage.mockImplementation(() => Effect.succeed([ { @@ -293,7 +285,6 @@ describe("desktop reupload completion", () => { getPresignedUploadPartUrl: mocks.sign, }, headObject: (key: string) => Effect.suspend(() => mocks.head(key)), - deleteObject: mocks.deleteObject, copyObject: () => Effect.succeed({ CopyObjectResult: { ETag: "new-video" } }), }, @@ -401,7 +392,6 @@ describe("desktop reupload completion", () => { "retire", "publish", "delete-upload", - "delete-edit-source", "invalidate", ]); expect(mocks.complete).toHaveBeenCalledWith( @@ -415,21 +405,6 @@ describe("desktop reupload completion", () => { ContentLength: 90, }); }); - it("drops the preserved edit original of the replaced recording", async () => { - const editSourceKey = "owner/video/source/original.mp4"; - objects.set(editSourceKey, { ETag: "pre-replacement", ContentLength: 80 }); - expect((await complete()).status).toBe(200); - expect(mocks.deleteObject).toHaveBeenCalledWith(editSourceKey); - expect(objects.has(editSourceKey)).toBe(false); - expect(objects.get(oldKey)?.ETag).toBe("original"); - }); - it("publishes the replacement even when the edit original cannot be dropped", async () => { - mocks.deleteObject.mockReturnValueOnce( - Effect.fail(new Error("Storage unavailable")), - ); - expect((await complete()).status).toBe(200); - expect(video.source.outputKey).toBe(outputKey); - }); it("uses a new playback key even when CDN invalidation fails", async () => { mocks.invalidate.mockRejectedValueOnce(new Error("CloudFront unavailable")); const response = await complete(); @@ -475,7 +450,6 @@ describe("desktop reupload completion", () => { expect(video.source.outputKey).toBe(oldKey); expect(objects.get(oldKey)?.ETag).toBe("original"); expect(objects.get(outputKey)?.ETag).toBe("new-video"); - expect(mocks.deleteObject).not.toHaveBeenCalled(); rejectCommit = false; mocks.complete.mockReturnValueOnce(Effect.fail(new Error("NoSuchUpload"))); expect((await complete()).status).toBe(200); @@ -502,17 +476,6 @@ describe("desktop reupload completion", () => { expect(mocks.complete).toHaveBeenCalledTimes(1); expect(mocks.prepareReplacement).toHaveBeenCalledTimes(1); }); - it("keeps the edit original when a late duplicate finds the replacement published", async () => { - const stale = { ...video, source: { ...video.source } }; - expect((await complete()).status).toBe(200); - mocks.getOwnedById.mockImplementationOnce(() => - Effect.succeed(Option.some([stale])), - ); - mocks.deleteObject.mockClear(); - expect((await complete()).status).toBe(200); - expect(mocks.prepareReplacement).toHaveBeenCalledTimes(2); - expect(mocks.deleteObject).not.toHaveBeenCalled(); - }); it("rejects competing replacements after another operation publishes", async () => { const competing = createDesktopReuploadToken(video, { uploadId: "competing", 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/app/api/upload/[...route]/multipart.ts b/apps/web/app/api/upload/[...route]/multipart.ts index b2f091705c1..495d8efba14 100644 --- a/apps/web/app/api/upload/[...route]/multipart.ts +++ b/apps/web/app/api/upload/[...route]/multipart.ts @@ -36,7 +36,6 @@ import { matchesCompletedRecordingParts, } from "@/lib/recording-multipart-integrity"; import { runPromise } from "@/lib/server"; -import { getEditSourceKey } from "@/lib/video-edit-processing"; import { startVideoProcessingWorkflow } from "@/lib/video-processing"; import { stringOrNumberOptional } from "@/utils/zod"; import { @@ -624,14 +623,14 @@ app.post( ), ); const head = yield* verify; - const published = yield* db.use((db) => + yield* db.use((db) => db.transaction(async (tx) => { const publication = await prepareDesktopReupload( tx, video, replacement, ); - if (!publication) return false; + if (!publication) return; await tx .update(Db.videos) .set({ @@ -658,13 +657,8 @@ app.post( eq(Db.videoUploads.rawFileKey, outputKey), ), ); - return true; }), ); - if (published) - yield* bucket - .deleteObject(getEditSourceKey(video.ownerId, video.id)) - .pipe(Effect.catchAll(Effect.logWarning)); yield* Effect.tryPromise(() => invalidateGoogleDriveStorageQuotaCache( Option.getOrNull(video.storageIntegrationId), 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);