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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable released changes are documented here. AutoSubs follows semantic vers

## [Unreleased]

## [3.2.0] - 2026-09-18

### Added

- New **Word by word** caption animation: only the currently spoken unit is displayed, using canonical word timings.
- Word-by-word grouping keeps French elisions and split compounds together (for example `l'` + `amour` and `rendez-` + `vous`) instead of flashing orphan fragments.
- Editor action to remove sentence-ending full stops in one pass while preserving commas, `!`, `?`, and ellipses, with a one-step undo before saving.

### Changed

- Preset and live video previews now mirror the word-by-word grouping used by the ASS/libass renderer.

## [3.1.2] - 2026-09-17

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "autosubs"
version = "3.1.2"
version = "3.2.0"
edition = "2024"
rust-version = "1.98"
authors = ["GodsQuantum"]
Expand Down
6 changes: 3 additions & 3 deletions README.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ Le navigateur n'est pas le moteur métier. Rust possède la normalisation des ti
- **Correction LLM optionnelle** — orthographe et ponctuation sans laisser le modèle modifier directement les timings.
- **Un seul moteur de timing** — Rust répare plages invalides, chevauchements, gaps et timings de mots. Le frontend ne possède pas une deuxième implémentation divergente.
- **Découpage Unicode/français** — comptage par graphèmes, opportunités Unicode et règles de non-coupure françaises.
- **Styles ASS/libass** — Pop, Surbrillance, Rebond, Karaoké, Fondu, Slide-up ou aucun effet; polices, contour, ombre, position et couleur d'accent.
- **Timings mot-à-mot durables** — la timeline canonique survit aux regroupements et modifications visuelles ; les animations utilisent les timings originaux.
- **Styles ASS/libass** — Pop, Surbrillance, Rebond, Karaoké, **Mot par mot**, Fondu, Slide-up ou aucun effet; polices, contour, ombre, position et couleur d'accent.
- **Timings mot-à-mot durables** — la timeline canonique survit aux regroupements et modifications visuelles ; les animations utilisent les timings originaux. Le mode Mot par mot garde les élisions (`l'` + mot) et mots composés avec tiret dans une seule unité visuelle.
- **Mise en page française** — segmentation syntaxique française et `maxLines` strict garantissent des retours explicites sans ligne cachée supplémentaire.
- **Cycle de vie complet** — édition, scission, fusion, suppression, retranscription et rerendu depuis la file et l'éditeur. Supprimer un job conserve la source et la sortie finale.
- **Invariant Source** — Source + Conserver préserve la géométrie primaire sans scale, pad, crop ni bandes noires.
Expand Down Expand Up @@ -122,7 +122,7 @@ volumes:
3. La correction LLM optionnelle ne touche qu'au texte.
4. Le moteur Rust normalise timings et regroupement.
5. Le job arrive à **Prêt**. Aucun encodage vidéo n'a encore été dépensé si le rendu immédiat n'a pas été demandé.
6. Corrige/scinde/fusionne/supprime/recherche/remplace/regroupe/décale dans l'Éditeur ; la timeline canonique reste disponible pour les regroupements ultérieurs.
6. Corrige/scinde/fusionne/supprime/recherche/remplace/regroupe/décale dans l'Éditeur. **Supprimer les points finaux** nettoie en lot les captions short-form tout en conservant virgules, !, ?, et points de suspension ; l'action peut être annulée avant enregistrement. La timeline canonique reste disponible pour les regroupements ultérieurs.
7. Exporte SRT/ASS/JSON sans réencoder, ou lance **Rendre la vidéo**.
8. FFmpeg/libass écrit d'abord un staging `.partial`. `.partial` est strictement interne au média incomplet, jamais un format de sous-titres.
9. Vidéo et sidecars sont publiés ensemble ; l'archive éventuelle de la source arrive en dernier. Les jobs existants peuvent être retranscrits ou rerendus depuis la file.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ It is deliberately not a browser-only subtitle toy. The Rust backend owns timing
- **Optional LLM correction** — spelling/punctuation correction after import/transcription while preserving line count and timings.
- **Canonical subtitle timing engine** — one Rust implementation repairs invalid ranges, overlaps, gaps and word timings. The browser does not maintain a competing copy.
- **Unicode-aware line grouping** — grapheme counting, Unicode line-break opportunities and French no-break rules instead of UTF-8 byte counting.
- **ASS/libass styling** — Pop, Highlight, Bounce, Karaoke, Fade, Slide-up and plain subtitle modes; custom fonts, outline, shadow, placement and highlight colors.
- **Durable word timing** — the canonical word-by-word timeline survives regrouping and visual edits; timed animations use the original word timings.
- **ASS/libass styling** — Pop, Highlight, Bounce, Karaoke, **Word by word**, Fade, Slide-up and plain subtitle modes; custom fonts, outline, shadow, placement and highlight colors.
- **Durable word timing** — the canonical word-by-word timeline survives regrouping and visual edits; timed animations use the original word timings. Word-by-word display keeps apostrophe prefixes and split hyphen compounds attached to their word.
- **French-safe visual layout** — French syntax-aware segmentation and hard `maxLines` enforcement use explicit line breaks, so rendered captions never gain hidden extra lines.
- **Complete job lifecycle** — edit, split, merge, delete, retranscribe and re-render jobs from the queue/editor. Deleting a job keeps its source and final output files.
- **Source geometry invariant** — Source + Preserve keeps the primary video dimensions and aspect ratio without scale, pad, crop, or black bars.
Expand Down Expand Up @@ -136,7 +136,7 @@ For migration, the old `SPEACHES_URL` variable is still accepted as a first-boot
3. Optional LLM correction runs on text only.
4. The canonical Rust engine normalizes timings and grouping.
5. The job reaches **Ready**. Nothing has been re-encoded yet unless you explicitly chose immediate render.
6. Review/edit/split/merge/delete/search/replace/regroup/shift timings in Editor. The canonical word timeline remains available for later regrouping.
6. Review/edit/split/merge/delete/search/replace/regroup/shift timings in Editor. Use **Remove final periods** for short-form caption cleanup; it preserves commas, !, ?, and ellipses and can be undone before saving. The canonical word timeline remains available for later regrouping.
7. Export SRT/ASS/JSON without touching the video, or click **Render video**.
8. FFmpeg/libass renders to a `.partial` staging file. `.partial` is internal media staging and is never treated as a subtitle file.
9. Video and sidecars publish together; an optional source archive happens last. Existing jobs can be retranscribed or re-rendered from the queue.
Expand Down
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "autosubs-frontend",
"private": true,
"version": "3.1.2",
"version": "3.2.0",
"type": "module",
"scripts": {
"dev": "vite dev",
Expand Down
18 changes: 10 additions & 8 deletions frontend/src/lib/components/FormatPreview.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { FontFace, FormatProfile, Preset, SubtitleWord } from '$lib/types';
import { activeWordIndex, customFontMatch, clampPreviewPosition, demoSubtitleWords, formatRatio, karaokeProgress, loopedPreviewTime, previewSubtitleTokens, previewWidthForRatio, safeZoneGuide, subtitlePositionBounds, scalePreviewMetric, videoObjectFit } from '$lib/preview.js';
import { activeWordIndex, customFontMatch, clampPreviewPosition, demoSubtitleWords, formatRatio, karaokeProgress, loopedPreviewTime, previewSubtitleTokens, wordByWordPreviewTokens, previewWidthForRatio, safeZoneGuide, subtitlePositionBounds, scalePreviewMetric, videoObjectFit } from '$lib/preview.js';

type SafeZoneKey = 'off'|'generic'|'tiktok'|'reels'|'shorts';

Expand Down Expand Up @@ -67,17 +67,19 @@
$: renderedItalic = selectedFace?.italic ?? Boolean(p?.italic);
$: timedWords = words?.length ? words : demoSubtitleWords(text).map(word=>({...word,start:word.start*demoDuration,end:word.end*demoDuration}));
$: renderedFamily = selectedFace?.fullName ?? selectedFace?.family ?? p?.fontFamily.split(',')[0].trim() ?? "sans-serif";
$: previewWords = previewSubtitleTokens(text, timedWords);
$: animation = p?.animationStyle ?? 'none';
$: rawPreviewWords = previewSubtitleTokens(text, timedWords);
$: previewWords = animation === 'word-by-word' ? wordByWordPreviewTokens(rawPreviewWords) : rawPreviewWords;
$: previewTime = videoSrc ? currentTime : loopedPreviewTime(demoElapsed,demoDuration);
$: activeWord = activeWordIndex(previewWords, previewTime);
$: animation = p?.animationStyle ?? 'none';
$: 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) : 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));
$: layoutText = animation === 'word-by-word' ? (previewWords[activeWord]?.word ?? text) : text;
$: visualLines = Math.max(1, layoutText.split('\n').filter(line=>line.trim()).length);
$: longestLine = Math.max(1, ...layoutText.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);
$: positionBounds = subtitlePositionBounds(displayWidth, displayHeight, estimatedBlockWidth, estimatedBlockHeight);
Expand Down Expand Up @@ -203,9 +205,9 @@
on:pointerup={stopDrag}
on:pointercancel={stopDrag}
on:keydown={nudgePosition}
><span class:floating={p?.floating} class="preview-floating" style={motionStyle}><span class={`preview-animation-inner animation-${animation}`} style={motionStyle}>{#each previewWords as word, i}{#if word.separator}<span aria-hidden="true">{word.separator}</span>{/if}<span class:active={i === activeWord} class={`preview-word word-animation-${animation}`} style={`${motionStyle}--word-time:${Math.max(0,previewTime-word.start)};--base-color:${p?.baseColor ?? '#fff'};--highlight-color:${p?.highlightColor ?? '#3dd7cf'};--karaoke-progress:${karaokeProgress(previewTime,word.start,word.end)}%;color:${i === activeWord && (animation === 'pop' || animation === 'highlight' || animation === 'bounce' || animation === 'karaoke') ? (p?.highlightColor ?? '#3dd7cf') : (p?.baseColor ?? '#fff')}`}>{word.word}</span>{/each}</span></span></button>
><span class:floating={p?.floating} class="preview-floating" style={motionStyle}><span class={`preview-animation-inner animation-${animation}`} style={motionStyle}>{#each previewWords as word, i}{#if animation !== 'word-by-word' || i === activeWord}{#if animation !== 'word-by-word' && word.separator}<span aria-hidden="true">{word.separator}</span>{/if}<span class:active={i === activeWord} class={`preview-word word-animation-${animation}`} style={`${motionStyle}--word-time:${Math.max(0,previewTime-word.start)};--base-color:${p?.baseColor ?? '#fff'};--highlight-color:${p?.highlightColor ?? '#3dd7cf'};--karaoke-progress:${karaokeProgress(previewTime,word.start,word.end)}%;color:${i === activeWord && (animation === 'pop' || animation === 'highlight' || animation === 'bounce' || animation === 'karaoke') ? (p?.highlightColor ?? '#3dd7cf') : (p?.baseColor ?? '#fff')}`}>{word.word}</span>{/if}{/each}</span></span></button>
{:else}
<div class="preview-subtitle" style={subtitleStyle}><span class:floating={p?.floating} class="preview-floating" style={motionStyle}><span class={`preview-animation-inner animation-${animation}`} style={motionStyle}>{#each previewWords as word, i}{#if word.separator}<span aria-hidden="true">{word.separator}</span>{/if}<span class:active={i === activeWord} class={`preview-word word-animation-${animation}`} style={`${motionStyle}--word-time:${Math.max(0,previewTime-word.start)};--base-color:${p?.baseColor ?? '#fff'};--highlight-color:${p?.highlightColor ?? '#3dd7cf'};--karaoke-progress:${karaokeProgress(previewTime,word.start,word.end)}%;color:${i === activeWord && (animation === 'pop' || animation === 'highlight' || animation === 'bounce' || animation === 'karaoke') ? (p?.highlightColor ?? '#3dd7cf') : (p?.baseColor ?? '#fff')}`}>{word.word}</span>{/each}</span></span></div>
<div class="preview-subtitle" style={subtitleStyle}><span class:floating={p?.floating} class="preview-floating" style={motionStyle}><span class={`preview-animation-inner animation-${animation}`} style={motionStyle}>{#each previewWords as word, i}{#if animation !== 'word-by-word' || i === activeWord}{#if animation !== 'word-by-word' && word.separator}<span aria-hidden="true">{word.separator}</span>{/if}<span class:active={i === activeWord} class={`preview-word word-animation-${animation}`} style={`${motionStyle}--word-time:${Math.max(0,previewTime-word.start)};--base-color:${p?.baseColor ?? '#fff'};--highlight-color:${p?.highlightColor ?? '#3dd7cf'};--karaoke-progress:${karaokeProgress(previewTime,word.start,word.end)}%;color:${i === activeWord && (animation === 'pop' || animation === 'highlight' || animation === 'bounce' || animation === 'karaoke') ? (p?.highlightColor ?? '#3dd7cf') : (p?.baseColor ?? '#fff')}`}>{word.word}</span>{/if}{/each}</span></span></div>
{/if}
{/if}
</div>
Expand All @@ -232,7 +234,7 @@
.word-animation-pop.active { animation:preview-pop .32s ease both; animation-delay:calc(var(--word-time) * -1s); animation-play-state:paused; }
.word-animation-bounce.active { animation:preview-bounce .38s ease both; animation-delay:calc(var(--word-time) * -1s); animation-play-state:paused; }
.word-animation-karaoke.active { color:transparent !important; background:linear-gradient(90deg,var(--highlight-color) 0 var(--karaoke-progress),var(--base-color) var(--karaoke-progress)); background-clip:text; -webkit-background-clip:text; }
.animation-highlight, .animation-karaoke, .animation-none { animation:none; }
.animation-highlight, .animation-karaoke, .animation-word-by-word, .animation-none { animation:none; }
@keyframes preview-fade { from { opacity:0; } to { opacity:1; } }
@keyframes preview-slide-up { from { opacity:0; transform:translateY(18px); } to { opacity:1; transform:translateY(0); } }
@keyframes preview-pop { 0% { transform:scale(1); } 43% { transform:scale(1.12); } 100% { transform:scale(1); } }
Expand Down
Loading