Initial commit

This commit is contained in:
pluja
2023-08-26 10:36:05 +02:00
commit a3543ee13a
76 changed files with 6165 additions and 0 deletions

8
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.env
node_modules/
.svelte-kit
Dockerfile
.eslintignore
.eslintrc.js
.prettierrc
.gitignore

13
frontend/.eslintignore Normal file
View File

@@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

14
frontend/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,14 @@
module.exports = {
root: true,
extends: ['eslint:recommended', 'plugin:svelte/recommended', 'prettier'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
}
};

10
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.DS_Store
node_modules
build/
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

2
frontend/.npmrc Normal file
View File

@@ -0,0 +1,2 @@
engine-strict=true
resolution-mode=highest

13
frontend/.prettierignore Normal file
View File

@@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

9
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

34
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
FROM node:alpine as build
ARG PUBLIC_API_HOST
ARG PUBLIC_TRANSLATION_API_HOST
ARG PUBLIC_INTERNAL_API_HOST
ENV PUBLIC_API_HOST=$PUBLIC_API_HOST
ENV PUBLIC_TRANSLATION_API_HOST=$PUBLIC_TRANSLATION_API_HOST
ENV PUBLIC_INTERNAL_API_HOST=$PUBLIC_INTERNAL_API_HOST
WORKDIR /app
COPY . .
RUN yarn
ENV BODY_SIZE_LIMIT=0
RUN yarn build
RUN ls -al
FROM node:alpine as production
ARG PUBLIC_API_HOST
ARG PUBLIC_TRANSLATION_API_HOST
ARG PUBLIC_INTERNAL_API_HOST
ENV PUBLIC_API_HOST=$PUBLIC_API_HOST
ENV PUBLIC_TRANSLATION_API_HOST=$PUBLIC_TRANSLATION_API_HOST
ENV PUBLIC_INTERNAL_API_HOST=$PUBLIC_INTERNAL_API_HOST
ENV BODY_SIZE_LIMIT=0
WORKDIR /app
COPY --from=build /app/package.json ./
COPY --from=build /app/build/ ./
RUN yarn --production
EXPOSE 3000
CMD [ "node", "index.js"]

38
frontend/README.md Normal file
View File

