✨ Quick save
This commit is contained in:
@@ -2,8 +2,9 @@
|
||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
from datetime import datetime, timedelta
|
||||
from tempfile import SpooledTemporaryFile
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Request, Response
|
||||
from fastapi.responses import StreamingResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -19,8 +20,22 @@ settings = settings()
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def headers_from_storage_item(item: StorageItem) -> dict[str, str]:
|
||||
base_headers = {"Content-Type": item.mime_type}
|
||||
if item.hash is not None:
|
||||
base_headers["X-Hash"] = item.hash.hex()
|
||||
if item.thumbhash is not None:
|
||||
base_headers["X-Thumbhash"] = item.thumbhash
|
||||
if item.alt_text is not None:
|
||||
base_headers["X-Alt-Text"] = item.alt_text
|
||||
if item.size != 0:
|
||||
base_headers["Content-Size"] = str(item.size)
|
||||
return base_headers
|
||||
|
||||
|
||||
@router.get("/download/{file_name}")
|
||||
async def download_file(file_name: str):
|
||||
item = None
|
||||
checked_image_string = check_image_string(file_name)
|
||||
if not checked_image_string[0]:
|
||||
raise HTTPException(status_code=400, detail="Invalid file name")
|
||||
@@ -32,7 +47,7 @@ async def download_file(file_name: str):
|
||||
if file_name is None:
|
||||
file_name = item.id.hex
|
||||
if storage.backend == "s3":
|
||||
return RedirectResponse(url=await storage.get_url(file_name, 300))
|
||||
return RedirectResponse(url=await storage.get_url(file_name, 300), headers=headers_from_storage_item(item))
|
||||
try:
|
||||
download = await storage.download(file_name)
|
||||
except DownloadingFailedError:
|
||||
@@ -43,13 +58,43 @@ async def download_file(file_name: str):
|
||||
def iter_file():
|
||||
yield from download
|
||||
|
||||
media_type = "image/*"
|
||||
if item is not None:
|
||||
media_type = item.mime_type
|
||||
headers = {"Cache-Control": "public, immutable, max-age=31536000"}
|
||||
if item is not None:
|
||||
headers = {**headers, **headers_from_storage_item(item)}
|
||||
|
||||
return StreamingResponse(
|
||||
iter_file(),
|
||||
media_type="image/*",
|
||||
headers={"Cache-Control": "public, immutable, max-age=31536000"},
|
||||
media_type=media_type,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
@router.head("/download/{file_name}")
|
||||
async def download_file_head(file_name: str) -> Response:
|
||||
checked_image_string = check_image_string(file_name)
|
||||
if not checked_image_string[0]:
|
||||
raise HTTPException(status_code=404, detail="Invalid file name")
|
||||
if checked_image_string[1] is not None:
|
||||
item = await StorageItem.objects.get_or_none(id=checked_image_string[1])
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
# return PublicStorageItem.from_db_model(item)
|
||||
storage_file_name = item.storage_path
|
||||
if storage_file_name is None:
|
||||
storage_file_name = item.id.hex
|
||||
resp = Response(status_code=200, headers=headers_from_storage_item(item))
|
||||
else:
|
||||
resp = Response(status_code=200, headers={"Content-Type": "image/*"})
|
||||
storage_file_name = file_name
|
||||
if storage.backend == "s3":
|
||||
resp.status_code = 307
|
||||
resp.headers.append("Location", await storage.get_url(storage_file_name, 300))
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def upload_file(file: UploadFile = File(), user: User = Depends(get_current_user)) -> PublicStorageItem:
|
||||
if user.storage_used > settings.free_storage_limit:
|
||||
@@ -67,8 +112,33 @@ async def upload_file(file: UploadFile = File(), user: User = Depends(get_curren
|
||||
deleted_at=None,
|
||||
alt_text=None,
|
||||
)
|
||||
file_data = await file.read()
|
||||
await storage.upload(file_name=file_id.hex, file_data=file_data)
|
||||
await storage.upload(file_name=file_id.hex, file_data=file.file)
|
||||
await file_obj.save()
|
||||
await arq.enqueue_job("calculate_hash", file_id.hex)
|
||||
return PublicStorageItem.from_db_model(file_obj)
|
||||
|
||||
|
||||
@router.post("/raw")
|
||||
async def upload_raw_file(request: Request, user: User = Depends(get_current_user)) -> PublicStorageItem:
|
||||
if user.storage_used > settings.free_storage_limit:
|
||||
raise HTTPException(status_code=409, detail="Storage limit reached")
|
||||
file_id = uuid4()
|
||||
data_file = SpooledTemporaryFile(max_size=1000)
|
||||
async for chunk in request.stream():
|
||||
data_file.write(chunk)
|
||||
data_file.seek(0)
|
||||
file_obj = StorageItem(
|
||||
id=file_id,
|
||||
uploaded_at=datetime.now(),
|
||||
mime_type=request.headers.get("Content-Type"),
|
||||
hash=None,
|
||||
user=user,
|
||||
size=0,
|
||||
deleted_at=None,
|
||||
alt_text=None,
|
||||
)
|
||||
# https://github.com/VirusTotal/vt-py/issues/119#issuecomment-1261246867
|
||||
await storage.upload(file_name=file_id.hex, file_data=data_file._file)
|
||||
await file_obj.save()
|
||||
await arq.enqueue_job("calculate_hash", file_id.hex)
|
||||
return PublicStorageItem.from_db_model(file_obj)
|
||||
|
||||
@@ -50,8 +50,8 @@ class Storage:
|
||||
"""
|
||||
yield self.instance.download(file_name)
|
||||
|
||||
async def upload(self, file_name: str, file_data: BinaryIO) -> None:
|
||||
return await self.instance.upload(file=file_data, file_name=file_name)
|
||||
async def upload(self, file_name: str, file_data: BinaryIO, mime_type: str | None = None) -> None:
|
||||
return await self.instance.upload(file=file_data, file_name=file_name, mime_type=mime_type)
|
||||
|
||||
async def delete(self, file_names: [str]) -> None:
|
||||
return await self.instance.delete(file_names=file_names)
|
||||
|
||||
@@ -27,7 +27,7 @@ class LocalStorage:
|
||||
except FileNotFoundError:
|
||||
yield None
|
||||
|
||||
async def upload(self, file_name: str, data: BinaryIO) -> None:
|
||||
async def upload(self, file_name: str, data: BinaryIO, mime_type: str | None = None) -> None:
|
||||
async with aiofiles.open(file=os.path.join(self.base_path, file_name), mode="wb") as f:
|
||||
await aioshutil_copyfileobj(data, f)
|
||||
|
||||
|
||||
@@ -112,9 +112,7 @@ class S3Storage:
|
||||
|
||||
return headers, request_url
|
||||
|
||||
async def upload(
|
||||
self, file: BinaryIO, file_name: str, content_type: str | None = "application/octet-stream"
|
||||
) -> None:
|
||||
async def upload(self, file: BinaryIO, file_name: str, mime_type: str | None = "application/octet-stream") -> None:
|
||||
headers, url = self._generate_aws_signature_v4(method="PUT", path=f"/{file_name}")
|
||||
async with ClientSession() as session, session.put(url, headers=headers, data=file) as resp:
|
||||
if resp.status == 200:
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"@ckeditor/ckeditor5-theme-lark": "^37.1.0",
|
||||
"@felte/reporter-tippy": "^1.1.5",
|
||||
"@felte/validator-yup": "^1.0.11",
|
||||
"@ffmpeg/core": "^0.11.0",
|
||||
"@ffmpeg/ffmpeg": "^0.11.6",
|
||||
"@fontsource/marck-script": "^4.5.11",
|
||||
"@sentry/browser": "^7.48.0",
|
||||
"@sentry/tracing": "^7.48.0",
|
||||
@@ -96,6 +98,7 @@
|
||||
"typescript": "~5.0.4",
|
||||
"ua-parser-js": "^1.0.35",
|
||||
"vite": "^4.2.2",
|
||||
"vite-plugin-cross-origin-isolation": "^0.1.6",
|
||||
"vite-plugin-iso-import": "^1.0.0",
|
||||
"yup": "^1.1.1"
|
||||
},
|
||||
|
||||
Generated
+89
@@ -10,6 +10,8 @@ specifiers:
|
||||
'@ckeditor/ckeditor5-theme-lark': ^37.1.0
|
||||
'@felte/reporter-tippy': ^1.1.5
|
||||
'@felte/validator-yup': ^1.0.11
|
||||
'@ffmpeg/core': ^0.11.0
|
||||
'@ffmpeg/ffmpeg': ^0.11.6
|
||||
'@fontsource/marck-script': ^4.5.11
|
||||
'@sentry/browser': ^7.48.0
|
||||
'@sentry/tracing': ^7.48.0
|
||||
@@ -84,6 +86,7 @@ specifiers:
|
||||
typescript: ~5.0.4
|
||||
ua-parser-js: ^1.0.35
|
||||
vite: ^4.2.2
|
||||
vite-plugin-cross-origin-isolation: ^0.1.6
|
||||
vite-plugin-iso-import: ^1.0.0
|
||||
yup: ^1.1.1
|
||||
|
||||
@@ -100,6 +103,8 @@ devDependencies:
|
||||
'@ckeditor/ckeditor5-theme-lark': 37.1.0
|
||||
'@felte/reporter-tippy': 1.1.5_tippy.js@6.3.7
|
||||
'@felte/validator-yup': 1.0.11_yup@1.1.1
|
||||
'@ffmpeg/core': 0.11.0
|
||||
'@ffmpeg/ffmpeg': 0.11.6
|
||||
'@fontsource/marck-script': 4.5.11
|
||||
'@sentry/browser': 7.48.0
|
||||
'@sentry/tracing': 7.48.0
|
||||
@@ -173,6 +178,7 @@ devDependencies:
|
||||
typescript: 5.0.4
|
||||
ua-parser-js: 1.0.35
|
||||
vite: 4.2.2_sass@1.62.0
|
||||
vite-plugin-cross-origin-isolation: 0.1.6
|
||||
vite-plugin-iso-import: 1.0.0_vite@4.2.2
|
||||
yup: 1.1.1
|
||||
|
||||
@@ -939,6 +945,28 @@ packages:
|
||||
yup: 1.1.1
|
||||
dev: true
|
||||
|
||||
/@ffmpeg/core/0.11.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-9Tt/+2PMpkGPXUK8n6He9G8Y+qR6qmCPSCw9iEKZxHHOvJ9BE/r0Fccj+YgDZTlyu6rXxc9x6EqCaFBIt7qzjA==
|
||||
}
|
||||
dev: true
|
||||
|
||||
/@ffmpeg/ffmpeg/0.11.6:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-uN8J8KDjADEavPhNva6tYO9Fj0lWs9z82swF3YXnTxWMBoFLGq3LZ6FLlIldRKEzhOBKnkVfA8UnFJuvGvNxcA==
|
||||
}
|
||||
engines: { node: '>=12.16.1' }
|
||||
dependencies:
|
||||
is-url: 1.2.4
|
||||
node-fetch: 2.6.11
|
||||
regenerator-runtime: 0.13.11
|
||||
resolve-url: 0.2.1
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
dev: true
|
||||
|
||||
/@fontsource/marck-script/4.5.11:
|
||||
resolution:
|
||||
{
|
||||
@@ -3538,6 +3566,13 @@ packages:
|
||||
}
|
||||
dev: true
|
||||
|
||||
/is-url/1.2.4:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==
|
||||
}
|
||||
dev: true
|
||||
|
||||
/isexe/2.0.0:
|
||||
resolution:
|
||||
{
|
||||
@@ -4017,6 +4052,21 @@ packages:
|
||||
}
|
||||
dev: true
|
||||
|
||||
/node-fetch/2.6.11:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w==
|
||||
}
|
||||
engines: { node: 4.x || >=6.0.0 }
|
||||
peerDependencies:
|
||||
encoding: ^0.1.0
|
||||
peerDependenciesMeta:
|
||||
encoding:
|
||||
optional: true
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
dev: true
|
||||
|
||||
/node-releases/2.0.10:
|
||||
resolution:
|
||||
{
|
||||
@@ -4955,6 +5005,14 @@ packages:
|
||||
protocol-buffers-schema: 3.6.0
|
||||
dev: true
|
||||
|
||||
/resolve-url/0.2.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==
|
||||
}
|
||||
deprecated: https://github.com/lydell/resolve-url#deprecated
|
||||
dev: true
|
||||
|
||||
/resolve/1.22.2:
|
||||
resolution:
|
||||
{
|
||||
@@ -5567,6 +5625,13 @@ packages:
|
||||
engines: { node: '>=6' }
|
||||
dev: true
|
||||
|
||||
/tr46/0.0.3:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
|
||||
}
|
||||
dev: true
|
||||
|
||||
/ts-interface-checker/0.1.13:
|
||||
resolution:
|
||||
{
|
||||
@@ -5724,6 +5789,13 @@ packages:
|
||||
unist-util-stringify-position: 2.0.3
|
||||
dev: true
|
||||
|
||||
/vite-plugin-cross-origin-isolation/0.1.6:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-OY0naW9nPUDrKTffYnY7FRYXOgZdHoNwMGpPxUmj/n32mGZi01fcq+J536jkmwGWX7DLT95XBQVHHbrAJzTvrw==
|
||||
}
|
||||
dev: true
|
||||
|
||||
/vite-plugin-iso-import/1.0.0_vite@4.2.2:
|
||||
resolution:
|
||||
{
|
||||
@@ -5799,6 +5871,23 @@ packages:
|
||||
pbf: 3.2.1
|
||||
dev: true
|
||||
|
||||
/webidl-conversions/3.0.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
|
||||
}
|
||||
dev: true
|
||||
|
||||
/whatwg-url/5.0.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==
|
||||
}
|
||||
dependencies:
|
||||
tr46: 0.0.3
|
||||
webidl-conversions: 3.0.1
|
||||
dev: true
|
||||
|
||||
/which-module/2.0.0:
|
||||
resolution:
|
||||
{
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<!--
|
||||
- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
export let src: string;
|
||||
let type: 'img' | 'video' | undefined = undefined;
|
||||
|
||||
const get_media = async () => {
|
||||
const res = await fetch(src, { method: 'HEAD' });
|
||||
const fileType = res.headers.get('Content-Type');
|
||||
console.log(fileType, 'fileType');
|
||||
if (fileType.includes('video')) {
|
||||
console.log('Setting type to video');
|
||||
type = 'video';
|
||||
} else {
|
||||
type = 'img';
|
||||
console.log(res);
|
||||
return {
|
||||
data: URL.createObjectURL(await res.blob()),
|
||||
alt_text: res.headers.get('X-Alt-Text')
|
||||
};
|
||||
}
|
||||
};
|
||||
const update_url = () => {
|
||||
media = get_media();
|
||||
};
|
||||
let media = get_media();
|
||||
$: {
|
||||
src;
|
||||
update_url();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await media}
|
||||
<p>Placeholder</p>
|
||||
{:then data}
|
||||
{#if type === 'img'}
|
||||
<img src={data.data} alt={data.alt_text ?? 'Not available'} />
|
||||
{:else if type === 'video'}
|
||||
<p>Video!</p>
|
||||
<video
|
||||
disablepictureinpicture
|
||||
disableremoteplayback
|
||||
x-webkit-airplay="deny"
|
||||
loop
|
||||
preload="metadata"
|
||||
>
|
||||
<source {src} />
|
||||
</video>
|
||||
{:else}
|
||||
<p>Unknown media type</p>
|
||||
{/if}
|
||||
{/await}
|
||||
@@ -12,14 +12,15 @@
|
||||
import Spinner from '../Spinner.svelte';
|
||||
// import { createTippy } from 'svelte-tippy';
|
||||
import { getLocalization } from '$lib/i18n';
|
||||
// import MediaComponent from "$lib/editor/MediaComponent.svelte";
|
||||
|
||||
const { t } = getLocalization();
|
||||
|
||||
/* const tippy = createTippy({
|
||||
arrow: true,
|
||||
animation: 'perspective-subtle',
|
||||
placement: 'top'
|
||||
});*/
|
||||
arrow: true,
|
||||
animation: 'perspective-subtle',
|
||||
placement: 'top'
|
||||
});*/
|
||||
|
||||
export let data: EditorData;
|
||||
export let selected_question: number;
|
||||
@@ -49,6 +50,17 @@
|
||||
selected_question;
|
||||
set_unique();
|
||||
}
|
||||
let image_url = '';
|
||||
|
||||
const update_image_url = () => {
|
||||
image_url = `/api/v1/storage/download/${data.questions[selected_question].image}`;
|
||||
console.log('updated!');
|
||||
};
|
||||
$: {
|
||||
update_image_url();
|
||||
selected_question;
|
||||
data.questions;
|
||||
}
|
||||
/*
|
||||
if (typeof data.questions[selected_question].type !== QuizQuestionType) {
|
||||
console.log(data.questions[selected_question].type !== QuizQuestionType.ABCD || data.questions[selected_question].type !== QuizQuestionType.RANGE)
|
||||
@@ -126,12 +138,9 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<img
|
||||
src="/api/v1/storage/download/{data.questions[selected_question]
|
||||
.image}"
|
||||
alt="not available"
|
||||
class="max-h-64 h-auto w-auto"
|
||||
/>
|
||||
{#await import('$lib/editor/MediaComponent.svelte') then c}
|
||||
<svelte:component this={c.default} bind:src={image_url} />
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -144,6 +153,7 @@
|
||||
bind:edit_id
|
||||
bind:data
|
||||
bind:selected_question
|
||||
video_upload={true}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
bind:modalOpen={uppyOpen}
|
||||
bind:edit_id
|
||||
bind:data
|
||||
video_upload={false}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
@@ -194,6 +195,7 @@
|
||||
bind:edit_id
|
||||
bind:data
|
||||
selected_question={-1}
|
||||
video_upload={false}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import '@uppy/image-editor/dist/style.css';
|
||||
import type { EditorData } from '../quiz_types';
|
||||
import { getLocalization } from '$lib/i18n';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const { t } = getLocalization();
|
||||
|
||||
@@ -28,6 +29,10 @@
|
||||
export let edit_id: string;
|
||||
export let data: EditorData;
|
||||
export let selected_question: number;
|
||||
export let video_upload: boolean = false;
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
let video_popup: undefined | WindowProxy = undefined;
|
||||
|
||||
const uppy = new Uppy()
|
||||
.use(DropTarget, {
|
||||
@@ -47,8 +52,9 @@
|
||||
const props = {
|
||||
inline: true,
|
||||
restrictions: {
|
||||
maxFileSize: 10_000_000,
|
||||
maxNumberOfFiles: 1
|
||||
maxFileSize: 10_490_000,
|
||||
maxNumberOfFiles: 1,
|
||||
allowedFileTypes: ['image/*']
|
||||
// allowedFileTypes: ['.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp']
|
||||
}
|
||||
};
|
||||
@@ -69,6 +75,28 @@
|
||||
modalOpen = false;
|
||||
});
|
||||
console.log(edit_id);
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key !== 'video_upload_id') {
|
||||
return;
|
||||
}
|
||||
localStorage.removeItem('video_upload_id');
|
||||
data.questions[selected_question].image = e.newValue;
|
||||
});
|
||||
});
|
||||
|
||||
const upload_video = async () => {
|
||||
video_popup = window.open(
|
||||
'/edit/videos',
|
||||
'_blank',
|
||||
'popup=true,toolbar=false,menubar=false,location=false,'
|
||||
);
|
||||
video_popup.addEventListener('beforeunload', () => {
|
||||
console.log('Unloaded');
|
||||
video_popup = undefined;
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if modalOpen}
|
||||
@@ -77,17 +105,33 @@
|
||||
transition:fade|local
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-t-lg bg-black text-white px-1"
|
||||
on:click={() => {
|
||||
modalOpen = false;
|
||||
}}
|
||||
>Close
|
||||
</button>
|
||||
<div>
|
||||
<SvelteDashboard {uppy} width="100%" {props} />
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-t-lg bg-black text-white px-1"
|
||||
on:click={() => {
|
||||
modalOpen = false;
|
||||
}}
|
||||
>Close
|
||||
</button>
|
||||
{#if video_upload}
|
||||
<button
|
||||
class="rounded-t-lg bg-black text-white px-1"
|
||||
on:click={upload_video}
|
||||
type="button"
|
||||
>Upload video
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if video_popup}
|
||||
<div class="flex w-full h-1/3 bg-white dark:bg-gray-700 p-4">
|
||||
<h2 class="text-4xl m-auto">Popup open</h2>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<SvelteDashboard {uppy} width="100%" {props} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load = (async ({ setHeaders }) => {
|
||||
setHeaders({
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
'Cross-Origin-Opener-Policy': 'same-origin'
|
||||
});
|
||||
}) satisfies PageServerLoad;
|
||||
@@ -0,0 +1,202 @@
|
||||
<!--
|
||||
- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<!--
|
||||
- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg';
|
||||
import BrownButton from '$lib/components/buttons/brown.svelte';
|
||||
import { navbarVisible } from '$lib/stores';
|
||||
|
||||
let file_input: HTMLInputElement;
|
||||
navbarVisible.set(false);
|
||||
|
||||
let stats: { progress: number; time_elapsed: number; speed: number } = {
|
||||
progress: 0,
|
||||
time_elapsed: 0,
|
||||
speed: 0
|
||||
};
|
||||
|
||||
let upload_stats: { progress: number; upload_started: undefined | Date; time_elapsed: number } =
|
||||
{
|
||||
progress: 0,
|
||||
upload_started: undefined,
|
||||
time_elapsed: 0
|
||||
};
|
||||
|
||||
const speed_extraction_regex = /speed=(\d+\.\d+)x(?![\s\S]*speed=\d+\.\d+x)/;
|
||||
let last_time_elapsed = 0;
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
enum Status {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Idle,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Compressing,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
CompressDone,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Uploading,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Done
|
||||
}
|
||||
|
||||
let status: Status = Status.Idle;
|
||||
|
||||
let file_size_in_mi: undefined | number = undefined;
|
||||
let original_file_size_in_mi: undefined | number = undefined;
|
||||
let file_data: undefined | Blob = undefined;
|
||||
|
||||
const compress_video = async () => {
|
||||
if (status !== Status.Idle) {
|
||||
return;
|
||||
}
|
||||
status = Status.Compressing;
|
||||
const ffmpeg = createFFmpeg({
|
||||
// log: true,
|
||||
progress: (p) => {
|
||||
if (stats.progress >= 1) {
|
||||
stats.time_elapsed = last_time_elapsed;
|
||||
return;
|
||||
}
|
||||
stats.time_elapsed = p.time ?? 0;
|
||||
last_time_elapsed = stats.time_elapsed;
|
||||
stats.progress = p.ratio ?? 0;
|
||||
}
|
||||
});
|
||||
const file = file_input.files[0];
|
||||
original_file_size_in_mi = file.size / 1_048_576;
|
||||
ffmpeg.setLogger(({ message }) => {
|
||||
const speed_match = message.match(speed_extraction_regex);
|
||||
stats.speed = speed_match ? parseFloat(speed_match[1]) : 0;
|
||||
});
|
||||
await ffmpeg.load();
|
||||
ffmpeg.FS('writeFile', file.name, await fetchFile(file));
|
||||
await ffmpeg.run(
|
||||
'-i',
|
||||
file.name,
|
||||
'-vcodec',
|
||||
'libx264',
|
||||
'-crf',
|
||||
'20',
|
||||
'-vf',
|
||||
'scale=1080:-2',
|
||||
'out.mp4'
|
||||
);
|
||||
const data = await ffmpeg.FS('readFile', 'out.mp4');
|
||||
file_size_in_mi = data.length / 1_048_576;
|
||||
file_data = new Blob([data]);
|
||||
status = Status.CompressDone;
|
||||
stats.progress = 1;
|
||||
};
|
||||
|
||||
const upload_video = async () => {
|
||||
status = Status.Uploading;
|
||||
upload_stats.upload_started = new Date();
|
||||
/* const progressTrackingStream = new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue(chunk);
|
||||
bytesUploaded += chunk.byteLength;
|
||||
console.log("upload progress:", bytesUploaded / totalBytes);
|
||||
upload_stats.progress = bytesUploaded / totalBytes;
|
||||
upload_stats.time_elapsed = (new Date() - upload_stats.upload_started) / 1000;
|
||||
},
|
||||
flush(_controller) {
|
||||
console.log("completed stream");
|
||||
}
|
||||
});*/
|
||||
/* const response = await fetch("/api/v1/storage/raw", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "video/mp4"
|
||||
},
|
||||
body: file_data.stream().pipeThrough(progressTrackingStream),
|
||||
duplex: "half"
|
||||
});
|
||||
const json = await response.json()*/
|
||||
const xhr = new XMLHttpRequest();
|
||||
const success = await new Promise((resolve) => {
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable) {
|
||||
upload_stats.progress = event.loaded / event.total;
|
||||
upload_stats.time_elapsed = (new Date() - upload_stats.upload_started) / 1000;
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('loadend', () => {
|
||||
resolve({
|
||||
success: xhr.readyState === 4 && xhr.status === 200,
|
||||
body: xhr.responseText
|
||||
});
|
||||
});
|
||||
xhr.open('POST', '/api/v1/storage/raw', true);
|
||||
xhr.setRequestHeader('Content-Type', 'video/mp4');
|
||||
xhr.send(file_data);
|
||||
});
|
||||
console.log('success:', success);
|
||||
const json = JSON.parse(success.body);
|
||||
localStorage.setItem('video_upload_id', json.id);
|
||||
console.log(json, 'done');
|
||||
status = Status.Done;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="w-screen h-screen m-auto flex flex-col dark:bg-gray-700 p-2 bg-white">
|
||||
<input type="file" bind:this={file_input} class="m-auto" accept="video/mp4" />
|
||||
<div class="mt-auto flex justify-center">
|
||||
{#if status === Status.Compressing || status === Status.CompressDone}
|
||||
<table>
|
||||
<tr>
|
||||
<th>Speed</th>
|
||||
<th>Progress</th>
|
||||
<th>Time elapsed</th>
|
||||
<th>Time remaining</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{stats.speed}</td>
|
||||
<td>{Math.round(stats.progress * 100)}%</td>
|
||||
<td>{upload_stats.time_elapsed.toFixed(2)}s</td>
|
||||
<td
|
||||
>{(stats.time_elapsed / stats.progress - upload_stats.time_elapsed).toFixed(
|
||||
2
|
||||
)}s</td
|
||||
>
|
||||
</tr>
|
||||
</table>
|
||||
{:else if status === Status.Uploading}
|
||||
<table>
|
||||
<tr>
|
||||
<th>Progress</th>
|
||||
<th>Time elapsed</th>
|
||||
<th>Time remaining</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{Math.round(upload_stats.progress * 100)}%</td>
|
||||
<td>{stats.time_elapsed.toFixed(2)}s</td>
|
||||
<td>{(stats.time_elapsed / stats.progress - stats.time_elapsed).toFixed(2)}s</td
|
||||
>
|
||||
</tr>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex relative my-2">
|
||||
<div class="w-full">
|
||||
<BrownButton disabled={stats.progress !== 1} on:click={upload_video}>
|
||||
Upload {file_size_in_mi ? `${file_size_in_mi.toFixed(2)}Mi` : ''}</BrownButton
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
class="h-full transition-all absolute rounded bg-black bg-opacity-50"
|
||||
style="width: {100 - stats.progress * 100}%"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<BrownButton on:click={compress_video} disabled={status !== Status.Idle}>Submit</BrownButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
+16
-2
@@ -1,8 +1,22 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import crossOriginIsolation from 'vite-plugin-cross-origin-isolation';
|
||||
|
||||
/** @type {import('vite').UserConfig} */
|
||||
/** @type {import("vite").UserConfig} */
|
||||
const config = {
|
||||
plugins: [sveltekit()],
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
{
|
||||
name: 'configure-response-headers',
|
||||
configureServer: (server) => {
|
||||
server.middlewares.use((_req, res, next) => {
|
||||
/* res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
||||
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
||||
res.setHeader("Access-Control-Allow-Origin", "https://ncs3.classquiz.de");*/
|
||||
next();
|
||||
});
|
||||
}
|
||||
}
|
||||
],
|
||||
server: {
|
||||
port: 3000
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user