Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 19 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ global/default preset resolution

Applying a preset to a Job adopts the preset format, fit mode and segmentation limits by default. Explicit edits made after applying it remain possible.

Each Job inherits its outro from the selected Preset (then its Brand). The Editor can override that choice for one Job with another video asset or no outro.

## 🔄 Watch folders

Workflows combine low-latency filesystem events with periodic reconciliation. This matters on NFS: a remote write does not necessarily produce the local inotify event you expected.
Expand Down Expand Up @@ -221,35 +223,37 @@ GET /api/v1/health
GET /api/v1/capabilities
GET /api/v1/events SSE

GET/POST /api/v1/jobs
GET/PUT/DELETE /api/v1/jobs/{id} Delete keeps source/final media
GET /api/v1/jobs/{id}/media Range-aware video stream
GET /api/v1/jobs
POST /api/v1/jobs/from-path
GET/PUT/DELETE /api/v1/jobs/{id} Delete keeps source/final media
GET/HEAD /api/v1/jobs/{id}/video Compatibility: output, then source
GET/HEAD /api/v1/jobs/{id}/video/source Original source only
GET/HEAD /api/v1/jobs/{id}/video/output Rendered output only
POST /api/v1/jobs/{id}/cancel
POST /api/v1/jobs/{id}/prepare
POST /api/v1/jobs/{id}/render
POST /api/v1/jobs/{id}/retranscribe
PUT/DELETE /api/v1/jobs/{id}/sidecar
POST /api/v1/jobs/{id}/sidecar/upload
GET/PUT /api/v1/jobs/{id}/subtitles
POST /api/v1/jobs/{id}/subtitles/regroup
POST /api/v1/jobs/{id}/subtitles/shift
GET /api/v1/jobs/{id}/subtitles/export
POST /api/v1/jobs/{id}/regroup
GET /api/v1/jobs/{id}/subtitles/{srt|ass|json}

GET /api/v1/fonts Detected custom font catalog
GET /api/v1/fonts/css Browser @font-face stylesheet
GET /api/v1/fonts/{id}/content Safe font content endpoint
GET /api/v1/fonts Detected custom font catalog
GET /api/v1/fonts/css Browser @font-face stylesheet
GET /api/v1/fonts/{id}/content Safe font content endpoint

POST /api/v1/uploads tus create
HEAD/PATCH /api/v1/uploads/{id} tus resume/upload
DELETE /api/v1/uploads/{id}

