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}
+1
View File
@@ -20,6 +20,7 @@ export const icons = {
image: { path: '<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm1-4h12l-3.75-5l-3 4L9 13z"/>' },
add: { path: '<path fill="currentColor" d="M11 13H5v-2h6V5h2v6h6v2h-6v6h-2z"/>' },
remove: { path: '<path fill="currentColor" d="M5 13v-2h14v2z"/>' },
play_arrow: { path: '<path fill="currentColor" d="M8 19V5l11 7z"/>' },
photo_library: { path: '<path fill="currentColor" d="M9 14h10l-3.45-4.5l-2.3 3l-1.55-2zm-1 4q-.825 0-1.412-.587T6 16V4q0-.825.588-1.412T8 2h12q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18zm-4 4q-.825 0-1.412-.587T2 20V6h2v14h14v2z"/>' },
group: { path: '<path fill="currentColor" d="M1 20v-2.8q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2V20zm18 0v-3q0-1.1-.612-2.113T16.65 13.15q1.275.15 2.4.513t2.1.887q.9.5 1.375 1.112T23 17v3zM6.175 10.825Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175m11.65 0Q16.65 12 15 12q-.275 0-.7-.062t-.7-.138q.675-.8 1.038-1.775T15 8t-.362-2.025T13.6 4.2q.35-.125.7-.163T15 4q1.65 0 2.825 1.175T19 8t-1.175 2.825"/>' },
settings: { path: '<path fill="currentColor" d="m9.25 22l-.4-3.2q-.325-.125-.612-.3t-.563-.375L4.7 19.375l-2.75-4.75l2.575-1.95Q4.5 12.5 4.5 12.338v-.675q0-.163.025-.338L1.95 9.375l2.75-4.75l2.975 1.25q.275-.2.575-.375t.6-.3l.4-3.2h5.5l.4 3.2q.325.125.613.3t.562.375l2.975-1.25l2.75 4.75l-2.575 1.95q.025.175.025.338v.674q0 .163-.05.338l2.575 1.95l-2.75 4.75l-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2zm2.8-6.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5"/>' },
+3
View File
@@ -51,6 +51,8 @@ export interface Photo {
width: number;
height: number;
mime_type: string;
kind: 'image' | 'video';
duration_ms?: number;
taken_at?: string | null;
latitude?: number | null;
longitude?: number | null;
@@ -67,6 +69,7 @@ export interface Photo {
thumb_url?: string;
medium_url?: string;
preview_url?: string;
playback_url?: string;
original_url?: string;
download_url?: string;
}