feat: whisper STT and record screen (#1363)

This commit is contained in:
Markos Gogoulos
2025-09-01 15:11:38 +03:00
committed by GitHub
parent 8cbeb72dd2
commit 817e16ac60
52 changed files with 1179 additions and 339 deletions

View File

@@ -1,7 +1,10 @@
{% extends "base.html" %}
{% load i18n %}
{% load static %}
{% block headtitle %}Add new media - {{PORTAL_NAME}}{% endblock headtitle %}
{% load custom_filters %}
{% block externallinks %}
{% if LOAD_FROM_CDN %}
@@ -19,6 +22,8 @@
{%endblock topimports %}
{% block innercontent %}
{% get_current_language as LANGUAGE_CODE %}
{% if request.user.is_authenticated %}
{% if can_add %}
@@ -26,7 +31,7 @@
<div class="media-uploader-wrap">
<div class="media-uploader-top-wrap">
<div class="media-uploader-top-left-wrap">
<h1>Upload media files</h1>
<h1>{{ "Upload media" | custom_translate:LANGUAGE_CODE}}</h1>
</div>
<div class="media-uploader-top-right-wrap"> </div>
</div>
@@ -38,16 +43,19 @@
<div class="media-drag-drop-content">
<div class="media-drag-drop-content-inner">
<span><i class="material-icons">cloud_upload</i></span>
<span>Drag and drop files</span>
<span>or</span>
<span>{{ "Drag and drop files" | custom_translate:LANGUAGE_CODE}}</span>
<span>{{ "or" | custom_translate:LANGUAGE_CODE}}</span>
<span class="browse-files-btn-wrap">
<span class="qq-upload-button-selector">Browse your files</span>
<span class="qq-upload-button-selector">{{ "Browse your files" | custom_translate:LANGUAGE_CODE}}</span>
</span>
<div class="qq-upload-drop-area-selector media-dropzone" qq-hide-dropzone>
<span class="qq-upload-drop-area-text-selector"></span>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -76,7 +84,7 @@
<span class="filename-edit qq-edit-filename-icon-selector" aria-label="Edit filename">Edit filename <i class="material-icons">create</i></span>
<button type="button" class="delete-media-upload-item qq-upload-delete-selector" aria-label="Delete">Delete <i class="material-icons">delete</i></button>
<button type="button" class="cancel-media-upload-item qq-upload-cancel-selector" aria-label="Cancel">Cancel <i class="material-icons">cancel</i></button>
<a href="#" class="view-uploaded-media-link qq-hide" target="_blank">View media <i class="material-icons">open_in_new</i></a>
<a href="#" class="view-uploaded-media-link qq-hide" target="_blank">{{ "View media" | custom_translate:LANGUAGE_CODE}}<i class="material-icons">open_in_new</i></a>
</div>
<div class="media-upload-item-bottom-actions">
<button type="button" class="continue-media-upload-item qq-upload-continue-selector" aria-label="Continue"><i class="material-icons">play_circle_outline</i> Continue</button>

View File

@@ -1,29 +1,49 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% load static %}
{% block headtitle %}Add subtitle - {{PORTAL_NAME}}{% endblock headtitle %}
{% block innercontent %}
{% include "cms/media_nav.html" with active_tab="subtitles" %}
<div class="user-action-form-wrap">
<div class="user-action-form-inner">
<h1>Add subtitle</h1>
Media: <a href="{{media.get_absolute_url}}">{{media.title}}</a>
<form enctype="multipart/form-data" action="" method="post" class="post-form">
{% csrf_token %}
{{ form|crispy }}
<button class="primaryAction" type="submit">Add</button>
{% crispy form %}
</form>
</div>
</div>
{% if subtitles %}
<h3>View/Edit Existing Subtitles</h3>
{% if subtitles %}
<div class="user-action-form-wrap">
<div class="user-action-form-inner">
<h3>Existing Subtitles</h3>
<ul>
{% for subtitle in subtitles %}
<li><a href="{{subtitle.url}}">{{subtitle.language.title}}</a></li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
</div>
{% endif %}
{% if whisper_form %}
<div class="user-action-form-wrap">
<div class="user-action-form-inner">
<h3 style="display: flex; align-items: center; gap: 0.25rem;">Request Automatic Tranascription
<span title="This is Automatic Transcription using a Whisper model that is loaded locally" style="cursor: help;">
<i class="material-icons">info_outline</i>
</span>
</h3>
<form enctype="multipart/form-data" action="" method="post" class="post-form">
{% csrf_token %}
{% crispy whisper_form %}
</form>
</div>
</div>
{% endif %}
{% endblock innercontent %}

