mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: enable S3 video uploads and update related configurations
- Added support for self-hosted S3 video uploads with new environment variables: OPENFRAME_ENABLE_S3_VIDEO_UPLOADS and OPENFRAME_MAX_VIDEO_UPLOAD_BYTES. - Updated .env.example and .env.docker.example to reflect new configuration options. - Enhanced Content Security Policy to include origins for S3-compatible storage. - Updated dependencies for AWS SDK to support new features. - Refactored upload logic to accommodate both Bunny and S3 upload providers. - Updated documentation to clarify the usage of direct uploads and S3 configurations. - Closes #11
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
import { captureVideoThumbnail } from '@/lib/client/video-thumbnail';
|
||||
|
||||
export type R2VideoInitResponse = {
|
||||
presignedPutUrl: string;
|
||||
objectKey: string;
|
||||
proxyUrl: string;
|
||||
uploadToken: string;
|
||||
reservationId: string | null;
|
||||
contentType: string;
|
||||
thumbnailPresignedPutUrl: string;
|
||||
thumbnailObjectKey: string;
|
||||
thumbnailProxyUrl: string;
|
||||
};
|
||||
|
||||
export type R2VideoUploadResult = R2VideoInitResponse & {
|
||||
duration: number | null;
|
||||
thumbnailUrl: string | null;
|
||||
};
|
||||
|
||||
type UploadProgressHandler = (progress: number) => void;
|
||||
|
||||
function uploadBytesWithProgress(
|
||||
url: string,
|
||||
body: Blob | File,
|
||||
contentType: string,
|
||||
onProgress?: UploadProgressHandler
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', url);
|
||||
xhr.setRequestHeader('Content-Type', contentType);
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (!onProgress || !event.lengthComputable) return;
|
||||
onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(
|
||||
new Error(
|
||||
'Network error during upload. If you use direct S3/R2 uploads, configure bucket CORS to allow PUT from this site origin.'
|
||||
)
|
||||
);
|
||||
};
|
||||
xhr.onabort = () => reject(new Error('Upload aborted'));
|
||||
|
||||
xhr.send(body);
|
||||
});
|
||||
}
|
||||
|
||||
async function readVideoDuration(file: File): Promise<number | null> {
|
||||
return new Promise((resolve) => {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
|
||||
const cleanup = () => {
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
const duration =
|
||||
Number.isFinite(video.duration) && video.duration > 0 ? Math.round(video.duration) : null;
|
||||
cleanup();
|
||||
resolve(duration);
|
||||
};
|
||||
|
||||
video.onerror = () => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
video.src = objectUrl;
|
||||
});
|
||||
}
|
||||
|
||||
export async function initR2VideoUpload(
|
||||
projectId: string,
|
||||
file: File
|
||||
): Promise<R2VideoInitResponse> {
|
||||
const initRes = await fetch(`/api/projects/${projectId}/videos/r2-init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
sizeBytes: file.size,
|
||||
}),
|
||||
});
|
||||
|
||||
const initPayload = (await initRes.json().catch(() => null)) as {
|
||||
data?: R2VideoInitResponse;
|
||||
error?: string;
|
||||
} | null;
|
||||
if (!initRes.ok || !initPayload?.data) {
|
||||
throw new Error(initPayload?.error || 'Failed to initialize video upload');
|
||||
}
|
||||
|
||||
return initPayload.data;
|
||||
}
|
||||
|
||||
export async function cleanupPendingR2VideoUpload(
|
||||
projectId: string,
|
||||
input: {
|
||||
objectKey: string;
|
||||
uploadToken: string;
|
||||
reservationId: string | null;
|
||||
thumbnailObjectKey?: string | null;
|
||||
},
|
||||
keepalive = false
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fetch(`/api/projects/${projectId}/videos/r2-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
objectKey: input.objectKey,
|
||||
uploadToken: input.uploadToken,
|
||||
reservationId: input.reservationId,
|
||||
thumbnailObjectKey: input.thumbnailObjectKey ?? undefined,
|
||||
}),
|
||||
keepalive,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup pending R2 video upload:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadVideoToR2(
|
||||
projectId: string,
|
||||
file: File,
|
||||
options?: { onProgress?: UploadProgressHandler }
|
||||
): Promise<R2VideoUploadResult> {
|
||||
const init = await initR2VideoUpload(projectId, file);
|
||||
|
||||
const cleanupInput = {
|
||||
objectKey: init.objectKey,
|
||||
uploadToken: init.uploadToken,
|
||||
reservationId: init.reservationId,
|
||||
thumbnailObjectKey: init.thumbnailObjectKey,
|
||||
};
|
||||
|
||||
try {
|
||||
await uploadBytesWithProgress(
|
||||
init.presignedPutUrl,
|
||||
file,
|
||||
init.contentType,
|
||||
options?.onProgress
|
||||
);
|
||||
} catch (error) {
|
||||
await cleanupPendingR2VideoUpload(projectId, cleanupInput);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [duration, thumbnailBlob] = await Promise.all([
|
||||
readVideoDuration(file),
|
||||
captureVideoThumbnail(file),
|
||||
]);
|
||||
|
||||
let thumbnailUrl: string | null = null;
|
||||
if (thumbnailBlob) {
|
||||
try {
|
||||
await uploadBytesWithProgress(init.thumbnailPresignedPutUrl, thumbnailBlob, 'image/jpeg');
|
||||
thumbnailUrl = init.thumbnailProxyUrl;
|
||||
} catch (error) {
|
||||
console.warn('Failed to upload video thumbnail:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...init, duration, thumbnailUrl };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
const THUMBNAIL_MAX_WIDTH = 640;
|
||||
const DEFAULT_SEEK_SECONDS = 1;
|
||||
|
||||
export async function captureVideoThumbnail(
|
||||
file: File,
|
||||
seekSeconds = DEFAULT_SEEK_SECONDS
|
||||
): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
|
||||
let settled = false;
|
||||
|
||||
const finish = (blob: Blob | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
resolve(blob);
|
||||
};
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
const duration = Number.isFinite(video.duration) ? video.duration : 0;
|
||||
const target =
|
||||
duration > 0 ? Math.min(Math.max(seekSeconds, 0), Math.max(0, duration - 0.1)) : 0;
|
||||
video.currentTime = target;
|
||||
};
|
||||
|
||||
video.onseeked = () => {
|
||||
try {
|
||||
const width = video.videoWidth;
|
||||
const height = video.videoHeight;
|
||||
if (width <= 0 || height <= 0) {
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = Math.min(1, THUMBNAIL_MAX_WIDTH / width);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(width * scale);
|
||||
canvas.height = Math.round(height * scale);
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
|
||||
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
canvas.toBlob((blob) => finish(blob), 'image/jpeg', 0.85);
|
||||
} catch {
|
||||
finish(null);
|
||||
}
|
||||
};
|
||||
|
||||
video.onerror = () => finish(null);
|
||||
video.src = objectUrl;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user