Fix video streaming: parse total size from S3 Content-Range header
This commit is contained in:
@@ -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 {
|
return {
|
||||||
stream: webStream,
|
stream: webStream,
|
||||||
contentType,
|
contentType,
|
||||||
contentLength: totalSize,
|
contentLength: totalSize,
|
||||||
range,
|
range: { start, end },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No range request (or no Content-Range in response) — full object
|
||||||
|
return {
|
||||||
|
stream: webStream,
|
||||||
|
contentType,
|
||||||
|
contentLength: result.ContentLength ?? 0,
|
||||||
|
range: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
|
||||||
{/* Loading state */}
|
|
||||||
{!loaded && !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 blur-sm opacity-50"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<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
|
<video
|
||||||
ref={videoRef}
|
|
||||||
src={video.videoUrl}
|
src={video.videoUrl}
|
||||||
controls
|
controls
|
||||||
playsInline
|
playsInline
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
className="w-full h-full object-contain"
|
|
||||||
poster={video.thumbnailUrl}
|
poster={video.thumbnailUrl}
|
||||||
onCanPlay={() => setLoaded(true)}
|
className="w-full aspect-video bg-black object-contain"
|
||||||
onError={() => setError(true)}
|
|
||||||
>
|
>
|
||||||
Your browser does not support video playback.
|
Your browser does not support video playback.
|
||||||
</video>
|
</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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user