Quick save

This commit is contained in:
Mawoka
2023-06-09 10:07:47 +02:00
parent 44432b20c7
commit 28c072227c
13 changed files with 537 additions and 35 deletions
+76 -6
View File
@@ -2,8 +2,9 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this # 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/. # file, You can obtain one at https://mozilla.org/MPL/2.0/.
from datetime import datetime, timedelta 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 fastapi.responses import StreamingResponse, RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
@@ -19,8 +20,22 @@ settings = settings()
router = APIRouter() 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}") @router.get("/download/{file_name}")
async def download_file(file_name: str): async def download_file(file_name: str):
item = None
checked_image_string = check_image_string(file_name) checked_image_string = check_image_string(file_name)
if not checked_image_string[0]: if not checked_image_string[0]:
raise HTTPException(status_code=400, detail="Invalid file name") 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: if file_name is None:
file_name = item.id.hex file_name = item.id.hex
if storage.backend == "s3": 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: try:
download = await storage.download(file_name) download = await storage.download(file_name)
except DownloadingFailedError: except DownloadingFailedError:
@@ -43,13 +58,43 @@ async def download_file(file_name: str):
def iter_file(): def iter_file():
yield from download 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( return StreamingResponse(
iter_file(), iter_file(),
media_type="image/*", media_type=media_type,
headers={"Cache-Control": "public, immutable, max-age=31536000"}, 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("/") @router.post("/")
async def upload_file(file: UploadFile = File(), user: User = Depends(get_current_user)) -> PublicStorageItem: async def upload_file(file: UploadFile = File(), user: User = Depends(get_current_user)) -> PublicStorageItem:
if user.storage_used > settings.free_storage_limit: 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, deleted_at=None,
alt_text=None, alt_text=None,
) )
file_data = await file.read() await storage.upload(file_name=file_id.hex, file_data=file.file)
await storage.upload(file_name=file_id.hex, file_data=file_data) 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 file_obj.save()
await arq.enqueue_job("calculate_hash", file_id.hex) await arq.enqueue_job("calculate_hash", file_id.hex)
return PublicStorageItem.from_db_model(file_obj) return PublicStorageItem.from_db_model(file_obj)
+2 -2
View File
@@ -50,8 +50,8 @@ class Storage:
""" """
yield self.instance.download(file_name) yield self.instance.download(file_name)
async def upload(self, file_name: str, file_data: BinaryIO) -> None: 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) return await self.instance.upload(file=file_data, file_name=file_name, mime_type=mime_type)
async def delete(self, file_names: [str]) -> None: async def delete(self, file_names: [str]) -> None:
return await self.instance.delete(file_names=file_names) return await self.instance.delete(file_names=file_names)
+1 -1
View File
@@ -27,7 +27,7 @@ class LocalStorage:
except FileNotFoundError: except FileNotFoundError:
yield None 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: async with aiofiles.open(file=os.path.join(self.base_path, file_name), mode="wb") as f:
await aioshutil_copyfileobj(data, f) await aioshutil_copyfileobj(data, f)
+1 -3
View File
@@ -112,9 +112,7 @@ class S3Storage:
return headers, request_url return headers, request_url
async def upload( async def upload(self, file: BinaryIO, file_name: str, mime_type: str | None = "application/octet-stream") -> None:
self, file: BinaryIO, file_name: str, content_type: str | None = "application/octet-stream"
) -> None:
headers, url = self._generate_aws_signature_v4(method="PUT", path=f"/{file_name}") 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: async with ClientSession() as session, session.put(url, headers=headers, data=file) as resp:
if resp.status == 200: if resp.status == 200:
+3
View File
@@ -23,6 +23,8 @@
"@ckeditor/ckeditor5-theme-lark": "^37.1.0", "@ckeditor/ckeditor5-theme-lark": "^37.1.0",
"@felte/reporter-tippy": "^1.1.5", "@felte/reporter-tippy": "^1.1.5",
"@felte/validator-yup": "^1.0.11", "@felte/validator-yup": "^1.0.11",
"@ffmpeg/core": "^0.11.0",
"@ffmpeg/ffmpeg": "^0.11.6",
"@fontsource/marck-script": "^4.5.11", "@fontsource/marck-script": "^4.5.11",
"@sentry/browser": "^7.48.0", "@sentry/browser": "^7.48.0",
"@sentry/tracing": "^7.48.0", "@sentry/tracing": "^7.48.0",
@@ -96,6 +98,7 @@
"typescript": "~5.0.4", "typescript": "~5.0.4",
"ua-parser-js": "^1.0.35", "ua-parser-js": "^1.0.35",
"vite": "^4.2.2", "vite": "^4.2.2",
"vite-plugin-cross-origin-isolation": "^0.1.6",
"vite-plugin-iso-import": "^1.0.0", "vite-plugin-iso-import": "^1.0.0",
"yup": "^1.1.1" "yup": "^1.1.1"
}, },
+89
View File
@@ -10,6 +10,8 @@ specifiers:
'@ckeditor/ckeditor5-theme-lark': ^37.1.0 '@ckeditor/ckeditor5-theme-lark': ^37.1.0
'@felte/reporter-tippy': ^1.1.5 '@felte/reporter-tippy': ^1.1.5
'@felte/validator-yup': ^1.0.11 '@felte/validator-yup': ^1.0.11
'@ffmpeg/core': ^0.11.0
'@ffmpeg/ffmpeg': ^0.11.6
'@fontsource/marck-script': ^4.5.11 '@fontsource/marck-script': ^4.5.11
'@sentry/browser': ^7.48.0 '@sentry/browser': ^7.48.0
'@sentry/tracing': ^7.48.0 '@sentry/tracing': ^7.48.0
@@ -84,6 +86,7 @@ specifiers:
typescript: ~5.0.4 typescript: ~5.0.4
ua-parser-js: ^1.0.35 ua-parser-js: ^1.0.35
vite: ^4.2.2 vite: ^4.2.2
vite-plugin-cross-origin-isolation: ^0.1.6
vite-plugin-iso-import: ^1.0.0 vite-plugin-iso-import: ^1.0.0
yup: ^1.1.1 yup: ^1.1.1
@@ -100,6 +103,8 @@ devDependencies:
'@ckeditor/ckeditor5-theme-lark': 37.1.0 '@ckeditor/ckeditor5-theme-lark': 37.1.0
'@felte/reporter-tippy': 1.1.5_tippy.js@6.3.7 '@felte/reporter-tippy': 1.1.5_tippy.js@6.3.7
'@felte/validator-yup': 1.0.11_yup@1.1.1 '@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 '@fontsource/marck-script': 4.5.11
'@sentry/browser': 7.48.0 '@sentry/browser': 7.48.0
'@sentry/tracing': 7.48.0 '@sentry/tracing': 7.48.0
@@ -173,6 +178,7 @@ devDependencies:
typescript: 5.0.4 typescript: 5.0.4
ua-parser-js: 1.0.35 ua-parser-js: 1.0.35
vite: 4.2.2_sass@1.62.0 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 vite-plugin-iso-import: 1.0.0_vite@4.2.2
yup: 1.1.1 yup: 1.1.1
@@ -939,6 +945,28 @@ packages:
yup: 1.1.1 yup: 1.1.1
dev: true 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: /@fontsource/marck-script/4.5.11:
resolution: resolution:
{ {
@@ -3538,6 +3566,13 @@ packages:
} }
dev: true dev: true
/is-url/1.2.4:
resolution:
{
integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==
}
dev: true
/isexe/2.0.0: /isexe/2.0.0:
resolution: resolution:
{ {
@@ -4017,6 +4052,21 @@ packages:
} }
dev: true 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: /node-releases/2.0.10:
resolution: resolution:
{ {
@@ -4955,6 +5005,14 @@ packages:
protocol-buffers-schema: 3.6.0 protocol-buffers-schema: 3.6.0
dev: true 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: /resolve/1.22.2:
resolution: resolution:
{ {
@@ -5567,6 +5625,13 @@ packages:
engines: { node: '>=6' } engines: { node: '>=6' }
dev: true dev: true
/tr46/0.0.3:
resolution:
{
integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
}
dev: true
/ts-interface-checker/0.1.13: /ts-interface-checker/0.1.13:
resolution: resolution:
{ {
@@ -5724,6 +5789,13 @@ packages:
unist-util-stringify-position: 2.0.3 unist-util-stringify-position: 2.0.3
dev: true 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: /vite-plugin-iso-import/1.0.0_vite@4.2.2:
resolution: resolution:
{ {
@@ -5799,6 +5871,23 @@ packages:
pbf: 3.2.1 pbf: 3.2.1
dev: true 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: /which-module/2.0.0:
resolution: 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}
+20 -10
View File
@@ -12,14 +12,15 @@
import Spinner from '../Spinner.svelte'; import Spinner from '../Spinner.svelte';
// import { createTippy } from 'svelte-tippy'; // import { createTippy } from 'svelte-tippy';
import { getLocalization } from '$lib/i18n'; import { getLocalization } from '$lib/i18n';
// import MediaComponent from "$lib/editor/MediaComponent.svelte";
const { t } = getLocalization(); const { t } = getLocalization();
/* const tippy = createTippy({ /* const tippy = createTippy({
arrow: true, arrow: true,
animation: 'perspective-subtle', animation: 'perspective-subtle',
placement: 'top' placement: 'top'
});*/ });*/
export let data: EditorData; export let data: EditorData;
export let selected_question: number; export let selected_question: number;
@@ -49,6 +50,17 @@
selected_question; selected_question;
set_unique(); 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) { if (typeof data.questions[selected_question].type !== QuizQuestionType) {
console.log(data.questions[selected_question].type !== QuizQuestionType.ABCD || data.questions[selected_question].type !== QuizQuestionType.RANGE) console.log(data.questions[selected_question].type !== QuizQuestionType.ABCD || data.questions[selected_question].type !== QuizQuestionType.RANGE)
@@ -126,12 +138,9 @@
/> />
</svg> </svg>
</button> </button>
<img {#await import('$lib/editor/MediaComponent.svelte') then c}
src="/api/v1/storage/download/{data.questions[selected_question] <svelte:component this={c.default} bind:src={image_url} />
.image}" {/await}
alt="not available"
class="max-h-64 h-auto w-auto"
/>
</div> </div>
</div> </div>
{:else} {:else}
@@ -144,6 +153,7 @@
bind:edit_id bind:edit_id
bind:data bind:data
bind:selected_question bind:selected_question
video_upload={true}
/> />
{/await} {/await}
{/if} {/if}
@@ -82,6 +82,7 @@
bind:modalOpen={uppyOpen} bind:modalOpen={uppyOpen}
bind:edit_id bind:edit_id
bind:data bind:data
video_upload={false}
/> />
{/await} {/await}
{/if} {/if}
@@ -194,6 +195,7 @@
bind:edit_id bind:edit_id
bind:data bind:data
selected_question={-1} selected_question={-1}
video_upload={false}
/> />
{/await} {/await}
{/if} {/if}
+55 -11
View File
@@ -21,6 +21,7 @@
import '@uppy/image-editor/dist/style.css'; import '@uppy/image-editor/dist/style.css';
import type { EditorData } from '../quiz_types'; import type { EditorData } from '../quiz_types';
import { getLocalization } from '$lib/i18n'; import { getLocalization } from '$lib/i18n';
import { onMount } from 'svelte';
const { t } = getLocalization(); const { t } = getLocalization();
@@ -28,6 +29,10 @@
export let edit_id: string; export let edit_id: string;
export let data: EditorData; export let data: EditorData;
export let selected_question: number; 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() const uppy = new Uppy()
.use(DropTarget, { .use(DropTarget, {
@@ -47,8 +52,9 @@
const props = { const props = {
inline: true, inline: true,
restrictions: { restrictions: {
maxFileSize: 10_000_000, maxFileSize: 10_490_000,
maxNumberOfFiles: 1 maxNumberOfFiles: 1,
allowedFileTypes: ['image/*']
// allowedFileTypes: ['.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp'] // allowedFileTypes: ['.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp']
} }
}; };
@@ -69,6 +75,28 @@
modalOpen = false; modalOpen = false;
}); });
console.log(edit_id); 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> </script>
{#if modalOpen} {#if modalOpen}
@@ -77,17 +105,33 @@
transition:fade|local transition:fade|local
> >
<div> <div>
<button
type="button"
class="rounded-t-lg bg-black text-white px-1"
on:click={() => {
modalOpen = false;
}}
>Close
</button>
<div> <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> </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>
</div> </div>
{/if} {/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
View File
@@ -1,8 +1,22 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import crossOriginIsolation from 'vite-plugin-cross-origin-isolation';
/** @type {import('vite').UserConfig} */ /** @type {import("vite").UserConfig} */
const config = { 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: { server: {
port: 3000 port: 3000
}, },