View File

@@ -1,17 +1,28 @@
{% load custom_filters %}
{% load i18n %}
{% get_current_language as LANGUAGE_CODE %}
<div class="media-edit-nav" style="background-color: #f0f0f0; padding: 10px 0; margin-bottom: 20px;">
<ul style="list-style: none; display: flex; justify-content: space-around; margin: 0; padding: 0;">
<li style="display: inline-block;">
<a href="{% url 'edit_media' %}?m={{media_object.friendly_token}}"
style="text-decoration: none; {% if active_tab == 'metadata' %}font-weight: bold; color: #333; padding-bottom: 3px; border-bottom: 2px solid #333;{% else %}color: #666;{% endif %}">
Metadata
{{ "Metadata" | custom_translate:LANGUAGE_CODE}}
</a>
</li>
{% if media_object.media_type == 'video' %}
<li style="display: inline-block;">
<a href="{% url 'add_subtitle' %}?m={{media_object.friendly_token}}"
style="text-decoration: none; {% if active_tab == 'subtitles' %}font-weight: bold; color: #333; padding-bottom: 3px; border-bottom: 2px solid #333;{% else %}color: #666;{% endif %}">
{{ "Subtitles" | custom_translate:LANGUAGE_CODE}}
</a>
</li>
<li style="display: inline-block;">
<a href="{% url 'edit_video' %}?m={{media_object.friendly_token}}"
style="text-decoration: none; {% if active_tab == 'trim' %}font-weight: bold; color: #333; padding-bottom: 3px; border-bottom: 2px solid #333;{% else %}color: #666;{% endif %}">
Trim
{{ "Trim" | custom_translate:LANGUAGE_CODE}}
</a>
</li>
{% comment %}
@@ -19,7 +30,7 @@
<a href="{% url 'edit_chapters' %}?m={{media_object.friendly_token}}"
style="text-decoration: none; {% if active_tab == 'chapters' %}font-weight: bold; color: #333; padding-bottom: 3px; border-bottom: 2px solid #333;{% else %}color: #666;{% endif %}">
Chapters
{{ "Chapters" | custom_translate:LANGUAGE_CODE}}
</a>
</li>
{% endcomment %}
@@ -27,7 +38,7 @@
<li style="display: inline-block;">
<a href="{% url 'publish_media' %}?m={{media_object.friendly_token}}"
style="text-decoration: none; {% if active_tab == 'publish' %}font-weight: bold; color: #333; padding-bottom: 3px; border-bottom: 2px solid #333;{% else %}color: #666;{% endif %}">
Publish
{{ "Publish" | custom_translate:LANGUAGE_CODE}}
</a>
</li>
</ul>

View File

