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:
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"/>' },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user