Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down Expand Up @@ -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() }));
Expand Down Expand Up @@ -102,6 +105,7 @@ let video: {
audioLevelSourceKey?: string;
};
metadata: Record<string, unknown>;
transcriptionStatus?: string | null;
};
let events: string[];
let updates: Record<string, unknown>[];
Expand Down Expand Up @@ -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");
});
Expand Down Expand Up @@ -306,6 +319,7 @@ describe("desktop reupload publication", () => {
"lock-video",
"lock-upload",
"retire-job",
"delete-edits",
]);
expect(mocks.retire).toHaveBeenCalledWith(tx, {
videoId: "video",
Expand All @@ -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) => {
Expand Down Expand Up @@ -393,6 +417,7 @@ describe("desktop reupload publication", () => {
"lock-video",
"lock-upload",
"retire-job",
"delete-edits",
]);
});

Expand Down Expand Up @@ -491,6 +516,7 @@ describe("desktop reupload publication", () => {
"lock-video",
"lock-upload",
"retire-job",
"delete-edits",
"publication-failed",
"rollback",
]);
Expand Down Expand Up @@ -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), {
Expand Down
37 changes: 35 additions & 2 deletions apps/web/__tests__/unit/save-video-edits-original-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -64,6 +65,7 @@ vi.mock("@cap/web-backend", () => ({
bucketName: "cap-bucket",
headObject: headObjectMock,
copyObject: copyObjectMock,
deleteObject: deleteObjectMock,
},
]),
},
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(
Expand All @@ -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();
});
});
1 change: 1 addition & 0 deletions apps/web/__tests__/unit/video-edit-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ beforeEach(() => {
bucketName: "bucket",
headObject: mocks.head,
copyObject: mocks.copy,
deleteObject: () => Effect.void,
getInternalSignedObjectUrl: () =>
Effect.succeed("https://storage.test/original.mp4"),
},
Expand Down
24 changes: 12 additions & 12 deletions apps/web/actions/videos/save-edits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
yashranaway marked this conversation as resolved.
Resolved
}

async function markEditProcessing({
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions apps/web/lib/desktop-reupload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
} from "@aws-sdk/client-cloudfront";
import type { db } from "@cap/database";
import {
videoEdits,
videoProcessingJobs,
videos,
videoUploads,
Expand Down Expand Up @@ -72,6 +73,7 @@ export async function prepareDesktopReupload(
videoId: video.id,
userId: video.ownerId,
});
await tx.delete(videoEdits).where(eq(videoEdits.videoId, video.id));
Comment thread
yashranaway marked this conversation as resolved.
Resolved
const metadata = { ...(locked.metadata ?? {}) };
delete metadata.desktopRecordingUpload;
delete metadata.summary;
Expand Down
4 changes: 4 additions & 0 deletions apps/web/workflows/edit-video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading