feat(video): add video upload with remux/transcode playback

Add video as a media kind in photos: streaming upload with per-kind
limits, ffprobe metadata, poster frame reusing the image variant
pipeline, and a playback object (remux when web-compatible, else
h264/yuv420p/aac transcode). Video jobs run on a separate pool with a
long lease. Frontend gains a video viewer, grid badge/duration and
video upload.
This commit is contained in:
iu
2026-10-11 20:37:53 +03:00
parent d059d042f0
commit d38ede0201
52 changed files with 2717 additions and 82 deletions
@@ -50,6 +50,14 @@
const isSelected = (id: string) => selectedIds.includes(id);
const isCursor = (i: number) => cursorIndex === i;
function formatDuration(ms?: number): string {
if (!ms || ms <= 0) return '';
const total = Math.round(ms / 1000);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
const tabIndexFor = (i: number) =>
cursorIndex == null ? (i === 0 ? 0 : -1) : isCursor(i) ? 0 : -1;
@@ -117,6 +125,21 @@
360
</span>
{/if}
{#if photo.kind === 'video'}
<span
class="absolute top-1 left-1 inline-flex items-center justify-center rounded bg-black/60 p-1 text-white"
aria-label="Видео"
>
<Icon name="play_arrow" class="size-4" />
</span>
{#if formatDuration(photo.duration_ms)}
<span
class="absolute bottom-1 left-1 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-medium text-white"
>
{formatDuration(photo.duration_ms)}
</span>
{/if}
{/if}
<span
class="bg-bg/70 text-text absolute right-1 bottom-1 flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs"
>
+11 -6
View File
@@ -14,7 +14,12 @@
let progress = $state(0);
let error = $state('');
const MAX_SIZE = 100 * 1024 * 1024;
const IMAGE_MAX = 100 * 1024 * 1024;
const VIDEO_MAX = 2 * 1024 * 1024 * 1024;
function isVideoFile(f: File): boolean {
return f.type.startsWith('video/') || /\.(mp4|m4v|mov|mkv|webm|avi|3gp)$/i.test(f.name);
}
export function pick() {
fileInput?.click();
@@ -23,14 +28,14 @@
export function upload(files: FileList | File[]) {
const list = Array.from(files);
if (list.length === 0) return;
const tooBig = list.filter((f) => f.size > MAX_SIZE);
const tooBig = list.filter((f) => f.size > (isVideoFile(f) ? VIDEO_MAX : IMAGE_MAX));
if (tooBig.length > 0) {
error = `Файл слишком большой: ${tooBig.map((f) => f.name).join(', ')}. Максимум 100 МБ.`;
error = `Файл слишком большой: ${tooBig.map((f) => f.name).join(', ')}. Видео до 2 ГБ, изображения до 100 МБ.`;
return;
}
const total = list.reduce((sum, f) => sum + f.size, 0);
if (total > MAX_SIZE) {
error = 'Суммарный размер файлов превышает 100 МБ. Загрузите меньшими порциями.';
if (total > VIDEO_MAX) {
error = 'Суммарный размер файлов превышает 2 ГБ. Загрузите меньшими порциями.';
return;
}
error = '';
@@ -80,7 +85,7 @@
<input
bind:this={fileInput}
type="file"
accept="image/*"
accept="image/*,video/*"
multiple
class="hidden"
onchange={onChange}
+44
View File
@@ -60,6 +60,11 @@
const panoTag = $derived(isPanoTag(current) || isPanoTag(fresh));
const pano = $derived(panoTag && !panoFailed);
const panoSrc = $derived(fullSrc || previewSrc);
const isVideo = $derived((current?.kind ?? fresh?.kind) === 'video');
const playbackSrc = $derived(fresh?.playback_url ?? current?.playback_url ?? '');
const videoPoster = $derived(
fresh?.preview_url ?? fresh?.thumb_url ?? current?.preview_url ?? current?.thumb_url ?? ''
);
$effect(() => {
const id = current?.id;
@@ -268,6 +273,10 @@
function onKey(ev: KeyboardEvent) {
if (isTyping()) return;
// Let the native video player own its playback keys.
if (isVideo && (ev.code === 'Space' || ev.key === 'ArrowLeft' || ev.key === 'ArrowRight')) {
return;
}
if (ev.key === 'Escape') {
if (!document.fullscreenElement) onClose();
return;
@@ -396,6 +405,41 @@
onFallback={() => (panoFailed = true)}
/>
</div>
{:else if isVideo}
<div
class="bg-canvas flex min-h-0 flex-1 items-center justify-center overflow-hidden"
role="group"
aria-label="Просмотр видео"
oncontextmenu={onContextMenu}
>
{#if playbackSrc}
<!-- svelte-ignore a11y_media_has_caption -->
<video
class="m-auto block max-h-full max-w-full"
src={playbackSrc}
poster={videoPoster}
controls
playsinline
preload="metadata"
></video>
{:else}
<div class="relative flex items-center justify-center">
{#if videoPoster}
<img
src={videoPoster}
alt={current?.original_filename ?? ''}
class="max-h-full max-w-full object-contain"
/>
{/if}
<span
class="absolute rounded bg-black/60 px-2 py-1 text-xs text-white"
role="status"
>
Обработка видео…
</span>
</div>
{/if}
</div>
{:else}
<div
bind:this={scroller}