Fix video streaming: parse total size from S3 Content-Range header

This commit is contained in:
CallMeVerity
2026-06-03 04:18:21 +01:00
parent 2f62e68688
commit 4fd31ba07d
3 changed files with 38 additions and 123 deletions
+28 -41
View File
@@ -7,7 +7,6 @@ import {
CompleteMultipartUploadCommand, CompleteMultipartUploadCommand,
AbortMultipartUploadCommand, AbortMultipartUploadCommand,
GetObjectCommand, GetObjectCommand,
HeadObjectCommand,
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
@@ -169,61 +168,49 @@ export async function getVideoStream(
): Promise<VideoStreamResult> { ): Promise<VideoStreamResult> {
const s3 = getRustFsClient(); const s3 = getRustFsClient();
// Get object metadata first to know total size // Build the GetObject command, optionally with a Range
const headResult = await s3.send( const command = new GetObjectCommand({
new HeadObjectCommand({ Bucket: RUSTFS_BUCKET,
Bucket: RUSTFS_BUCKET, Key: key,
Key: key, ...(rangeHeader ? { Range: rangeHeader } : {}),
}), });
);
const totalSize = headResult.ContentLength ?? 0;
const contentType = headResult.ContentType || "video/webm";
let range: { start: number; end: number } | undefined;
let command: GetObjectCommand;
if (rangeHeader) {
// Parse Range header like "bytes=0-1023" or "bytes=1024-"
const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
if (match) {
const start = match[1] ? parseInt(match[1], 10) : 0;
const end = match[2] ? parseInt(match[2], 10) : totalSize - 1;
range = { start, end: Math.min(end, totalSize - 1) };
command = new GetObjectCommand({
Bucket: RUSTFS_BUCKET,
Key: key,
Range: `bytes=${range.start}-${range.end}`,
});
} else {
command = new GetObjectCommand({
Bucket: RUSTFS_BUCKET,
Key: key,
});
}
} else {
command = new GetObjectCommand({
Bucket: RUSTFS_BUCKET,
Key: key,
});
}
const result = await s3.send(command); const result = await s3.send(command);
if (!result.Body) { if (!result.Body) {
throw new Error("Empty response body from RustFS"); throw new Error("Empty response body from RustFS");
} }
const contentType = result.ContentType || "video/webm";
// Convert SDK stream to web ReadableStream // Convert SDK stream to web ReadableStream
const sdkStream = result.Body as any; const sdkStream = result.Body as any;
const webStream: ReadableStream<Uint8Array> = sdkStream.transformToWebStream const webStream: ReadableStream<Uint8Array> = sdkStream.transformToWebStream
? sdkStream.transformToWebStream() ? sdkStream.transformToWebStream()
: sdkStream; : sdkStream;
// If we sent a range request, parse the Content-Range header from S3
// Format: "bytes start-end/total"
if (rangeHeader && result.ContentRange) {
const match = result.ContentRange.match(/bytes (\d+)-(\d+)\/(\d+)/);
if (match) {
const start = parseInt(match[1], 10);
const end = parseInt(match[2], 10);
const totalSize = parseInt(match[3], 10);
return {
stream: webStream,
contentType,
contentLength: totalSize,
range: { start, end },
};
}
}
// No range request (or no Content-Range in response) — full object
return { return {
stream: webStream, stream: webStream,
contentType, contentType,
contentLength: totalSize, contentLength: result.ContentLength ?? 0,
range, range: undefined,
}; };
} }
+10 -70
View File
@@ -1,79 +1,19 @@
import { useState, useRef } from "react";
import type { VideoDetail } from "../types/video"; import type { VideoDetail } from "../types/video";
import { formatRunTime } from "../api/client"; import { formatRunTime } from "../api/client";
export default function VideoPlayer({ video }: { video: VideoDetail }) { export default function VideoPlayer({ video }: { video: VideoDetail }) {
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
return ( return (
<div className="rounded-lg border border-white/5 overflow-hidden"> <div className="rounded-lg border border-white/5 overflow-hidden">
<div className="aspect-video bg-black relative"> <video
{/* Loading state */} src={video.videoUrl}
{!loaded && !error && ( controls
<div className="absolute inset-0 flex items-center justify-center z-10"> playsInline
{video.thumbnailUrl && ( preload="metadata"
<img poster={video.thumbnailUrl}
src={video.thumbnailUrl} className="w-full aspect-video bg-black object-contain"
alt="" >
className="absolute inset-0 w-full h-full object-cover blur-sm opacity-50" Your browser does not support video playback.
/> </video>
)}
<div className="relative z-20 flex flex-col items-center gap-2">
<div className="w-10 h-10 rounded-full border-2 border-white/20 border-t-white/80 animate-spin" />
<span className="text-xs text-white/40">
Loading video
</span>
</div>
</div>
)}
{/* Error state */}
{error && (
<div className="absolute inset-0 flex items-center justify-center z-10">
{video.thumbnailUrl && (
<img
src={video.thumbnailUrl}
alt=""
className="absolute inset-0 w-full h-full object-cover opacity-30"
/>
)}
<div className="relative z-20 flex flex-col items-center gap-2">
<svg
className="w-12 h-12 text-red-400/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="text-xs text-white/40">
Failed to load video
</span>
</div>
</div>
)}
<video
ref={videoRef}
src={video.videoUrl}
controls
playsInline
preload="metadata"
className="w-full h-full object-contain"
poster={video.thumbnailUrl}
onCanPlay={() => setLoaded(true)}
onError={() => setError(true)}
>
Your browser does not support video playback.
</video>
</div>
<div className="px-4 py-3 flex items-center justify-between border-t border-white/5 bg-white/3"> <div className="px-4 py-3 flex items-center justify-between border-t border-white/5 bg-white/3">
<a <a
-12
View File
@@ -24,15 +24,3 @@ a {
color: #1896d3; color: #1896d3;
text-decoration: none; text-decoration: none;
} }
video::-webkit-media-controls {
opacity: 1;
}
video::-webkit-media-controls-enclosure {
border-radius: 0;
}
video::-webkit-media-controls-panel {
background: rgba(0, 0, 0, 0.75);
}