GET/POST /api/v1/presets
GET/PUT/DEL /api/v1/presets/{id}
DELETE /api/v1/presets/{id}
GET/POST /api/v1/brands
GET/PUT/DEL /api/v1/brands/{id}
DELETE /api/v1/brands/{id}
GET/POST /api/v1/workflows
GET/PUT/DEL /api/v1/workflows/{id}
DELETE /api/v1/workflows/{id}
GET/PUT /api/v1/settings
GET /api/v1/files/roots
GET /api/v1/files/browse
GET /api/v1/browse
GET/POST /api/v1/assets
DELETE /api/v1/assets/{id}
```
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { parseApiResponse } from './api-response.js';
import type { Asset, Brand, BrowseResponse, Capabilities, FontFace, Job, Preset, SettingsView, SubtitleLine, Workflow, FormatProfile } from './types';
import type { Asset, Brand, BrowseResponse, Capabilities, FontFace, Job, JobOutro, Preset, SettingsView, SubtitleLine, Workflow, FormatProfile } from './types';

export class ApiError extends Error {
constructor(public status: number, message: string) { super(message); }
Expand Down Expand Up @@ -28,7 +28,7 @@ export const api = {
cancel: (id: string) => request<Job>(`/api/v1/jobs/${id}/cancel`, { method: 'POST' }),
retranscribe: (id: string) => request<{accepted:boolean}>(`/api/v1/jobs/${id}/retranscribe`, { method: 'POST' }),
deleteJob: (id: string) => request<void>(`/api/v1/jobs/${id}`, { method: 'DELETE' }),
updateJob: (id: string, body: { presetId?: string | null; format?: FormatProfile }) => request<Job>(`/api/v1/jobs/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
updateJob: (id: string, body: { presetId?: string | null; format?: FormatProfile; outro?: JobOutro }) => request<Job>(`/api/v1/jobs/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
subtitles: (id: string) => request<SubtitleLine[]>(`/api/v1/jobs/${id}/subtitles`),
saveSubtitles: (id: string, lines: SubtitleLine[]) => request<{lines:SubtitleLine[]; repairedLineOverlaps:number; retimedWordLines:number; droppedEmptyLines:number}>(`/api/v1/jobs/${id}/subtitles`, { method: 'PUT', body: JSON.stringify(lines) }),
regroup: (id: string, maxChars: number, maxLines: number) => request<SubtitleLine[]>(`/api/v1/jobs/${id}/regroup`, { method: 'POST', body: JSON.stringify({ maxChars, maxLines }) }),
Expand Down Expand Up @@ -58,6 +58,8 @@ export const api = {

export const subtitleExportUrl = (id:string, format:'srt'|'ass'|'json') => `/api/v1/jobs/${id}/subtitles/${format}`;
export const videoUrl = (id:string) => `/api/v1/jobs/${id}/video`;
export const sourceVideoUrl = (id:string) => `/api/v1/jobs/${id}/video/source`;
export const renderedVideoUrl = (id:string) => `/api/v1/jobs/${id}/video/output`;
export const assetUrl = (id:string) => `/api/v1/assets/${id}/content`;

const TUS = '1.0.0';
Expand Down
9 changes: 4 additions & 5 deletions frontend/src/lib/components/FormatPreview.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@
$: previewWidth = previewWidthForRatio(ratio);
$: objectFit = videoObjectFit(format);
$: guide = safeZoneGuide(safeZone);
$: outputHeight = format.key === 'portrait916' ? 1920 : format.key === 'landscape169' ? 1080 : format.key === 'square11' ? 1080 : format.key === 'portrait45' ? 1350 : format.key === 'custom' && format.height ? Number(format.height) : naturalHeight || 1080;
$: displayWidth = measuredWidth || previewWidth;
$: displayHeight = measuredHeight || previewWidth / ratio;
$: p = preset;
Expand All @@ -75,15 +74,15 @@
$: eventTime = Math.max(0, previewTime - (previewWords[0]?.start ?? 0));
$: wobbleDuration = 1 / Math.max(0.05, Number(p?.wobbleSpeed) || 1);
$: motionStyle = `--preview-time:${previewTime};--event-time:${eventTime};--wobble-duration:${wobbleDuration}s;`;
$: fontSize = p ? scalePreviewMetric(p.size,displayHeight,outputHeight) : 18;
$: fontSize = p ? scalePreviewMetric(p.size,displayHeight) : 18;
$: lineHeight = 1.08;
$: visualLines = Math.max(1, text.split('\n').filter(line=>line.trim()).length);
$: longestLine = Math.max(1, ...text.split('\n').map(line=>Array.from(line).length));
$: estimatedBlockWidth = Math.min(displayWidth * .9, longestLine * fontSize * .56);
$: estimatedBlockHeight = visualLines * fontSize + Math.max(0,visualLines-1) * scalePreviewMetric(p?.lineSpacing ?? 0,displayHeight,outputHeight);
$: estimatedBlockHeight = visualLines * fontSize + Math.max(0,visualLines-1) * scalePreviewMetric(p?.lineSpacing ?? 0,displayHeight);
$: positionBounds = subtitlePositionBounds(displayWidth, displayHeight, estimatedBlockWidth, estimatedBlockHeight);
$: outlineSize = p ? scalePreviewMetric(p.outlineThickness,displayHeight,outputHeight) : 0;
$: shadowSize = p ? scalePreviewMetric(p.shadowThickness ?? 1,displayHeight,outputHeight) : 0;
$: outlineSize = p ? scalePreviewMetric(p.outlineThickness,displayHeight) : 0;
$: shadowSize = p ? scalePreviewMetric(p.shadowThickness ?? 1,displayHeight) : 0;
$: safePosition = clampPreviewPosition(p?.positionX ?? 50, p?.positionY ?? 68, positionBounds);
$: subtitleStyle = p
? `top:${safePosition.y}%;left:${safePosition.x}%;font-size:${fontSize}px;line-height:${lineHeight};color:${p.baseColor};font-family:"${renderedFamily.replaceAll('"','\\"')}";font-weight:${renderedWeight};font-style:${renderedItalic?'italic':'normal'};font-synthesis:none;text-transform:${p.uppercase?'uppercase':'none'};-webkit-text-stroke:${outlineSize}px ${p.outlineColor};text-shadow:0 ${shadowSize}px ${shadowSize*2}px ${p.shadowColor??'#000000'};`
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/lib/download.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/** @param {string} value @param {string} fallback */
function safeFilename(value, fallback) {
const clean = value.replace(/[\\/\0]/g, '_').trim();
return clean || fallback;
}

/** @param {string|null} value @param {string} fallback */
export function filenameFromDisposition(value, fallback) {
if (!value) return fallback;
const encoded = value.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
if (encoded) {
try { return safeFilename(decodeURIComponent(encoded), fallback); }
catch { return fallback; }
}
const plain = value.match(/filename="([^"]+)"/i)?.[1] ?? value.match(/filename=([^;]+)/i)?.[1];
return plain ? safeFilename(plain, fallback) : fallback;
}

/** @param {string} url @param {string} fallback @param {typeof fetch} fetchImpl */
export async function fetchDownload(url, fallback, fetchImpl = fetch) {
const response = await fetchImpl(url);
if (!response.ok) {
let message = `HTTP ${response.status}`;
try {
const body = JSON.parse(await response.text());
message = body?.error?.message ?? message;
} catch {
// Keep the status when the server did not return JSON.
}
throw new Error(message);
}
return {
blob: await response.blob(),
filename: filenameFromDisposition(response.headers.get('content-disposition'), fallback)
};
}

/** @param {{blob:Blob;filename:string}} payload */
export function saveDownload(payload, documentRef = document, urlApi = URL) {
const href = urlApi.createObjectURL(payload.blob);
const link = documentRef.createElement('a');
link.href = href;
link.download = payload.filename;
link.hidden = true;
documentRef.body.appendChild(link);
link.click();
link.remove();
urlApi.revokeObjectURL(href);
}
8 changes: 4 additions & 4 deletions frontend/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ const en = {
preserve:'Preserve', contain:'Contain', cover:'Cover', stretch:'Stretch', width:'Width', height:'Height', apply:'Apply', applyToJob:'Apply to job',
newPreset:'New preset', presetName:'Preset name', animation:'Animation', font:'Font', size:'Size', positionX:'Position X', positionY:'Position Y',
baseColor:'Base color', highlightColor:'Highlight', outlineColor:'Outline', outline:'Outline size', shadow:'Shadow', shadowColor:'Shadow color', uppercase:'Uppercase', bold:'Bold', italic:'Italic', floating:'Floating', keywords:'Filename keywords', lineSpacing:'Line spacing', wobbleSpeed:'Floating speed', borderStyle:'Border style',
preview:'Preview', sampleText:'THIS IS A PREVIEW', brand:'Brand', noBrand:'No brand', outro:'Outro', defaultOutro:'Default outro', noOutro:'No outro',
preview:'Preview', sampleText:'THIS IS A PREVIEW', brand:'Brand', noBrand:'No brand', outro:'Outro', defaultOutro:'Default outro', noOutro:'No outro', outroOverride:'Job outro', inheritPresetOutro:'Use preset / brand outro', inheritedOutro:'Inherited outro',
newBrand:'New brand', brandName:'Brand name', description:'Description', brandDefaults:'Default preset per format', assets:'Assets', importAsset:'Import server asset', uploadAsset:'Upload asset', logo:'Logo', assetLibrary:'Asset library',
newWorkflow:'New workflow', workflowName:'Workflow name', watchDir:'Watch folder', outputDir:'Output folder', archiveDir:'Archive folder', enabled:'Enabled', disabled:'Disabled', presetOverride:'Preset override', brandDefault:'Brand default', workflowHint:'Native filesystem events plus periodic reconciliation keep NFS workflows reliable.',
transcription:'Transcription', primary:'Primary', localFallback:'Local / fallback', endpoint:'Endpoint', model:'Model', apiKey:'API key', keyStored:'Key stored', keepKey:'Keep stored key', replaceKey:'Replace key', clearKey:'Clear key',
transcriptionLanguage:'Transcription language', localEnabled:'Use local transcription first', fallbackEnabled:'Fallback to the other provider on failure', llm:'LLM correction', llmEnabled:'Enable correction', prompt:'Correction prompt',
encoding:'Encoding', encoder:'Encoder', quality:'Quality', encoderPreset:'Encoder preset', capabilities:'Capabilities', ffmpeg:'FFmpeg', libass:'libass', available:'Available', unavailable:'Unavailable', detected:'Detected',
filePicker:'Server picker', folder:'Folder', file:'File', choose:'Choose', root:'Root', noEntries:'No matching entries', currentPath:'Current path', filter:'Filter',
pending:'Pending', uploadingStatus:'Uploading', probing:'Probing', transcribing:'Transcribing', correcting:'Correcting', ready:'Ready', rendering:'Rendering', done:'Done', cancelled:'Cancelled', interrupted:'Interrupted', failed:'Failed',
loading:'Loading…', saving:'Saving…', confirmDelete:'Delete this item?', required:'Required', copied:'Copied', open:'Open', clear:'Clear',
loading:'Loading…', saving:'Saving…', downloading:'Downloading…', confirmDelete:'Delete this item?', required:'Required', copied:'Copied', open:'Open', clear:'Clear',
settingsInfo:'Secrets are never returned by the API. Leaving a key untouched keeps the stored value.',
mobileTip:'Everything remains editable on phone and tablet; panels collapse instead of removing controls.',
pathNotAllowed:'Path is outside allowed roots.', uploadFailed:'Upload failed', operationFailed:'Operation failed', connectionLost:'Connection lost. AutoSubs will retry when the page refreshes.',
Expand Down Expand Up @@ -58,15 +58,15 @@ const fr: typeof en = {
preserve:'Conserver', contain:'Contenir', cover:'Couvrir', stretch:'Étirer', width:'Largeur', height:'Hauteur', apply:'Appliquer', applyToJob:'Appliquer au job',
newPreset:'Nouveau preset', presetName:'Nom du preset', animation:'Animation', font:'Police', size:'Taille', positionX:'Position X', positionY:'Position Y',
baseColor:'Couleur de base', highlightColor:'Surbrillance', outlineColor:'Contour', outline:'Taille du contour', shadow:'Ombre', shadowColor:"Couleur de l’ombre", uppercase:'Majuscules', bold:'Gras', italic:'Italique', floating:'Flottant', keywords:'Mots-clés du nom de fichier', lineSpacing:'Espacement des lignes', wobbleSpeed:'Vitesse flottante', borderStyle:'Style de bordure',
preview:'Aperçu', sampleText:'CECI EST UN APERÇU', brand:'Marque', noBrand:'Aucune marque', outro:'Outro', defaultOutro:'Outro par défaut', noOutro:'Aucun outro',
preview:'Aperçu', sampleText:'CECI EST UN APERÇU', brand:'Marque', noBrand:'Aucune marque', outro:'Outro', defaultOutro:'Outro par défaut', noOutro:'Aucun outro', outroOverride:'Outro du job', inheritPresetOutro:'Utiliser l’outro du preset / de la marque', inheritedOutro:'Outro hérité',
newBrand:'Nouvelle marque', brandName:'Nom de la marque', description:'Description', brandDefaults:'Preset par défaut selon le format', assets:'Assets', importAsset:'Importer un asset serveur', uploadAsset:'Importer un asset', logo:'Logo', assetLibrary:"Bibliothèque d’assets",
newWorkflow:'Nouveau workflow', workflowName:'Nom du workflow', watchDir:'Dossier surveillé', outputDir:'Dossier de sortie', archiveDir:"Dossier d’archive", enabled:'Activé', disabled:'Désactivé', presetOverride:'Forcer un preset', brandDefault:'Défaut de la marque', workflowHint:'Les événements natifs et une réconciliation périodique rendent les workflows fiables aussi sur NFS.',
transcription:'Transcription', primary:'Principal', localFallback:'Local / secours', endpoint:'Endpoint', model:'Modèle', apiKey:'Clé API', keyStored:'Clé enregistrée', keepKey:'Conserver la clé', replaceKey:'Remplacer la clé', clearKey:'Effacer la clé',
transcriptionLanguage:'Langue de transcription', localEnabled:'Utiliser d’abord la transcription locale', fallbackEnabled:'Basculer vers l’autre fournisseur en cas d’échec', llm:'Correction LLM', llmEnabled:'Activer la correction', prompt:'Prompt de correction',
encoding:'Encodage', encoder:'Encodeur', quality:'Qualité', encoderPreset:'Preset encodeur', capabilities:'Capacités', ffmpeg:'FFmpeg', libass:'libass', available:'Disponible', unavailable:'Indisponible', detected:'Détecté',
filePicker:'Explorateur serveur', folder:'Dossier', file:'Fichier', choose:'Choisir', root:'Racine', noEntries:'Aucun élément correspondant', currentPath:'Chemin courant', filter:'Filtrer',
pending:'En attente', uploadingStatus:'Import', probing:'Analyse', transcribing:'Transcription', correcting:'Correction', ready:'Prêt', rendering:'Rendu', done:'Terminé', cancelled:'Annulé', interrupted:'Interrompu', failed:'Échec',
loading:'Chargement…', saving:'Enregistrement…', confirmDelete:'Supprimer cet élément ?', required:'Obligatoire', copied:'Copié', open:'Ouvrir', clear:'Effacer',
loading:'Chargement…', saving:'Enregistrement…', downloading:'Téléchargement…', confirmDelete:'Supprimer cet élément ?', required:'Obligatoire', copied:'Copié', open:'Ouvrir', clear:'Effacer',
settingsInfo:'Les secrets ne sont jamais renvoyés par l’API. Ne pas toucher à une clé conserve sa valeur enregistrée.',
mobileTip:'Tout reste modifiable sur téléphone et tablette : les panneaux se replient sans supprimer de contrôles.',
pathNotAllowed:'Le chemin est hors des racines autorisées.', uploadFailed:"Échec de l’import", operationFailed:"Échec de l’opération", connectionLost:'Connexion perdue. AutoSubs reprendra lors du prochain rafraîchissement.',
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/lib/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,11 @@ export function loopedPreviewTime(elapsed, duration) {
return safeElapsed % safeDuration;
}

/** @param {number} value @param {number} displayHeight @param {number} outputHeight */
export function scalePreviewMetric(value, displayHeight, outputHeight) {
return Math.max(0, value) * Math.max(0, displayHeight) / Math.max(1, outputHeight);
/** @param {number} value @param {number} displayHeight @param {number} [legacyOutputHeight] */
export function scalePreviewMetric(value, displayHeight, legacyOutputHeight) {
const candidateHeight = legacyOutputHeight ?? 1080;
const referenceHeight = Number.isFinite(candidateHeight) ? Math.max(1, candidateHeight) : 1080;
return Math.max(0, value) * Math.max(0, displayHeight) / referenceHeight;
}

/** @template {{family:string,fullName?:string,weight?:number,italic?:boolean}} T @param {T[]} fonts @param {string} family @param {number} [weight] @param {boolean} [italic] @returns {T|null} */
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export interface Preset {
export interface BrandAssets { defaultOutro?:string; logo?:string }
export interface Brand { id:string; name:string; description:string; assets:BrandAssets; presetIds:string[]; defaultPresetByFormat:Partial<Record<FormatKey,string>> }
export interface Workflow { id:string; name:string; watchDir:string; outputDir:string; archiveDir:string; brandId?:string; format:FormatProfile; presetId?:string; enabled:boolean }
export interface Job { id:string; originalName:string; status:JobStatus; progress?:number; lines?:SubtitleLine[]; error?:string; inputPath?:string; outputPath?:string; presetId?:string; format:FormatProfile; workflowId?:string; archiveAfterSuccess:boolean; attachedSidecar?:string; createdAtMs:number; updatedAtMs:number }
export type JobOutro = {mode:'inherit'} | {mode:'none'} | {mode:'asset';assetId:string};
export interface Job { id:string; originalName:string; status:JobStatus; progress?:number; lines?:SubtitleLine[]; error?:string; inputPath?:string; outputPath?:string; presetId?:string; format:FormatProfile; outro:JobOutro; workflowId?:string; archiveAfterSuccess:boolean; attachedSidecar?:string; createdAtMs:number; updatedAtMs:number }
export interface Encoder { kind:'auto'|'libx264'|'libx265'|'nvenc_h264'|'nvenc_hevc'|'qsv_h264'|'vaapi_h264'|'amf_h264'; quality:number; preset:string }
export interface SettingsView {
transcriptionUrl:string; transcriptionModel:string; transcriptionApiKeySet:boolean; language:string;
Expand Down
Loading