Files
OpenFrame/components/video-page/hooks/use-version-actions.ts
T
yusufipek 4ff801738c fix(uploads): count a Bunny upload from the moment it is admitted
A Bunny init asked the quota whether it could store zero bytes, which is a
question with only one answer. Nothing an upload was about to consume was
visible to the next request, so every init inside the same window read the
same total and every one of them passed, and an upload that could never
fit was only refused after it had been sent.

The client now declares the size up front. It is checked against the
account's remaining room before Bunny is asked for anything, and held as
a reservation the next init has to see. The declaration is a claim rather
than proof, so it is signed into the upload token: the same token already
binds the video id, which is what makes the reservation safe to release
on a caller's say-so, since releasing it costs them the video it belongs
to.

The declared size is then written onto the version or asset row and the
reservation is dropped in the same transaction, because Bunny reports no
size at all for a video until it has finished encoding it. On a half hour
of footage that is most of an hour during which the upload did not appear
on the uploader's own storage page and did not count against the next
upload. Per-video accounting now takes the larger of what Bunny reports
and what was declared, so the estimate stands in until the real figure
arrives and Bunny's wins once it does.

Two smaller things came out of the same reading. The asset route's
in-transaction fallback compared against the plan limit, so a caller
quoting a reservation that no longer existed was measured against 200 GiB
even on a trial worth three. And the guest branch reserves without being
able to release early, because a guest grant is bound to our video id and
the caller's network context rather than to the Bunny video, which would
let the reservation be dropped while the upload it stands for carried on.
2026-08-18 10:35:08 +03:00

346 lines
12 KiB
TypeScript

