✨ Quick save
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user