@@ -0,0 +1,38 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "frontend",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/adapter-node": "^1.3.1",
"@sveltejs/kit": "^1.20.4",
"autoprefixer": "^10.4.15",
"daisyui": "^3.5.1",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.30.0",
"postcss": "^8.4.28",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.0.5",
"tailwindcss": "^3.3.3",
"vite": "^4.4.2"
},
"type": "module",
"dependencies": {
"svelte-french-toast": "^1.2.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

3
frontend/src/app.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

13
frontend/src/app.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.webp" />
<meta name="viewport" content="width=device-width" />
<title>Whishper</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,282 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { writable } from 'svelte/store';
import toast from 'svelte-french-toast';
import { editorSettings, currentTranscription, editorHistory } from '$lib/stores';
import EditorSettings from './EditorSettings.svelte';
import EditorSegment from './EditorSegment.svelte';
import { CLIENT_API_HOST } from '$lib/utils';
let language = writable('original');
// Segments lazy loading
let segmentsToShow = 20;
function loadMore() {
segmentsToShow += 10;
}
let loadMoreButton;
async function textFromSegments() {
let text = '';
if ($language == 'original') {
text = $currentTranscription.result.segments
.map((segment) => segment.text)
.join(' ')
.replace(/(\r\n|\n|\r)/gm, ' ');
} else {
text = $currentTranscription.translations
.filter((translation) => translation.targetLanguage == $language)[0]
.result.segments.map((segment) => segment.text)
.join(' ')
.replace(/(\r\n|\n|\r)/gm, ' ');
}
console.log(text);
return text;
}
async function saveChanges() {
var url = `${CLIENT_API_HOST}/api/transcriptions`; // replace with your actual endpoint
console.log($language)
// Update text to match segments
if ($language == 'original') {
$currentTranscription.result.text = await textFromSegments();
} else {
$currentTranscription.translations.forEach(async (translation) => {
if (translation.targetLanguage == $language) translation.result.text = await textFromSegments();
});
}
try {
const response = await fetch(url, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify($currentTranscription)
});
if (!response.ok) {
if (response.status === 304) {
if (!$editorSettings.autoSave) {
toast('No changes were made!', {
icon: '👐'
});
}
return;
} else {
toast.error("Couldn't save!");
throw new Error(`HTTP error! status: ${response.status}`);
}
}
if ($editorSettings.autoSave) {
toast('Autosaving...', { icon: 'ℹ️' });
} else {
toast.success('Saved!');
}
} catch (error) {
toast.error("Couldn't save!");
console.error('Error:', error);
}
}
let handleKeyDown;
let observer;
onMount(() => {
// Lazy loading
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
loadMore();
}
});
});
observer.observe(loadMoreButton);
// Function to handle Ctrl+Z and Ctrl+S shortcuts
editorHistory.set([JSON.parse(JSON.stringify($currentTranscription))]);
let isUndoing = false;
handleKeyDown = function (e) {
// Undo (CTRL+Z)
if (e.ctrlKey && e.key === 'z' && !isUndoing) {
isUndoing = true;
let previousTranscription = null;
editorHistory.update((history) => {
if (history.length > 1) {
history = history.slice(0, -1);
previousTranscription = { ...history[history.length - 1] };
}
return history;
});
if (previousTranscription) {
$currentTranscription = { ...previousTranscription };
}
isUndoing = false;
}
let isSaving = false;
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
if (!isSaving) {
isSaving = true;
saveChanges();
isSaving = false;
}
}
};
if (!$editorSettings.autoSave) {
toast('Autosave is disabled.', {
icon: '👋'
});
}
// Listen to keydown event
document.addEventListener('keydown', handleKeyDown);
});
// Autosave
let autosaveInterval;
let autoSaveAux = $editorSettings.autoSave;
$: if ($editorSettings.autoSave) {
toast.success('Autosave enabled.');
autoSaveAux = true;
autosaveInterval = setInterval(() => {
saveChanges();
}, $editorSettings.autosaveInterval);
} else {
if (autoSaveAux == true) {
toast('Autosave is disabled.', {
icon: '👋'
});
autoSaveAux = false;
}
clearInterval(autosaveInterval);
}
</script>
{#if $currentTranscription.status != 2}
<div class="flex items-center justify-center">
<span class="loading loading-spinner loading-lg"></span>
<p class="text-center">Waiting for task to finish {$currentTranscription.status == 3 ? "translating" : "transcribing"}...</p>
</div>
{:else}
<div class="flex flex-col items-center break-words">
<h1 class="text-center text-2xl mt-8 break-words">
{$currentTranscription.fileName.split('_WHSHPR_')[1]}
</h1>
<!-- Menu -->
<ul class="menu menu-horizontal bg-base-200 rounded-box mt-6">
<li>
<a href="/" class="tooltip" data-tip="Home">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/></svg
>
</a>
</li>
<li>
<button on:click={saveChanges} class="tooltip" data-tip="Save (Ctrl+S)">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-device-floppy"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 4h10l4 4v10a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2" />
<path d="M12 14m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M14 4l0 4l-6 0l0 -4" />
</svg>
</button>
</li>
</ul>
<!-- End Menu -->
{#if $currentTranscription.translations.length > 0}
<div class="form-control max-w-xs my-4">
<label for="language" class="label">
<span class="label-text">Subtitles language</span>
</label>
<select
bind:value={$language}
name="language"
class="select select-sm select-bordered uppercase"
>
<option value="original">✅ {$currentTranscription.result.language}</option>
{#each $currentTranscription.translations as translation}
<option value={translation.targetLanguage}>🤖 {translation.targetLanguage}</option>
{/each}
</select>
</div>
{/if}
</div>
<div class="mt-4">
<!-- Editor configuration -->
<EditorSettings />
<!-- End Editor configuration -->
<div class="overflow-x-auto">
<!-- Segments table -->
<table class="table px-4">
<thead>
<tr>
<th />
<th>Start</th>
<th>End</th>
<th>Text</th>
<th>Info</th>
<th />
</tr>
</thead>
<tbody>
{#if $language == 'original'}
{#each $currentTranscription.result.segments.slice(0, segmentsToShow) as segment, index (segment.id)}
<EditorSegment {segment} {index} translationIndex={-1} />
{/each}
{:else}
{#each $currentTranscription.translations as translation, translationIndex}
{#if translation.targetLanguage == $language}
{#each translation.result.segments.slice(0, segmentsToShow) as segment, index (segment.id)}
<EditorSegment {segment} {index} {translationIndex} />
{/each}
{/if}
{/each}
{/if}
</tbody>
</table>
<button bind:this={loadMoreButton}>
{#if $language == 'original'}
{#if segmentsToShow >= $currentTranscription.result.segments.length}
No more segments to load
{:else}
Loading more...
{/if}
{:else if segmentsToShow >= $currentTranscription.translations.filter((translation) => translation.targetLanguage == $language)[0].result.segments.length}
No more segments to load
{:else}
Loading more...
{/if}
</button>
<!-- End Segments table -->
</div>
</div>
{/if}

View File

@@ -0,0 +1,354 @@
<script>
import { editorSettings } from '$lib/stores';
import { currentVideoPlayerTime, currentTranscription, editorHistory } from '$lib/stores';
export let index;
export let segment;
export let translationIndex;
let isActive = false;
function getCps(s) {
// Find duration
let duration = s.end - s.start;
// Count characters
let charCount = s.text.length;
// Calculate CPS
let cps = charCount / duration;
// Round to 2 decimals
cps = Math.round(cps * 100) / 100;
// Truncate to integer
return Math.trunc(cps);
}
function deleteSegment(index, callback) {
console.log($currentTranscription)
const source =
translationIndex == -1
? $currentTranscription.result.segments
: $currentTranscription.translations[translationIndex].result.segments;
source.splice(index, 1);
// Update index
$currentTranscription = { ...$currentTranscription }; // deep copy
callback();
}
// This function takes a segment and splits it into two segments at the given index
function splitSegment(index, callback) {
// Choose the correct source based on translationIndex
const source =
translationIndex == -1
? $currentTranscription.result.segments
: $currentTranscription.translations[translationIndex].result.segments;
const segment = source[index];
const words = segment.text.split(' ');
const half = Math.ceil(words.length / 2);
const firstHalf = words.slice(0, half).join(' ');
const secondHalf = words.slice(half).join(' ');
const duration = segment.end - segment.start;
const midTime = segment.start + duration / 2;
// Update current segment
segment.text = firstHalf;
segment.end = midTime;
// Create and insert new segment
const newSegment = {
id: JSON.stringify(Date.now()),
start: midTime,
end: segment.end + duration / 2,
text: secondHalf,
words: []
};
source.splice(index + 1, 0, newSegment);
$currentTranscription = { ...$currentTranscription };
callback();
}
// Text changes only save after 6 keystrokes
let keystrokes = 0;
function handleKeystrokes() {
keystrokes++;
if (keystrokes > 6) {
handleHistory();
keystrokes = 0;
}
}
// Save history on editing
function handleHistory() {
let currentT = JSON.parse(JSON.stringify($currentTranscription));
editorHistory.update((value) => {
return [...value, currentT];
});
}
function insertSegmentAbove(index, callback) {
const source =
translationIndex == -1
? $currentTranscription.result.segments
: $currentTranscription.translations[translationIndex].result.segments;
source.splice(index, 0, {
id: JSON.stringify(Date.now()),
start: 0,
end: 0,
text: '',
words: []
});
$currentTranscription = { ...$currentTranscription }; // deep copy
callback();
}
function insertSegmentBelow(index, callback) {
const source =
translationIndex == -1
? $currentTranscription.result.segments
: $currentTranscription.translations[translationIndex].result.segments;
source.splice(index + 1, 0, {
id: JSON.stringify(Date.now()),
start: 0,
end: 0,
text: '',
words: []
});
$currentTranscription = { ...$currentTranscription }; // deep copy
callback();
}
$: if (segment.start <= $currentVideoPlayerTime && $currentVideoPlayerTime <= segment.end) {
isActive = true;
} else {
isActive = false;
}
</script>
<tr class:bg-warning={isActive} class:bg-opacity-30={isActive} data-start={segment.start}>
<th>{index}</th>
<td class="space-x-2">
<!-- Start input -->
<input
class="w-20 input input-sm input-bordered"
type="number"
step="0.01"
bind:value={segment.start}
on:input={(e) => ($currentVideoPlayerTime.set(e.target.value))}
on:input={handleHistory}
on:click={(e) => {
if ($editorSettings.seekOnClick) $currentVideoPlayerTime = e.target.value;
}}
/>
<span class="tooltip" data-tip="Set to current time">
<button
on:click={() => {
segment.start = $currentVideoPlayerTime;
handleHistory();
}}
class="btn btn-xs btn-primary"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 stroke-white"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 7v5l2 2" />
<path d="M17 22l5 -3l-5 -3z" />
<path d="M13.017 20.943a9 9 0 1 1 7.831 -7.292" />
</svg>
</button>
</span>
</td>
<td class="space-x-2">
<!-- End input -->
<input
class="w-20 input input-sm input-bordered"
type="number"
step="0.01"
bind:value={segment.end}
on:input={(e) => ($currentVideoPlayerTime = e.target.value)}
on:input={handleHistory}
on:click={(e) => {
if ($editorSettings.seekOnClick) $currentVideoPlayerTime = e.target.value;
}}
/>
<span class="tooltip" data-tip="Set to current time">
<button
on:click={() => {
segment.end = $currentVideoPlayerTime;
handleHistory();
}}
class="btn btn-xs btn-primary"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 stroke-white"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 7v5l2 2" />
<path d="M17 22l5 -3l-5 -3z" />
<path d="M13.017 20.943a9 9 0 1 1 7.831 -7.292" />
</svg>
</button>
</span>
</td>
<td>
<!-- Text input -->
<div
bind:textContent={segment.text}
on:input={handleKeystrokes}
class="border-2 font-mono font-bold rounded-lg p-3 max-w-md bg-base-100"
class:border-error={getCps(segment) > 16}
contenteditable="true"
/>
</td>
<td>
<div>
<span class="flex flex-col text-xs flex-grow">
<span class:text-error={getCps(segment) > 16}>
<span class="whitespace-nowrap font-mono font-bold">
CPS: {getCps(segment)}
</span>
</span>
<span>
<span class="whitespace-nowrap font-mono font-bold">
Duration: {Math.round((segment.end - segment.start) * 100) / 100}s
</span>
</span>
</span>
</div>
</td>
<td
class="space-x-1 flex flex-col items-center justify-center space-y-2 md:space-y-0 align-middle lg:flex-row"
>
<!-- Add above -->
<span class="tooltip" data-tip="Insert Above">
<button
on:click={() => insertSegmentAbove(index, handleHistory)}
class="btn btn-primary btn-xs md:btn-sm btn-square"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M20 6v4a1 1 0 0 1 -1 1h-14a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1h14a1 1 0 0 1 1 1z"
/>
<path d="M12 15l0 4" />
<path d="M14 17l-4 0" />
</svg>
</button>
</span>
<!-- Add below -->
<span class="tooltip" data-tip="Insert Below">
<button
on:click={() => insertSegmentBelow(index, handleHistory)}
class="btn btn-primary btn-xs md:btn-sm btn-square"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M4 18v-4a1 1 0 0 1 1 -1h14a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-14a1 1 0 0 1 -1 -1z"
/>
<path d="M12 9v-4" />
<path d="M10 7l4 0" />
</svg>
</button>
</span>
<!-- Split -->
<span class="tooltip" data-tip="Split segment">
<button
on:click={() => splitSegment(index, handleHistory)}
class="btn btn-primary btn-xs md:btn-sm btn-square"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-arrows-split"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M21 17h-8l-3.5 -5h-6.5" />
<path d="M21 7h-8l-3.495 5" />
<path d="M18 10l3 -3l-3 -3" />
<path d="M18 20l3 -3l-3 -3" />
</svg>
</button>
</span>
<!-- Delete -->
<button
on:click={deleteSegment(index, handleHistory)}
class="btn btn-error btn-xs md:btn-sm btn-square"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M20 6a1 1 0 0 1 .117 1.993l-.117 .007h-.081l-.919 11a3 3 0 0 1 -2.824 2.995l-.176 .005h-8c-1.598 0 -2.904 -1.249 -2.992 -2.75l-.005 -.167l-.923 -11.083h-.08a1 1 0 0 1 -.117 -1.993l.117 -.007h16zm-9.489 5.14a1 1 0 0 0 -1.218 1.567l1.292 1.293l-1.292 1.293l-.083 .094a1 1 0 0 0 1.497 1.32l1.293 -1.292l1.293 1.292l.094 .083a1 1 0 0 0 1.32 -1.497l-1.292 -1.293l1.292 -1.293l.083 -.094a1 1 0 0 0 -1.497 -1.32l-1.293 1.292l-1.293 -1.292l-.094 -.083z"
stroke-width="0"
fill="currentColor"
/>
<path
d="M14 2a2 2 0 0 1 2 2a1 1 0 0 1 -1.993 .117l-.007 -.117h-4l-.007 .117a1 1 0 0 1 -1.993 -.117a2 2 0 0 1 1.85 -1.995l.15 -.005h4z"
stroke-width="0"
fill="currentColor"
/>
</svg>
</button>
</td>
</tr>

View File

@@ -0,0 +1,22 @@
<script>
import { editorSettings } from '$lib/stores';
</script>
<div class="flex flex-row items-center justify-center p-3 space-x-4 m-2">
<div
class="form-control tooltip"
data-tip="When clicking a timestamp input, seek the video to that position."
>
<label class="label cursor-pointer space-x-2">
<span class="label-text">Seek on click</span>
<input bind:checked={$editorSettings.seekOnClick} type="checkbox" class="checkbox checkbox-sm" />
</label>
</div>
<div class="form-control tooltip" data-tip="Automatically save changes every 30s.">
<label class="label cursor-pointer space-x-2">
<span class="label-text">Autosave</span>
<input bind:checked={$editorSettings.autoSave} type="checkbox" class="checkbox checkbox-sm" />
</label>
</div>
</div>

View File

@@ -0,0 +1,16 @@
<script>
import {deleteTranscription} from "$lib/utils.js";
export let tr;
</script>
<div class="alert alert-error p-3">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>
<p class="font-bold text-info-content text-md">id-{tr.id}</p>
<p class="font-mono text-info-content text-sm opacity-70">Failed: Could not transcribe.</p>
</span>
<div>
<button on:click={deleteTranscription(tr.id)} class="btn btn-sm">Delete</button>
</div>
</div>

View File

@@ -0,0 +1,161 @@
<script>
import {downloadSRT, downloadTXT, downloadJSON, downloadVTT, CLIENT_API_HOST} from '$lib/utils';
import toast from 'svelte-french-toast';
export let tr;
let subtitleFormat = "srt";
let language = "original";
function downloadSubtitle() {
console.log("download subtitle");
let segments = [];
let text = "";
console.log(language)
if (language == "original") {
segments = tr.result.segments;
text = tr.result.text;
} else {
for (const translation of tr.translations) {
if (translation.targetLanguage == language) {
segments = translation.result.segments;
text = translation.result.text;
break;
}
}
}
if (segments.length == 0 || text == "") {
toast.error("No data available for download");
return;
}
if (subtitleFormat == "srt") {
downloadSRT(segments);
} else if (subtitleFormat == "vtt") {
downloadVTT(segments);
} else if (subtitleFormat == "json") {
downloadJSON(tr.result);
} else if (subtitleFormat == "txt") {
downloadTXT(text);
}
}
function downloadMedia() {
let link = document.createElement('a');
link.href = `${CLIENT_API_HOST}/api/video/${tr.fileName}`;
link.target = "_blank";
link.download = tr.fileName.split('_WHSHPR_')[1]+".mp4";
link.click();
}
async function copyText() {
console.log("copy text");
let text = "";
if (language == "original") {
text = tr.result.text;
} else {
for (const translation of tr.translations) {
console.log(translation.targetLanguage == language)
if (translation.targetLanguage == language) {
text = translation.result.text;
break;
}
}
}
// Copy tr.result.text to clipboard
try {
await navigator.clipboard.writeText(text);
console.log('Text copied to clipboard');
toast.success('Text copied to clipboard');
} catch (err) {
console.error('Error in copying text: ', err);
}
}
</script>
<dialog id="modalDownloadOptions" class="modal">
<form method="dialog" class="modal-box flex flex-col items-center justify-center">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
{#if tr}
<h1 class="text-center font-bold mt-2 pb-2">Download Options</h1>
<div class="flex flex-row space-x-4">
<div class="form-control">
<label for="format" class="label">
<span class="label-text font-bold">File Format</span>
</label>
<select bind:value={subtitleFormat} name="format" class="select select-bordered w-full max-w-xs">
<option value="srt">SRT</option>
<option value="vtt">VTT</option>
<option value="json">JSON</option>
<option value="txt">TXT</option>
</select>
</div>
<div class="form-control">
<label for="language" class="label">
<span class="label-text font-bold">Text Language</span>
</label>
<select bind:value={language} name="language" class="select select-bordered w-full max-w-xs uppercase">
<option value="original">✅ {tr.result.language}</option>
{#each tr.translations as translation}
<option value="{translation.targetLanguage}">🤖 {translation.targetLanguage}</option>
{/each}
</select>
</div>
</div>
<div class="space-x-2 mt-8">
<span class="tooltip" data-tip="Download {subtitleFormat} file in {language}.">
<button on:click={downloadSubtitle} class="btn btn-sm btn-success">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-download" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2"></path>
<path d="M7 11l5 5l5 -5"></path>
<path d="M12 4l0 12"></path>
</svg>
<span>
File
</span>
</button>
</span>
<span class="tooltip" data-tip="Copy raw text in '{language}' language.">
<button on:click={copyText} class="btn btn-sm btn-info">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-copy" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"></path>
<path d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2"></path>
</svg>
<span>
Copy
</span>
</button>
</span>
<span class="tooltip" data-tip="Download source media">
<button on:click={downloadMedia} class="btn btn-sm btn-error">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-file-download" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M14 3v4a1 1 0 0 0 1 1h4"></path>
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z"></path>
<path d="M12 17v-6"></path>
<path d="M9.5 14.5l2.5 2.5l2.5 -2.5"></path>
</svg>
<span>
Media
</span>
</button>
</span>
</div>
{:else}
<div class="flex items-center justify-center w-screen h-screen">
<h1>
<span class="loading loading-bars loading-lg" />
</h1>
</div>
{/if}
</form>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>

View File

@@ -0,0 +1,183 @@
<script>
import { validateURL, CLIENT_API_HOST } from '$lib/utils.js';
import toast from 'svelte-french-toast';
let errorMessage = '';
let disableSubmit = true;
let modelSize = 'small';
let language = 'auto';
let sourceUrl = '';
let fileInput;
let languages = [
'auto',
'ar',
'be',
'bg',
'bn',
'ca',
'cs',
'cy',
'da',
'de',
'el',
'en',
'es',
'fr',
'it',
'jp',
'nl',
'pl',
'pt',
'ru',
'sk',
'sl',
'sv',
'tk',
'tr',
'zh'
];
let models = [
'tiny',
'tiny.en',
'base',
'base.en',
'small',
'small.en',
'medium',
'medium.en',
'large-v2'
];
// Sort the languages
languages.sort((a, b) => {
if (a == 'auto') return -1;
if (b == 'auto') return 1;
return a.localeCompare(b);
});
// Function that sends the data as a form to the backend
async function sendForm() {
if (sourceUrl && !validateURL(sourceUrl)) {
toast.error('You must enter a valid URL.');
return;
}
if (!sourceUrl && !fileInput) {
toast.error('No file or URL.');
return;
}
let formData = new FormData();
formData.append('language', language);
formData.append('modelSize', modelSize);
formData.append('sourceUrl', sourceUrl);
if (sourceUrl == '') {
formData.append('file', fileInput.files[0]);
}
const res = await fetch(`${CLIENT_API_HOST}/api/transcriptions`, {
method: 'POST',
body: formData
});
// Set file and sourceUrl to empty
sourceUrl = '';
fileInput.value = '';
toast.success('Success!');
}
// Reactive statement
$: if (sourceUrl && !validateURL(sourceUrl)) {
errorMessage = 'Enter a valid URL';
disableSubmit = true;
} else {
errorMessage = '';
disableSubmit = false;
}
</script>
<dialog id="modalNewTranscription" class="modal">
<form method="dialog" class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
{#if errorMessage != ''}
<div class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg
>
<span>{errorMessage}</span>
</div>
{/if}
<div class="space-y-2 mt-0">
<div class="form-control w-full max-w-xs">
<label for="file" class="label">
<span class="label-text">Pick a file</span>
</label>
<input
name="file"
bind:this={fileInput}
type="file"
class="file-input file-input-sm file-input-bordered file-input-primary w-full max-w-xs"
/>
</div>
<div class="form-control w-full max-w-xs">
<label for="sourceUrl" class="label">
<span class="label-text">Or a source URL</span>
</label>
<input
name="sourceUrl"
bind:value={sourceUrl}
type="text"
placeholder="https://youtube.com/watch?v=Hd33fCdW"
class="input input-sm input-bordered input-primary w-full max-w-xs"
/>
</div>
</div>
<div class="divider mb-0" />
<!-- Whisper Configuration -->
<div class="flex space-x-4">
<div class="form-control w-full max-w-xs">
<label for="modelSize" class="label">
<span class="label-text">Whisper model</span>
</label>
<select name="modelSize" bind:value={modelSize} class="select select-bordered">
{#each models as m}
<option value={m}>{m}</option>
{/each}
</select>
</div>
<div class="form-control w-full max-w-xs">
<label for="language" class="label">
<span class="label-text">Language</span>
</label>
<select name="language" bind:value={language} class="select select-bordered">
{#each languages as l}
<option value={l}>{l}</option>
{/each}
</select>
</div>
</div>
<div class="divider mb-0" />
<!--Actions-->
<button class="btn btn-wide btn-primary" on:click={sendForm} disabled={disableSubmit}
>Start</button
>
</form>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>

View File

@@ -0,0 +1,85 @@
<script>
import { onMount } from 'svelte';
import toast from 'svelte-french-toast';
import { CLIENT_API_HOST } from '$lib/utils';
import { PUBLIC_TRANSLATION_API_HOST } from '$env/static/public';
export let tr;
let targetLanguage = null;
let availableLanguages = [];
const getAvailableLangs = () => {
const fetchLanguages = () => {
fetch(`${PUBLIC_TRANSLATION_API_HOST}/languages`)
.then(res => res.json())
.then(data => {
if (data) {
availableLanguages = data;
console.log(availableLanguages)
// Languages fetched successfully, stop trying
clearInterval(fetchLanguagesInterval);
}
});
};
// Fetch languages repeatedly until successful
const fetchLanguagesInterval = setInterval(fetchLanguages, 5000);
fetchLanguages();
};
const handleTranslate = (id) => {
if(targetLanguage) {
const url = `${CLIENT_API_HOST}/api/translate/${id}/${targetLanguage}`;
fetch(url)
.then(() => toast.success('Translation started!'))
.catch(error => {
console.error(error);
toast.error('Error translating text!')
});
}
}
onMount(async () => {
await getAvailableLangs();
});
</script>
<dialog id="modalTranslation" class="modal">
<form method="dialog" class="modal-box flex flex-col items-center justify-center">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
{#if tr}
<h1 class="text-center font-bold mt-2 pb-2">
Translate
</h1>
<div>
<!-- Language picker -->
<div class="form-control w-full max-w-xs">
<label for="target-lan" class="label">
<span class="label-text">Target language</span>
</label>
<select bind:value={targetLanguage} name="target-lan" class="select select-bordered">
<option disabled selected>Pick one</option>
<!-- Iterate all available languages -->
{#each availableLanguages as lan}
<!-- When we find the source language -->
{#if lan.code == tr.result.language}
<!-- Iterate all possible target languages -->
{#each lan.targets as t}
{#if t != tr.result.language}
<option value="{t}">{t}</option>
{/if}
{/each}
{/if}
{/each}
</select>
</div>
<!-- End language picker-->
<button on:click={handleTranslate(tr.id)} class="btn btn-active btn-primary mt-5">Translate</button>
</div>
{/if}
</form>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>

View File

@@ -0,0 +1,11 @@
<script>
export let tr;
</script>
<div class="alert alert-info p-3">
<span class="loading loading-ring loading-md"></span>
<span>
<p class="font-bold text-info-content text-md">{tr.fileName != "" ? tr.fileName.split("_WHSHPR_")[1] : tr.id}</p>
<p class="font-mono text-info-content text-sm opacity-60">Waiting for transcription...</p>
</span>
</div>

View File

@@ -0,0 +1,25 @@
<script>
export let tr;
</script>
<div class="alert alert-info p-3">
<span class="loading loading-dots loading-md"></span>
<span>
<p class="font-bold text-info-content text-md flex items-center justify-center space-x-2">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-language-hiragana" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 5h7"></path>
<path d="M7 4c0 4.846 0 7 .5 8"></path>
<path d="M10 8.5c0 2.286 -2 4.5 -3.5 4.5s-2.5 -1.135 -2.5 -2c0 -2 1 -3 3 -3s5 .57 5 2.857c0 1.524 -.667 2.571 -2 3.143"></path>
<path d="M12 20l4 -9l4 9"></path>
<path d="M19.1 18h-6.2"></path>
</svg>
<span>{tr.fileName != "" ? tr.fileName.split("_WHSHPR_")[1] : tr.id}</span>
</p>
<p class="opacity-60">
<span class="font-mono text-info-content text-sm ">
Waiting for translation...
</span>
</p>
</span>
</div>

View File

@@ -0,0 +1,75 @@
<!-- SuccessTranscription.svelte -->
<script>
import {createEventDispatcher} from 'svelte';
import {deleteTranscription} from "$lib/utils.js";
export let tr;
const dispatch = createEventDispatcher();
let download = () => {
dispatch('download', tr); // emit a custom event with the transcription as detail
}
let translate = () => {
dispatch('translate', tr); // emit a custom event with the transcription as detail
}
</script>
<div class="alert alert-success p-3">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>
<p class="font-bold text-info-content text-md">{tr.fileName.split("_WHSHPR_")[1]}</p>
<p class="font-mono text-info-content text-sm opacity-60 flex space-x-2 md:space-x-4 lg:space-x-8">
<span class="space-x-1">
<span class="font-bold text-xs">~{Math.round(tr.result.duration)}s long</span>
</span>
<span class="space-x-1">
<span class="font-bold text-xs">{tr.translations.length} translations</span>
</span>
<span class="space-x-1">
<span class="font-bold text-xs">{tr.result.text.split(" ").length} words</span>
</span>
</p>
</span>
<div class="flex items-center justify-center flex-wrap space-x-2">
<a href="/editor/{tr.id}" class="btn btn-xs md:btn-sm">
<span class="tooltip flex items-center justify-center" data-tip="Edit">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 15l8.385 -8.415a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3z"></path>
<path d="M16 5l3 3"></path>
<path d="M9 7.07a7 7 0 0 0 1 13.93a7 7 0 0 0 6.929 -6"></path>
</svg>
</span>
</a>
<button on:click={download} onclick="modalDownloadOptions.showModal()" class="btn btn-xs md:btn-sm">
<span class="tooltip flex items-center justify-center" data-tip="Download">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2"></path>
<path d="M7 11l5 5l5 -5"></path>
<path d="M12 4l0 12"></path>
</svg>
</span>
</button>
<button on:click={translate} class="btn btn-xs md:btn-sm btn-primary">
<span class="tooltip flex items-center justify-center" data-tip="Translate">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 5h7"></path>
<path d="M7 4c0 4.846 0 7 .5 8"></path>
<path d="M10 8.5c0 2.286 -2 4.5 -3.5 4.5s-2.5 -1.135 -2.5 -2c0 -2 1 -3 3 -3s5 .57 5 2.857c0 1.524 -.667 2.571 -2 3.143"></path>
<path d="M12 20l4 -9l4 9"></path>
<path d="M19.1 18h-6.2"></path>
</svg>
</span>
</button>
<button on:click={deleteTranscription(tr.id)} class="btn btn-xs md:btn-sm btn-error">
<span class="tooltip flex items-center justify-center" data-tip="Delete">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M20 6a1 1 0 0 1 .117 1.993l-.117 .007h-.081l-.919 11a3 3 0 0 1 -2.824 2.995l-.176 .005h-8c-1.598 0 -2.904 -1.249 -2.992 -2.75l-.005 -.167l-.923 -11.083h-.08a1 1 0 0 1 -.117 -1.993l.117 -.007h16zm-9.489 5.14a1 1 0 0 0 -1.218 1.567l1.292 1.293l-1.292 1.293l-.083 .094a1 1 0 0 0 1.497 1.32l1.293 -1.292l1.293 1.292l.094 .083a1 1 0 0 0 1.32 -1.497l-1.292 -1.293l1.292 -1.293l.083 -.094a1 1 0 0 0 -1.497 -1.32l-1.293 1.292l-1.293 -1.292l-.094 -.083z" stroke-width="0" fill="currentColor"></path>
<path d="M14 2a2 2 0 0 1 2 2a1 1 0 0 1 -1.993 .117l-.007 -.117h-4l-.007 .117a1 1 0 0 1 -1.993 -.117a2 2 0 0 1 1.85 -1.995l.15 -.005h4z" stroke-width="0" fill="currentColor"></path>
</svg>
</span>
</button>
</div>
</div>

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,25 @@
import { writable } from 'svelte/store';
// contains all transcriptions
export const transcriptions = writable([]);
// contains the current editing transcription
export const currentTranscription = writable();
// transcription to be downloaded
export const downloadTranscription = writable(null);
// Editor history
export const editorHistory = writable([]);
// editor settings
export let editorSettings = writable({
autosave: false,
autosaveInterval: 30000,
autosaveNotify: true,
seekOnClick: true,
});
// Video player settings
export const currentVideoPlayerTime = writable(0);

30
frontend/src/lib/types.ts Normal file
View File

@@ -0,0 +1,30 @@
export type Transcription = {
language: string;
duration: number;
segments: Segment[];
id: string;
translations: Translation[];
}
export type Segment = {
start: number;
end: number;
text: string;
score: number;
uuid: string;
words?: WordData[];
};
type WordData = {
start: number;
end: number;
text: string;
score: number;
};
type Translation = {
sourceLanguage: string;
targetLanguage: string;
text: string;
segments: Segment[];
};

120
frontend/src/lib/utils.js Normal file
View File

@@ -0,0 +1,120 @@
import {transcriptions} from './stores';
import { PUBLIC_API_HOST } from '$env/static/public';
import { dev } from '$app/environment';
export const CLIENT_API_HOST = `${dev ? PUBLIC_API_HOST : ""}`;
export const CLIENT_WS_HOST = `${dev ? PUBLIC_API_HOST.replace("http://", "").replace("https://", "") : ""}`;
// URL Validator
export const validateURL = function (url) {
try {
new URL(url);
return true;
} catch (e) {
return false;
}
}
export const deleteTranscription = async function (id) {
const res = await fetch(`${CLIENT_API_HOST}/api/transcriptions/${id}`, {
method: "DELETE"
});
if (res.ok) {
transcriptions.update((_transcriptions) => _transcriptions.filter(t => t.id !== id));
}
}
export const getRandomSentence = function () {
const sentences = [
"Audio in, text out. What's your sound about?",
"Drop the beat, I'll drop the text!",
"Everybody knows the bird is the word!",
"From soundcheck to spellcheck!",
"I got 99 problems but transcribing ain't one!",
"I'm all ears!",
"iTranscribe, you dictate!",
"Lost for words?",
"Sound check 1, 2, 3...",
"Sound's up! What's your script?",
"Transcribe, transcribe, transcribe!",
"What are you transcribing today?",
"What's the story, morning wordy?",
"Words, don't come easy, but I can help find the way.",
"You speak, I write. It's no magic, just AI!",
"Can't understand that language? I can translate!",
"I mean every word I say!"
]
const randomSentence = sentences[Math.floor(Math.random() * sentences.length)];
return randomSentence;
}
// Expects a segments array with start, end and text properties
export const downloadSRT = function (jsonData) {
let srtContent = '';
jsonData.forEach((segment, index) => {
let startSeconds = Math.floor(segment.start);
let startMillis = Math.floor((segment.start - startSeconds) * 1000);
let start = new Date(startSeconds * 1000 + startMillis).toISOString().substr(11, 12);
let endSeconds = Math.floor(segment.end);
let endMillis = Math.floor((segment.end - endSeconds) * 1000);
let end = new Date(endSeconds * 1000 + endMillis).toISOString().substr(11, 12);
srtContent += `${index + 1}\n${start} --> ${end}\n${segment.text}\n\n`;
});
let srtBlob = new Blob([srtContent], {type: 'text/plain'});
let url = URL.createObjectURL(srtBlob);
let link = document.createElement('a');
link.href = url;
link.download = 'subtitles.srt';
link.click();
}
// Downloads received text as a TXT file
export const downloadTXT = function (text) {
let srtBlob = new Blob([text], {type: 'text/plain'});
let url = URL.createObjectURL(srtBlob);
let link = document.createElement('a');
link.href = url;
link.download = 'subtitles.txt';
link.click();
}
// Downloads received JSON data as a JSON file
export const downloadJSON = function (jsonData) {
let srtBlob = new Blob([JSON.stringify(jsonData)], {type: 'text/plain'});
let url = URL.createObjectURL(srtBlob);
let link = document.createElement('a');
link.href = url;
link.download = 'subtitles.json';
link.click();
}
// Expects a segments array with start, end and text properties
export const downloadVTT = function (jsonData) {
let vttContent = 'WEBVTT\n\n'; // VTT files start with "WEBVTT" line
jsonData.forEach((segment, index) => {
let startSeconds = Math.floor(segment.start);
let startMillis = Math.floor((segment.start - startSeconds) * 1000);
let start = new Date(startSeconds * 1000 + startMillis).toISOString().substr(11, 12).replace('.', ',');
let endSeconds = Math.floor(segment.end);
let endMillis = Math.floor((segment.end - endSeconds) * 1000);
let end = new Date(endSeconds * 1000 + endMillis).toISOString().substr(11, 12).replace('.', ',');
vttContent += `${index + 1}\n${start} --> ${end}\n- ${segment.text}\n\n`;
});
let vttBlob = new Blob([vttContent], {type: 'text/plain'});
let url = URL.createObjectURL(vttBlob);
let link = document.createElement('a');
link.href = url;
link.download = 'subtitles.vtt';
link.click();
}

View File

@@ -0,0 +1,20 @@
import { transcriptions } from '$lib/stores';
import { browser, dev } from '$app/environment';
import { PUBLIC_INTERNAL_API_HOST } from '$env/static/public';
import { CLIENT_API_HOST } from '$lib/utils';
/** @type {import('./$types').PageLoad} */
export async function load({ fetch }) {
// Use different endpoints for server-side and client-side
const endpoint = browser ? `${CLIENT_API_HOST}/api/transcriptions` : `${PUBLIC_INTERNAL_API_HOST}/api/transcriptions`;
console.log(endpoint);
const response = await fetch(endpoint);
const ts = await response.json();
if (ts) {
transcriptions.update(_ => ts.length > 0 ? ts : []);
} else {
transcriptions.update(_ => []);
}
}

View File

@@ -0,0 +1,5 @@
<script>
import "../app.css";
</script>
<slot />

View File

@@ -0,0 +1,11 @@
/** @type {import('./$types').PageLoad} */
import {getRandomSentence} from "$lib/utils";
export async function load() {
const randomSentence = getRandomSentence();
return {
randomSentence: randomSentence
};
};

View File

@@ -0,0 +1,126 @@
<script>
import { Toaster } from 'svelte-french-toast';
import { transcriptions } from '$lib/stores';
import { browser, dev } from '$app/environment';
import { CLIENT_WS_HOST } from '$lib/utils';
import { onMount, onDestroy } from 'svelte';
import ModalTranscriptionForm from '$lib/components/ModalTranscriptionForm.svelte';
import ModalDownloadOptions from '$lib/components/ModalDownloadOptions.svelte';
import ModalTranslationForm from '$lib/components/ModalTranslationForm.svelte';
import SuccessTranscription from '$lib/components/SuccessTranscription.svelte';
import PendingTranscription from '$lib/components/PendingTranscription.svelte';
import PendingTranslation from '$lib/components/PendingTranslation.svelte';
import ErrorTranscription from '$lib/components/ErrorTranscription.svelte';
//export let data;
let socket;
export let data;
function connect() {
if (!browser) {
console.log('Server, not connecting');
return;
}
let new_uri = '';
var loc = window.location;
if (loc.protocol === 'https:') {
new_uri = 'wss:';
} else {
new_uri = 'ws:';
}
new_uri += '//' + (CLIENT_WS_HOST == '' ? loc.host : CLIENT_WS_HOST);
new_uri += '/ws/transcriptions';
console.log('Connecting to: ', new_uri);
socket = new WebSocket(new_uri);
socket.onopen = () => console.log('WebSocket is connected...');
socket.onerror = (error) => console.log('WebSocket Error: ', error);
socket.onclose = (event) => {
console.log('WebSocket is closed with code: ', event.code, ' and reason: ', event.reason);
setTimeout(() => {
console.log('Reconnecting...');
connect();
}, 1000);
};
socket.onmessage = (event) => {
let update = JSON.parse(event.data);
// use update to update the store
transcriptions.update(transcriptions => {
let index = transcriptions.findIndex(tr => tr.id === update.id);
if (index >= 0) {
// replace the item at index
transcriptions[index] = update;
} else {
// add the new item
transcriptions.push(update);
}
return transcriptions; // return a new object to trigger reactivity
});
};
}
onMount(() => {
connect();
});
let downloadTranscription = null;
let handleDownload = (event) => {
downloadTranscription = event.detail; // this will be the transcription to download
modalDownloadOptions.showModal(); // show the modal
};
let translateTranscription = null;
let handleTranslate = (event) => {
translateTranscription = event.detail; // this will be the transcription to translate
modalTranslation.showModal(); // show the modal
};
onDestroy(() => {
if (socket) {
socket.close(1000);
}
});
</script>
<Toaster />
<ModalDownloadOptions tr={downloadTranscription} />
<ModalTranslationForm tr={translateTranscription} />
<ModalTranscriptionForm />
<header>
<h1 class="flex items-center justify-center space-x-4 font-bold text-4xl mt-8">
<span>
<img class="w-20 h-20" src="/logo.svg" alt="Logo: a cloud whispering" />
</span>
<span> Whishper </span>
</h1>
<h2 class="text-md text-center opacity-70 font-mono">{data.randomSentence}</h2>
</header>
<main class="card w-4/6 bg-neutral text-neutral-content mx-auto mt-4 mb-8">
<button
class="btn btn-primary mx-auto max-w-md mt-8 btn-md"
onclick="modalNewTranscription.showModal()">✨ new transcription</button
>
<div class="card-body items-center text-center mb-0">
{#if $transcriptions.length > 0}
{#each $transcriptions as tr (tr.id)}
{#if tr.status == 2}
<SuccessTranscription {tr} on:download={handleDownload} on:translate={handleTranslate} />
{/if}
{#if tr.status < 2 && tr.status >= 0}
<PendingTranscription {tr} />
{/if}
{#if tr.status == 3}
<PendingTranslation {tr} />
{/if}
{#if tr.status < 0}
<ErrorTranscription {tr} />
{/if}
{/each}
{:else}
<p class="text-center font-bold text-2xl">🔮 No transcriptions yet 🔮</p>
{/if}
</div>
</main>

View File

@@ -0,0 +1,17 @@
/** @type {import('./$types').PageLoad} */
import { currentTranscription } from '$lib/stores';
import { PUBLIC_INTERNAL_API_HOST } from '$env/static/public';
import { CLIENT_API_HOST } from '$lib/utils';
import { browser } from '$app/environment';
export async function load({params}) {
// Fetch json from /api/transcriptions/{id}
let id = params.id;
// Use different endpoints for server-side and client-side
const endpoint = browser ? `${CLIENT_API_HOST}/api/transcriptions` : `${PUBLIC_INTERNAL_API_HOST}/api/transcriptions`;
const response = await fetch(`${endpoint}/${id}`);
const ts = await response.json();
// Set currentTranscription to the fetched transcription
currentTranscription.set(ts);
};

View File

@@ -0,0 +1,45 @@
<script>
/** @type {import('./$types').PageData} */
import toast, { Toaster } from 'svelte-french-toast';
import Editor from '$lib/components/Editor.svelte';
import {currentVideoPlayerTime, currentTranscription} from '$lib/stores';
import { CLIENT_API_HOST } from '$lib/utils';
let video;
let tolerance = 0.1; // Tolerance level in seconds
let canPlay = false;
$: if(canPlay && video && Math.abs(video.currentTime - $currentVideoPlayerTime) > tolerance) {
console.log(video.currentTime, $currentVideoPlayerTime)
video.currentTime = $currentVideoPlayerTime;
}
</script>
<Toaster />
{#if $currentTranscription}
<div class="grid grid-cols-3 h-screen overflow-hidden">
<div class="col-span-1 bg-transparent overflow-hidden">
<div class="h-full w-full relative">
<video id="video"
controls
bind:this={video}
on:timeupdate={(e) => $currentVideoPlayerTime.set(e.target.currentTime)}
on:canplay={() => canPlay = true}
on:loadedmetadata={() => canPlay = true}
class="absolute top-0 left-0 h-full w-full">
<source src="{CLIENT_API_HOST}/api/video/{$currentTranscription.fileName}" type="video/mp4" />
<track kind="captions" />
</video>
</div>
</div>
<div class="col-span-2 bg-content overflow-auto">
<Editor />
</div>
</div>
{:else}
<div class="flex items-center justify-center w-screen h-screen">
<h1>
<span class="loading loading-bars loading-lg" />
</h1>
</div>
{/if}

BIN
frontend/static/30sec.webm Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

19
frontend/static/logo.svg Normal file
View File

@@ -0,0 +1,19 @@
<svg id="emoji" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<g id="color">
<path fill="#d0cfce" stroke="none" d="M14.5968,29.9075c-0.0396,0.4525-0.4062,0.8186-0.8602,0.8673C8.233,31.3643,4,36.7833,4,43.3794 C4,50.3693,8.8475,56.751,14.8272,56.751h41.2775c1.3997-0.037,2.5589-0.1579,3.7218-0.551C63.0257,54.6289,68,49.4069,68,42.6607 c0-7.079-4.8912-12.8736-11.0809-13.3441c-0.4735-0.036-0.8576-0.3674-0.9479-0.8313 c-1.3827-7.1005-7.7614-12.4363-15.3028-12.4363c-4.9012,0-9.2671,2.2189-12.1023,5.7487c-0.277,0.3449-0.742,0.503-1.151,0.3315 c-1.091-0.4575-2.2432-0.6789-3.5324-0.6789C19.0109,21.4502,15.012,25.1682,14.5968,29.9075z"/>
</g>
<g id="hair"/>
<g id="skin"/>
<g id="skin-shadow"/>
<g id="line">
<path fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M59.4442,55.4591C64.3882,53.8362,68,48.7016,68,42.6183c0-7.079-4.8912-12.8736-11.0809-13.3441 c-0.4735-0.036-0.8576-0.3674-0.9479-0.8313c-1.3827-7.1005-7.7614-12.4363-15.3028-12.4363 c-4.9012,0-9.2671,2.2189-12.1023,5.7487c-0.277,0.3449-0.742,0.503-1.151,0.3315c-1.091-0.4575-2.2432-0.6789-3.5324-0.6789 c-4.8718,0-8.8707,3.718-9.2859,8.4573c-0.0396,0.4525-0.4062,0.8186-0.8602,0.8673C8.233,31.322,4,36.741,4,43.3371 c0,6.9899,4.8475,12.6562,10.8272,12.6562l36.2187,0"/>
<path fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M31.686,32.062 c-1.2083,0.9992-2.7458,1.5119-4.312,1.438c-1.5664,0.0745-3.1043-0.4379-4.313-1.437"/>
<path fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M48.939,32.062 c-1.2083,0.9992-2.7458,1.5119-4.312,1.438c-1.5664,0.0745-3.1043-0.4379-4.313-1.437"/>
<path fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M39.6114,45.3333c1.6569,0,3-1.1379,3-2.5417c0-1.0528-0.7555-1.9561-1.8323-2.3419c-0.3589-0.1286-0.7535-0.1997-1.1677-0.1997"/>
<path fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M39.1843,50.4062c0.9401,0,2.0159-0.5708,2.3309-1.472c0.5041-1.4419-0.3552-3.0541-1.9192-3.6009"/>
<path fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M62.3636,45.5589c2.1545-3.308,2.0114-7.9339-1.9915-9.7984c-1.7893-0.8334-3.8681-0.6097-4.9454,1.0444 c-0.5386,0.827-0.3048,1.9341,0.5222,2.4727"/>
<path fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M46.0899,46.5506c4.7602,2.7667,12.0053,5.3518,16.2737-0.9917"/>
<path fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M55.955,59.826c-0.2166,1.9335-1.9589,3.369-3.8924,3.1524c-1.6258-0.1821-2.8687-1.4434-3.0943-2.9898"/>
<path fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M46.0899,46.5506c0,0,10.7036,5.7177,9.8651,13.2754"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

11
frontend/svelte.config.js Normal file
View File

@@ -0,0 +1,11 @@
//import adapter from '@sveltejs/adapter-auto';
import adapterNode from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapterNode()
},
preprocess: vitePreprocess()
};
export default config;

View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {}
},
plugins: [require("daisyui")],
};

6
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});

1804
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff