Initial commit
This commit is contained in:
8
frontend/.dockerignore
Normal file
8
frontend/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
node_modules/
|
||||
.svelte-kit
|
||||
Dockerfile
|
||||
.eslintignore
|
||||
.eslintrc.js
|
||||
.prettierrc
|
||||
.gitignore
|
||||
13
frontend/.eslintignore
Normal file
13
frontend/.eslintignore
Normal 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
14
frontend/.eslintrc.cjs
Normal 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
10
frontend/.gitignore
vendored
Normal 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
2
frontend/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
engine-strict=true
|
||||
resolution-mode=highest
|
||||
13
frontend/.prettierignore
Normal file
13
frontend/.prettierignore
Normal 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
9
frontend/.prettierrc
Normal 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
34
frontend/Dockerfile
Normal 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
38
frontend/README.md
Normal 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
32
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
3
frontend/src/app.css
Normal file
3
frontend/src/app.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
13
frontend/src/app.html
Normal file
13
frontend/src/app.html
Normal 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>
|
||||
282
frontend/src/lib/components/Editor.svelte
Normal file
282
frontend/src/lib/components/Editor.svelte
Normal 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}
|
||||
354
frontend/src/lib/components/EditorSegment.svelte
Normal file
354
frontend/src/lib/components/EditorSegment.svelte
Normal 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>
|
||||
22
frontend/src/lib/components/EditorSettings.svelte
Normal file
22
frontend/src/lib/components/EditorSettings.svelte
Normal 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>
|
||||
16
frontend/src/lib/components/ErrorTranscription.svelte
Normal file
16
frontend/src/lib/components/ErrorTranscription.svelte
Normal 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>
|
||||
161
frontend/src/lib/components/ModalDownloadOptions.svelte
Normal file
161
frontend/src/lib/components/ModalDownloadOptions.svelte
Normal 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>
|
||||
183
frontend/src/lib/components/ModalTranscriptionForm.svelte
Normal file
183
frontend/src/lib/components/ModalTranscriptionForm.svelte
Normal 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>
|
||||
85
frontend/src/lib/components/ModalTranslationForm.svelte
Normal file
85
frontend/src/lib/components/ModalTranslationForm.svelte
Normal 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>
|
||||
11
frontend/src/lib/components/PendingTranscription.svelte
Normal file
11
frontend/src/lib/components/PendingTranscription.svelte
Normal 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>
|
||||
25
frontend/src/lib/components/PendingTranslation.svelte
Normal file
25
frontend/src/lib/components/PendingTranslation.svelte
Normal 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>
|
||||
75
frontend/src/lib/components/SuccessTranscription.svelte
Normal file
75
frontend/src/lib/components/SuccessTranscription.svelte
Normal 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>
|
||||
1
frontend/src/lib/index.js
Normal file
1
frontend/src/lib/index.js
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
25
frontend/src/lib/stores.js
Normal file
25
frontend/src/lib/stores.js
Normal 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
30
frontend/src/lib/types.ts
Normal 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
120
frontend/src/lib/utils.js
Normal 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();
|
||||
}
|
||||
|
||||
20
frontend/src/routes/+layout.js
Normal file
20
frontend/src/routes/+layout.js
Normal 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(_ => []);
|
||||
}
|
||||
}
|
||||
5
frontend/src/routes/+layout.svelte
Normal file
5
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<script>
|
||||
import "../app.css";
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
11
frontend/src/routes/+page.js
Normal file
11
frontend/src/routes/+page.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('./$types').PageLoad} */
|
||||
import {getRandomSentence} from "$lib/utils";
|
||||
|
||||
|
||||
export async function load() {
|
||||
const randomSentence = getRandomSentence();
|
||||
|
||||
return {
|
||||
randomSentence: randomSentence
|
||||
};
|
||||
};
|
||||
126
frontend/src/routes/+page.svelte
Normal file
126
frontend/src/routes/+page.svelte
Normal 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>
|
||||
17
frontend/src/routes/editor/[id]/+page.js
Normal file
17
frontend/src/routes/editor/[id]/+page.js
Normal 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);
|
||||
};
|
||||
45
frontend/src/routes/editor/[id]/+page.svelte
Normal file
45
frontend/src/routes/editor/[id]/+page.svelte
Normal 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
BIN
frontend/static/30sec.webm
Normal file
Binary file not shown.
BIN
frontend/static/favicon.webp
Normal file
BIN
frontend/static/favicon.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
19
frontend/static/logo.svg
Normal file
19
frontend/static/logo.svg
Normal 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 |
BIN
frontend/static/somevideo.webm
Normal file
BIN
frontend/static/somevideo.webm
Normal file
Binary file not shown.
11
frontend/svelte.config.js
Normal file
11
frontend/svelte.config.js
Normal 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;
|
||||
8
frontend/tailwind.config.js
Normal file
8
frontend/tailwind.config.js
Normal 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
6
frontend/vite.config.js
Normal 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
1804
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user