diff --git a/README.md b/README.md index 4184a19..6ed8809 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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} ``` diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7da3398..29dc9be 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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); } @@ -28,7 +28,7 @@ export const api = { cancel: (id: string) => request(`/api/v1/jobs/${id}/cancel`, { method: 'POST' }), retranscribe: (id: string) => request<{accepted:boolean}>(`/api/v1/jobs/${id}/retranscribe`, { method: 'POST' }), deleteJob: (id: string) => request(`/api/v1/jobs/${id}`, { method: 'DELETE' }), - updateJob: (id: string, body: { presetId?: string | null; format?: FormatProfile }) => request(`/api/v1/jobs/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + updateJob: (id: string, body: { presetId?: string | null; format?: FormatProfile; outro?: JobOutro }) => request(`/api/v1/jobs/${id}`, { method: 'PUT', body: JSON.stringify(body) }), subtitles: (id: string) => request(`/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(`/api/v1/jobs/${id}/regroup`, { method: 'POST', body: JSON.stringify({ maxChars, maxLines }) }), @@ -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'; diff --git a/frontend/src/lib/components/FormatPreview.svelte b/frontend/src/lib/components/FormatPreview.svelte index 1a515e2..8066ec3 100644 --- a/frontend/src/lib/components/FormatPreview.svelte +++ b/frontend/src/lib/components/FormatPreview.svelte @@ -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; @@ -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'};` diff --git a/frontend/src/lib/download.js b/frontend/src/lib/download.js new file mode 100644 index 0000000..f7041f7 --- /dev/null +++ b/frontend/src/lib/download.js @@ -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); +} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 51f73df..1a78889 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -16,7 +16,7 @@ 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', @@ -24,7 +24,7 @@ const en = { 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.', @@ -58,7 +58,7 @@ 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Ă©', @@ -66,7 +66,7 @@ const fr: typeof en = { 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.', diff --git a/frontend/src/lib/preview.js b/frontend/src/lib/preview.js index 76928ee..eb5f52b 100644 --- a/frontend/src/lib/preview.js +++ b/frontend/src/lib/preview.js @@ -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} */ diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 5dc1afe..017f3b2 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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> } 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; diff --git a/frontend/src/lib/views/EditorView.svelte b/frontend/src/lib/views/EditorView.svelte index e8f1c2a..d64ba3c 100644 --- a/frontend/src/lib/views/EditorView.svelte +++ b/frontend/src/lib/views/EditorView.svelte @@ -1,9 +1,10 @@ @@ -87,7 +100,7 @@
- currentTime=time}/> + currentTime=time}/>
{formatKey==='source'?$dictionary.sourceFormat:formatKey==='portrait916'?'9:16':formatKey==='landscape169'?'16:9':formatKey==='square11'?'1:1':formatKey==='portrait45'?'4:5':`${customWidth}×${customHeight}`}{formatKey==='source'?$dictionary.preserve:fit}
@@ -129,6 +142,10 @@
{#if formatKey==='custom'}
{/if} +
+ {#if outroChoice==='inherit'} +
{$dictionary.inheritedOutro}: {inheritedOutro?.name || $dictionary.noOutro}
+ {/if}
{$dictionary.sourcePreserveHint}
@@ -155,7 +172,7 @@
{$dictionary.export}
- +
diff --git a/frontend/src/lib/views/PresetsView.svelte b/frontend/src/lib/views/PresetsView.svelte index 48ac744..b9eaacb 100644 --- a/frontend/src/lib/views/PresetsView.svelte +++ b/frontend/src/lib/views/PresetsView.svelte @@ -2,12 +2,13 @@ import { onMount } from 'svelte'; import { api } from '$lib/api'; import { dictionary } from '$lib/i18n'; - import type { Brand, FontFace, FormatKey, Preset } from '$lib/types'; + import type { Asset, Brand, FontFace, FormatKey, Preset } from '$lib/types'; import FormatPreview from '$lib/components/FormatPreview.svelte'; import { customFontMatch, verifyCustomFont } from '$lib/preview.js'; export let presets:Preset[]=[]; export let brands:Brand[]=[]; export let fonts:FontFace[]=[]; + export let assets:Asset[]=[]; export let refresh:()=>Promise=async()=>{}; export let notify:(type:'error'|'success'|'info',message:string)=>void=()=>{}; @@ -21,6 +22,7 @@ let fontStatus:{status:string;fileName:string}|null=null; $: current=presets.find(p=>p.id===selected); $: selectedCustomFont=customFontMatch(fonts,draft.fontFamily,draft.bold?700:400,draft.italic); + $: videoAssets=assets.filter(asset=>asset.mime.startsWith('video/')); $: if(draft.fontFamily && fontStylesheetRevision>=0) checkSelectedFont(draft.fontFamily,selectedCustomFont,fontStylesheetRevision); $: if(current && draft.id!==current.id) draft=clone(current); $: if(draft.format.key!==previousFormatKey){ @@ -36,7 +38,7 @@ function duplicate(){draft={...clone(draft),id:'',name:`${draft.name} copy`};selected='';previousFormatKey=draft.format.key} async function save(){ if(draft.format.key==='custom' && (!Number.isInteger(Number(draft.format.width))||!Number.isInteger(Number(draft.format.height))||Number(draft.format.width)<16||Number(draft.format.height)<16||Number(draft.format.width)>16384||Number(draft.format.height)>16384||Number(draft.format.width)%2!==0||Number(draft.format.height)%2!==0)){notify('error',`${$dictionary.custom}: ${$dictionary.width} × ${$dictionary.height}`);return;} - try{const saved=await api.savePreset(draft);selected=saved.id;draft=clone(saved);await refresh();notify('success',$dictionary.saved)}catch(e){notify('error',e instanceof Error?e.message:String(e))} + try{const saved=await api.savePreset({...draft,outroVideo:draft.outroVideo||undefined});selected=saved.id;draft=clone(saved);await refresh();notify('success',$dictionary.saved)}catch(e){notify('error',e instanceof Error?e.message:String(e))} } async function remove(){if(!draft.id||!confirm($dictionary.confirmDelete))return;try{await api.deletePreset(draft.id);selected='';draft=makePreset();await refresh()}catch(e){notify('error',e instanceof Error?e.message:String(e))}} async function checkSelectedFont(family:string,font:FontFace|null,_stylesheetRevision:number){ @@ -70,6 +72,7 @@
{#if draft.format.key==='custom'}
{/if} +
diff --git a/frontend/src/lib/views/QueueView.svelte b/frontend/src/lib/views/QueueView.svelte index 955e0e8..e46aedb 100644 --- a/frontend/src/lib/views/QueueView.svelte +++ b/frontend/src/lib/views/QueueView.svelte @@ -1,5 +1,5 @@