@@ -0,0 +1,216 @@
{% extends "base.html" %}
{% load i18n %}
{% block headtitle %}Record Screen - {{PORTAL_NAME}}{% endblock headtitle %}
{% load custom_filters %}
{% block innercontent %}
{% get_current_language as LANGUAGE_CODE %}
{% if can_add %}
<div class="custom-page-wrapper">
<h2>{{ "Record Screen" | custom_translate:LANGUAGE_CODE}}</h2>
<hr/>
<div style="text-align: center; padding: 40px 0;">
<p style="margin-bottom: 20px;">{{ "Click 'Start Recording' and select the screen or tab to record. Once recording is finished, click 'Stop Recording,' and the recording will be uploaded." | custom_translate:LANGUAGE_CODE}}</p>
<button id="startBtn" class="qq-upload-button-selector" style="padding: 10px 20px; font-size: 16px; margin-right: 10px; cursor: pointer;">{{ "Start Recording" | custom_translate:LANGUAGE_CODE}}</button>
<button id="stopBtn" class="qq-upload-button-selector" disabled style="padding: 10px 20px; font-size: 16px; cursor: pointer;">{{ "Stop Recording" | custom_translate:LANGUAGE_CODE}}</button>
<div id="spinner" style="display: none; margin-top: 20px;">
<div class="spinner"></div>
<p>video is getting uploaded</p>
</div>
</div>
</div>
<style>
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
width: 36px;
height: 36px;
border-radius: 50%;
border-left-color: #09f;
animation: spin 1s ease infinite;
margin: 10px auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<script>
document.addEventListener("DOMContentLoaded", function(event) {
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const spinner = document.getElementById('spinner');
function isMobileDevice() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
if (isMobileDevice()) {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
alert('Camera recording is not supported in your browser.');
startBtn.disabled = true;
return;
}
document.querySelector('h2').textContent = 'Record Video';
document.querySelector('p').textContent = 'Click \'Start Recording\' to start recording from your camera. Once recording is finished, click \'Stop Recording,\' and the recording will be uploaded.';
} else {
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
alert('Screen recording is not supported in your browser. Please try a modern browser on a desktop computer.');
startBtn.disabled = true;
return;
}
}
let mediaRecorder;
let recordedChunks = [];
let stream;
startBtn.addEventListener('click', async () => {
try {
if (isMobileDevice()) {
stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
} else {
const displayStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream = new MediaStream([...displayStream.getTracks(), ...audioStream.getTracks()]);
}
// When user stops sharing screen via browser UI
stream.getVideoTracks()[0].addEventListener('ended', () => {
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
}
});
const mimeTypes = [
'video/mp4',
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm',
];
let supportedMimeType = '';
for (const mimeType of mimeTypes) {
if (MediaRecorder.isTypeSupported(mimeType)) {
supportedMimeType = mimeType;
break;
}
}
if (!supportedMimeType) {
console.error("No supported mimeType found for MediaRecorder");
alert("Your browser doesn't support any suitable video recording format.");
return;
}
mediaRecorder = new MediaRecorder(stream, { mimeType: supportedMimeType });
mediaRecorder.ondataavailable = event => {
if (event.data.size > 0) {
recordedChunks.push(event.data);
}
};
mediaRecorder.onstart = () => {
stopBtn.disabled = false;
startBtn.disabled = true;
};
mediaRecorder.onstop = () => {
stopBtn.disabled = true;
startBtn.disabled = false;
if(stream) {
stream.getTracks().forEach(track => track.stop());
}
const blob = new Blob(recordedChunks, { type: supportedMimeType });
recordedChunks = [];
uploadFile(blob);
};
mediaRecorder.start();
} catch (err) {
console.error("Error starting screen recording:", err);
}
});
stopBtn.addEventListener('click', () => {
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
}
});
function getCSRFToken() {
var i, cookies, cookie, cookieVal = null;
if ( document.cookie && '' !== document.cookie ) {
cookies = document.cookie.split(';');
i = 0;
while( i < cookies.length ){
cookie = cookies[i].trim();
if ( 'csrftoken=' === cookie.substring(0, 10) ) {
cookieVal = decodeURIComponent( cookie.substring(10) );
break;
}
i += 1;
}
}
return cookieVal;
}
function uploadFile(blob) {
const formData = new FormData();
const extension = blob.type.includes('mp4') ? 'mp4' : 'webm';
const recordingType = isMobileDevice() ? 'video-recording' : 'screen-recording';
const fileName = `${recordingType}-${new Date().toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '-')}.${extension}`;
formData.append('media_file', blob, fileName);
formData.append('title', fileName);
spinner.style.display = 'block';
fetch('/api/v1/media', {
method: 'POST',
headers: {
'X-CSRFToken': getCSRFToken(),
},
body: formData,
})
.then(response => {
if (!response.ok) {
return response.json().then(err => { throw new Error(err.detail || 'Upload failed') });
}
return response.json();
})
.then(data => {
spinner.style.display = 'none';
console.log('Upload successful:', data);
if (data.friendly_token) {
window.location.href = '/view?m=' + data.friendly_token;
}
})
.catch(error => {
spinner.style.display = 'none';
console.error('Upload failed:', error);
});
}
});
</script>
{% else %}
{{can_upload_exp}}
<br>
<a href='/contact'>Contact</a> portal owners for more information.
{% endif %}
{% endblock %}