'use client';
import { useState, type Dispatch, type SetStateAction } from 'react';
import { toast } from 'sonner';
import * as tus from 'tus-js-client';
import {
parseVideoUrl,
getThumbnailUrl,
fetchVideoMetadata,
type VideoSource,
} from '@/lib/video-providers';
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
/** What a failed version upload has to undo, depending on which provider it started on. */
type PendingVersionCleanup =
| { objectKey: string; uploadToken: string; reservationId: string | null }
| { bunnyVideoId: string; uploadToken: string };
interface UseVersionActionsParams extends VersionActionsConfig {
setVideo: Dispatch<SetStateAction<VideoData | null>>;
activeVersionId: string | null;
setActiveVersionId: Dispatch<SetStateAction<string | null>>;
}
export function useVersionActions({
projectId,
videoId,
directUploadsEnabled = false,
directUploadProvider = 'bunny',
setVideo,
activeVersionId,
setActiveVersionId,
}: UseVersionActionsParams) {
const [showVersionDialog, setShowVersionDialog] = useState(false);
const [newVersionUrl, setNewVersionUrl] = useState('');
const [newVersionLabel, setNewVersionLabel] = useState('');
const [newVersionSource, setNewVersionSource] = useState<VideoSource | null>(null);
const [newVersionUrlError, setNewVersionUrlError] = useState('');
const [isCreatingVersion, setIsCreatingVersion] = useState(false);
const [newVersionMode, setNewVersionMode] = useState<'url' | 'file'>('url');
const [newVersionFile, setNewVersionFile] = useState<File | null>(null);
const [newVersionUploadProgress, setNewVersionUploadProgress] = useState(0);
const [newVersionUploadStatus, setNewVersionUploadStatus] = useState('');
const [showDeleteVersionDialog, setShowDeleteVersionDialog] = useState(false);
const [versionToDelete, setVersionToDelete] = useState<string | null>(null);
const [isDeletingVersion, setIsDeletingVersion] = useState(false);
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
const handleNewVersionUrlChange = (url: string) => {
setNewVersionUrl(url);
setNewVersionUrlError('');
if (!url.trim()) {
setNewVersionSource(null);
return;
}
const source = parseVideoUrl(url);
if (source) {
setNewVersionSource(source);
} else {
setNewVersionSource(null);
if (url.length > 10) setNewVersionUrlError('Unsupported URL');
}
};
/**
* `onPendingCleanup` is called the moment there is something to clean up, which for a
* Bunny upload is when bunny-init answers and the remote video already exists. Waiting
* for this function to return instead meant a tus `onError` threw past the assignment,
* so every failed Bunny upload left a video behind on the Bunny side: billed, and
* invisible in the app.
*/
const uploadNewVersionFile = async (
file: File,
title: string,
onPendingCleanup: (cleanup: PendingVersionCleanup) => void
) => {
if (!projectId) throw new Error('Missing project');
if (directUploadProvider === 'r2') {
setNewVersionUploadStatus('Initializing upload...');
const uploaded = await uploadVideoToR2(projectId, file, {
onProgress: (progress) => {
setNewVersionUploadProgress(progress);
setNewVersionUploadStatus(`Uploading... ${progress}%`);
},
});
return {
finalVideoUrl: uploaded.proxyUrl,
finalProviderId: 'r2',
finalProviderVideoId: uploaded.objectKey,
finalThumbnailUrl: uploaded.thumbnailUrl || '/placeholder-video-thumbnail.png',
finalDuration: uploaded.duration,
uploadToken: uploaded.uploadToken,
objectKey: uploaded.objectKey,
reservationId: uploaded.reservationId,
pendingCleanup: {
objectKey: uploaded.objectKey,
uploadToken: uploaded.uploadToken,
reservationId: uploaded.reservationId,
thumbnailObjectKey: uploaded.thumbnailObjectKey,
},
};
}
setNewVersionUploadStatus('Initializing upload...');
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, sizeBytes: file.size.toString() }),
});
if (!initRes.ok) throw new Error('Failed to initialize upload');
const {
data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
} = await initRes.json();
// The remote video exists from here on, so it is cleanable from here on.
onPendingCleanup({ bunnyVideoId, uploadToken });
await new Promise((resolve, reject) => {
setNewVersionUploadStatus('Uploading video...');
const upload = new tus.Upload(file, {
endpoint: 'https://video.bunnycdn.com/tusupload',
retryDelays: [0, 3000, 5000, 10000, 20000],
headers: {
AuthorizationSignature: signature,
AuthorizationExpire: expirationTime.toString(),
VideoId: bunnyVideoId,
LibraryId: libraryId,
},
metadata: {
filetype: file.type,
title,
},
onError: (error) => reject(new Error(`Upload failed: ${error.message}`)),
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
setNewVersionUploadProgress(Number(percentage));
setNewVersionUploadStatus(`Uploading... ${percentage}%`);
},
onSuccess: () => {
setNewVersionUploadStatus('Processing video...');
resolve(true);
},
});
upload.start();
});
return {
finalVideoUrl: `https://iframe.mediadelivery.net/embed/${libraryId}/${bunnyVideoId}`,
finalProviderId: 'bunny',
finalProviderVideoId: bunnyVideoId,
finalThumbnailUrl: bunnyCdnHostname
? `https://${bunnyCdnHostname}/${bunnyVideoId}/thumbnail.jpg`
: null,
finalDuration: null as number | null,
uploadToken,
objectKey: null as string | null,
reservationId: null as string | null,
pendingCleanup: {
bunnyVideoId,
uploadToken,
},
};
};
const handleCreateVersion = async () => {
if (!projectId) return;
setIsCreatingVersion(true);
setNewVersionUploadStatus('');
setNewVersionUploadProgress(0);
let pendingCleanup: PendingVersionCleanup | null = null;
try {
let finalVideoUrl = '';
let finalProviderId = '';
let finalProviderVideoId = '';
let finalThumbnailUrl: string | null = null;
let finalDuration: number | null = null;
let uploadToken: string | null = null;
let objectKey: string | null = null;
let reservationId: string | null = null;
if (newVersionMode === 'url') {
if (!newVersionSource) throw new Error('Invalid URL');
const meta = await fetchVideoMetadata(newVersionSource);
finalVideoUrl = newVersionSource.originalUrl;
finalProviderId = newVersionSource.providerId;
finalProviderVideoId = newVersionSource.videoId;
finalThumbnailUrl = getThumbnailUrl(newVersionSource, 'large');
finalDuration = meta?.duration || null;
} else {
if (!directUploadsEnabled) throw new Error('Direct uploads are disabled by this host');
if (!newVersionFile) throw new Error('No file selected');
let title = newVersionFile.name;
if (newVersionLabel.trim()) {
title = newVersionLabel.trim();
} else {
title = title.replace(/\.[^/.]+$/, '');
}
const uploaded = await uploadNewVersionFile(newVersionFile, title, (cleanup) => {
pendingCleanup = cleanup;
});
finalVideoUrl = uploaded.finalVideoUrl;
finalProviderId = uploaded.finalProviderId;
finalProviderVideoId = uploaded.finalProviderVideoId;
finalThumbnailUrl = uploaded.finalThumbnailUrl;
finalDuration = uploaded.finalDuration;
uploadToken = uploaded.uploadToken;
objectKey = uploaded.objectKey;
reservationId = uploaded.reservationId;
pendingCleanup = uploaded.pendingCleanup;
}
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}/versions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
videoUrl: finalVideoUrl,
providerId: finalProviderId,
providerVideoId: finalProviderVideoId,
uploadToken,
objectKey,
reservationId,
versionLabel: newVersionLabel.trim() || null,
thumbnailUrl: finalThumbnailUrl,
duration: finalDuration,
setActive: true,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.error || 'Failed to create version');
}
const versionData = await res.json();
const newVersion = versionData.data;
setVideo((prev) => {
if (!prev) return prev;
const updatedVersions = prev.versions.map((v) => ({ ...v, isActive: false }));
updatedVersions.unshift({
...newVersion,
comments: [],
});
return { ...prev, versions: updatedVersions };
});
setActiveVersionId(newVersion.id);
setShowVersionDialog(false);
setNewVersionUrl('');
setNewVersionLabel('');
setNewVersionSource(null);
setNewVersionFile(null);
setNewVersionUploadStatus('');
pendingCleanup = null;
} catch (err) {
const errorObj = err as Error;
if (pendingCleanup && projectId) {
if ('objectKey' in pendingCleanup) {
await cleanupPendingR2VideoUpload(projectId, pendingCleanup);
} else {
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
videoId: pendingCleanup.bunnyVideoId,
uploadToken: pendingCleanup.uploadToken,
}),
}).catch((cleanupError) => {
console.error('Failed to cleanup pending Bunny version upload:', cleanupError);
});
}
}
console.error('Failed to create version:', errorObj);
toast.error(errorObj.message || 'Failed to create version');
} finally {
setIsCreatingVersion(false);
setNewVersionUploadProgress(0);
}
};
const handleDeleteVersion = async () => {
if (!versionToDelete || !projectId) return;
setIsDeletingVersion(true);
try {
const res = await fetch(
`/api/projects/${projectId}/videos/${videoId}/versions/${versionToDelete}`,
{ method: 'DELETE' }
);
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.error || 'Failed to delete version');
}
setVideo((prev) => {
if (!prev) return prev;
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
if (activeVersionId === versionToDelete && remaining.length > 0) {
const nextActive = remaining.find((v) => v.isActive) ?? remaining[0];
setActiveVersionId(nextActive.id);
}
return { ...prev, versions: remaining };
});
setShowDeleteVersionDialog(false);
setVersionToDelete(null);
toast.success('Version deleted');
} catch (err) {
const errorObj = err as Error;
console.error('Failed to delete version:', errorObj);
toast.error(errorObj.message || 'Failed to delete version');
} finally {
setIsDeletingVersion(false);
}
};
return {
showVersionDialog,
setShowVersionDialog,
newVersionUrl,
newVersionLabel,
setNewVersionLabel,
newVersionSource,
newVersionUrlError,
isCreatingVersion,
newVersionMode,
setNewVersionMode,
newVersionFile,
setNewVersionFile,
newVersionUploadProgress,
newVersionUploadStatus,
handleNewVersionUrlChange,
handleCreateVersion,
showDeleteVersionDialog,
setShowDeleteVersionDialog,
setVersionToDelete,
isDeletingVersion,
handleDeleteVersion,
};
}