diff --git a/README.md b/README.md index 0deb1ad..ccddaaa 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ A private video editor that runs in your browser. Trim, crop, resize and rotate, - Extract audio is unavailable until the source or an added file has usable audio. GIF is unavailable when the source has no decodable video. - Hover a setting or edit control for a short explanation of what it does. - All three tools use the same trim. The timeline ruler marks time above the filmstrip. The filmstrip adds thumbnails as it widens, up to 40, and limits them on short clips. Drag the trim handles or use arrow keys to adjust by 0.1 seconds. Hold Shift with an arrow key for 1-second steps. Edit the Start or End time using seconds, m:ss, or h:mm:ss, with optional tenths. Use the adjacent Set button to place that boundary at the preview playhead. The previous and next frame buttons flank the playhead time and pause and step the preview by one source frame. Press comma or period for the same steps when focus is outside a text field. The kept duration appears in the Trim header and updates with the summary as you trim. Reset trim appears when a boundary changes and restores the full source duration in all three tools. -- Under Trim, Cut sections removes up to 20 ranges inside the trim. Add cut starts a one-second cut at the playhead. Each row accepts Start and End timecodes or a Set button to use the playhead; setting Start past End, or End before Start, moves the cut and keeps its length. Remove deletes the row. Cuts appear on the filmstrip and audio lanes, and playback skips them. Export joins the kept ranges for Shrink, Extract audio and GIF. Cuts force exact video and audio encoding, so Fast copy and already-small copies are unavailable. The estimate and Target size use the joined duration. Master fades run at the joined output's start and end; original audio gets a short fade at each join to prevent clicks. Transition sets Hard cut (the default) or Fade through black, with Fade duration 0.25, 0.5, 0.75 or 1 s: half fades out before each join and half fades in after it, so the joined duration does not change. The fade dims the picture and the original audio; overlays, captions and added audio stay as they are. Each fade is shortened so it never takes more than half of a kept part. Preview and export both show the fade. Added-audio offset and Loop follow output time, so added audio continues through joins. Reset all clears cuts. +- Under Trim, Cut sections removes up to 20 ranges inside the trim. Add cut starts a one-second cut at the playhead. Each row accepts Start and End timecodes or a Set button to use the playhead; setting Start past End, or End before Start, moves the cut and keeps its length. Remove deletes the row. Cuts appear on the filmstrip and audio lanes, and playback skips them. Export joins the kept ranges for Shrink, Extract audio and GIF. Cuts force exact video and audio encoding, so Fast copy and already-small copies are unavailable. The estimate and Target size use the joined duration. Master fades run at the joined output's start and end; original audio gets a short fade at each join to prevent clicks. Transition sets Hard cut (the default) or Fade through black, with Fade duration 0.25, 0.5, 0.75 or 1 s: half fades out before each join and half fades in after it, so the joined duration does not change. The fade dims the picture and the original audio; overlays, captions and added audio stay as they are. Each fade is shortened so it never takes more than half of a kept part. Preview and export both show the fade. Added-audio offset and Loop follow output time, so added audio continues through joins. Cuts outside the trim are kept and return when the trim widens. Reset all clears cuts. - Drag the white playhead or click the ruler or filmstrip to seek. With the playhead focused, Left and Right move 1 second, Shift with Left or Right moves 5 seconds, Page Up and Page Down move 10 seconds, and Home and End jump to the trim boundaries. - Press Space to play or pause the preview when focus is outside a control or editor. - The source summary shows whether sampled frames have a variable frame rate and whether the video is HDR, when Mediabunny can determine those properties. diff --git a/src/core/audioExtract.ts b/src/core/audioExtract.ts index 187e8ec..632efa6 100644 --- a/src/core/audioExtract.ts +++ b/src/core/audioExtract.ts @@ -2,7 +2,7 @@ import { AUDIO_MONO_BPS, AUDIO_STEREO_BPS } from './autoSettings'; import { hasAudioProcessing, normalizeAudioProcessing, type AudioProcessing } from './audioProcess'; import { hasAudioMix, normalizeAudioMix, type AudioMix } from './audioMix'; import type { Capabilities, SourceInfo } from './types'; -import { keptDuration, normalizeCuts, type Cut } from './cuts'; +import { boundCuts, keptDuration, normalizeCuts, type Cut } from './cuts'; export type AudioFormat = 'mp3' | 'm4a' | 'wav'; @@ -66,7 +66,7 @@ export function validateAudioExtract( value.copy && !hasAudioProcessing(value.audioProcessing) && !hasAudioMix(value.mix) && - !value.cuts?.length && + !normalizeCuts(value.cuts, value.trim).length && format === 'm4a' && src.audio?.codec === 'aac' && src.audio.bitrate !== null && @@ -75,16 +75,11 @@ export function validateAudioExtract( return { format, - ...(value.cuts ? { cuts: normalizeCuts(value.cuts, value.trim) } : {}), - ...(value.mix - ? { mix: normalizeAudioMix(value.mix, keptDuration(value.trim, value.cuts)) } - : {}), + ...(value.cuts ? { cuts: boundCuts(value.cuts, src.durationSec) } : {}), + ...(value.mix ? { mix: normalizeAudioMix(value.mix, src.durationSec) } : {}), ...(value.audioProcessing ? { - audioProcessing: normalizeAudioProcessing( - value.audioProcessing, - keptDuration(value.trim, value.cuts), - ), + audioProcessing: normalizeAudioProcessing(value.audioProcessing, src.durationSec), } : {}), trim: value.trim, @@ -107,7 +102,7 @@ export function audioExtractChangeCount( const settings = validateAudioExtract(value, auto, src, caps); return ( Number(settings.trim.start !== auto.trim.start || settings.trim.end !== auto.trim.end) + - Number(!!settings.cuts?.length) + + Number(!!normalizeCuts(settings.cuts, settings.trim).length) + Number(settings.format !== auto.format) + Number(settings.format !== 'wav' && settings.bitrate !== auto.bitrate) + Number(settings.channels !== auto.channels) + @@ -142,7 +137,9 @@ export function estimateAudioBytes( const seconds = keptDuration(s.trim, s.cuts); if (s.format === 'wav') { const mid = - (hasAudioMix(s.mix) || s.cuts?.length ? 48000 : (src.audio?.sampleRate ?? 0)) * + (hasAudioMix(s.mix) || normalizeCuts(s.cuts, s.trim).length + ? 48000 + : (src.audio?.sampleRate ?? 0)) * s.channels * 2 * seconds + diff --git a/src/core/captions.ts b/src/core/captions.ts index 2e9247d..3310657 100644 --- a/src/core/captions.ts +++ b/src/core/captions.ts @@ -68,6 +68,27 @@ function seconds(value: string): number | null { return Number(h ?? 0) * 3600 + Number(m) * 60 + Number(s) + Number(ms!.padEnd(3, '0')) / 1000; } +// WebVTT cue text must escape & < >; files also carry nbsp, direction marks and numeric refs. +const ENTITIES: Record = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: '\u00a0', + lrm: '\u200e', + rlm: '\u200f', +}; + +function decodeEntities(text: string): string { + return text.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (match, name: string) => { + if (name[0] !== '#') return ENTITIES[name.toLowerCase()] ?? match; + const code = + name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : Number(name.slice(1)); + return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match; + }); +} + /** Reads SRT or WebVTT text. Styling tags are removed; cue settings and notes are ignored. */ export function parseSubtitles(text: string): Caption[] { const cues: Caption[] = []; @@ -84,10 +105,10 @@ export function parseSubtitles(text: string): Caption[] { const body = lines .slice(at + 1) .join('\n') - .replace(/<[^>]*>/g, '') - .trim(); - if (start !== null && end !== null && end > start && body) - cues.push({ start, end, text: body }); + .replace(/<[^>]*>/g, ''); + const cueText = decodeEntities(body).trim(); + if (start !== null && end !== null && end > start && cueText) + cues.push({ start, end, text: cueText }); } return cues; } diff --git a/src/core/cuts.ts b/src/core/cuts.ts index 96e728f..69c8cb5 100644 --- a/src/core/cuts.ts +++ b/src/core/cuts.ts @@ -4,6 +4,15 @@ export type Trim = { start: number; end: number }; const MIN_CUT = 0.1; const MAX_CUTS = 20; +export function boundCuts(cuts: readonly Cut[] | undefined, duration: number): Cut[] { + return (cuts ?? []) + .filter((cut) => Number.isFinite(cut.start) && Number.isFinite(cut.end)) + .map((cut) => ({ + start: Math.max(0, Math.min(duration, cut.start)), + end: Math.max(0, Math.min(duration, cut.end)), + })); +} + export function normalizeCuts(cuts: readonly Cut[] | undefined, trim: Trim): Cut[] { if (!cuts?.length || !Number.isFinite(trim.start) || !Number.isFinite(trim.end)) return []; const sorted = cuts diff --git a/src/core/gifExport.ts b/src/core/gifExport.ts index dadc63d..5d7afb1 100644 --- a/src/core/gifExport.ts +++ b/src/core/gifExport.ts @@ -2,10 +2,10 @@ import { CanvasSink, type Input } from 'mediabunny'; import { scaleToLongEdge } from './autoSettings'; import { createCaptionRenderer, hasCaptions, normalizeCaptions } from './captions'; import { + boundCuts, createJoinFade, keptDuration, normalizeCutFade, - normalizeCuts, outputToSource, sourceToOutput, type Cut, @@ -99,7 +99,7 @@ export function validateGifSettings( : auto.fps; return { trim: value.trim, - ...(value.cuts ? { cuts: normalizeCuts(value.cuts, value.trim) } : {}), + ...(value.cuts ? { cuts: boundCuts(value.cuts, source.durationSec) } : {}), size: longEdge(Math.min(size, Math.max(frame.width, frame.height))), fps: Math.min(fps, sourceFps), loop: value.loop === 'once' || value.loop === 'boomerang' ? value.loop : 'forever', diff --git a/src/ui/audioProcessingControls.ts b/src/ui/audioProcessingControls.ts index 161c121..181fd18 100644 --- a/src/ui/audioProcessingControls.ts +++ b/src/ui/audioProcessingControls.ts @@ -17,7 +17,7 @@ export function mountAudioProcessing( duration: number, onChange: (value: AudioProcessing) => void, ) { - let settings = normalizeAudioProcessing(value, duration); + let settings = normalizeAudioProcessing(value, Number.MAX_VALUE); const section = el('div', 'setting audio-processing-setting'); const head = el('div', 'setting-head'); const reset = el('button', 'setting-reset', 'Reset audio processing'); @@ -46,9 +46,7 @@ export function mountAudioProcessing( input.max = String(max); input.step = String(step); input.setAttribute('aria-label', name); - input.addEventListener('input', () => - onChange(normalizeAudioProcessing({ ...settings, [key]: input.valueAsNumber }, duration)), - ); + input.addEventListener('input', () => onChange({ ...settings, [key]: input.valueAsNumber })); label.append(output, input); section.append(label); fields.set(key, { input, output }); @@ -67,12 +65,7 @@ export function mountAudioProcessing( normalize.append(option); } normalize.addEventListener('change', () => - onChange( - normalizeAudioProcessing( - { ...settings, normalize: Number(normalize.value) as AudioProcessing['normalize'] }, - duration, - ), - ), + onChange({ ...settings, normalize: Number(normalize.value) as AudioProcessing['normalize'] }), ); const field = el('span', 'field'); field.append(normalize, icon('i-chev')); @@ -88,12 +81,13 @@ export function mountAudioProcessing( host.append(section); function update(value: Partial | undefined, span: number): void { duration = span; - settings = normalizeAudioProcessing(value, duration); + settings = normalizeAudioProcessing(value, Number.MAX_VALUE); + const active = normalizeAudioProcessing(settings, duration); for (const [key, { input, output }] of fields) { if (key !== 'gainDb') input.max = String(fadeMax(duration)); - input.value = String(settings[key]); - input.setAttribute('aria-valuetext', format(key, settings[key])); - output.textContent = format(key, settings[key]); + input.value = String(active[key]); + input.setAttribute('aria-valuetext', format(key, active[key])); + output.textContent = format(key, active[key]); } normalize.value = String(settings.normalize ?? 'off'); reset.hidden = !hasAudioProcessing(settings); diff --git a/src/ui/audioTracksControls.ts b/src/ui/audioTracksControls.ts index 6fd28e7..689ec18 100644 --- a/src/ui/audioTracksControls.ts +++ b/src/ui/audioTracksControls.ts @@ -26,7 +26,7 @@ export function mountAudioTracks( }, ) { let value: AudioTracksValue = opts; - let mix = normalizeAudioMix(value.mix, value.duration); + let mix = normalizeAudioMix(value.mix, Number.MAX_VALUE); // The Audio section's summary is the visible heading; the region label stays for assistive tech. const section = el('section', 'audio-tracks'); section.setAttribute('aria-label', 'Audio tracks'); @@ -137,7 +137,7 @@ export function mountAudioTracks( function update(next: AudioTracksValue): void { value = next; - mix = normalizeAudioMix(value.mix, value.duration); + mix = normalizeAudioMix(value.mix, Number.MAX_VALUE); include.checked = value.sourceHasAudio && mix.original.include; include.disabled = !value.sourceHasAudio; reset.hidden = mix.original.volumeDb === 0 && mix.original.include; @@ -158,8 +158,9 @@ export function mountAudioTracks( const range = addedOffsetRange(value.added?.duration ?? 0, value.duration); offset.input.min = String(range.min); offset.input.max = String(range.max); - offset.update(mix.added?.offset ?? 0); - const offsetText = describeAddedOffset(mix.added?.offset ?? 0); + const activeOffset = normalizeAudioMix(value.mix, value.duration).added?.offset ?? 0; + offset.update(activeOffset); + const offsetText = describeAddedOffset(activeOffset); offset.input.setAttribute('aria-valuetext', offsetText); offsetOutput.textContent = offsetText; loop.checked = mix.added?.loop ?? false; diff --git a/src/ui/captionPreview.ts b/src/ui/captionPreview.ts index 3b962c2..c30b3b1 100644 --- a/src/ui/captionPreview.ts +++ b/src/ui/captionPreview.ts @@ -5,7 +5,13 @@ import { normalizeCaptions, type CaptionSettings, } from '../core/captions'; -import { createJoinFade, normalizeCutFade, sourceToOutput, type Cut } from '../core/cuts'; +import { + createJoinFade, + normalizeCutFade, + normalizeCuts, + sourceToOutput, + type Cut, +} from '../core/cuts'; import { el } from './dom'; type Size = { width: number; height: number }; @@ -80,7 +86,8 @@ export function mountCaptionPreview(video: HTMLVideoElement, frame: HTMLElement) return { update(value: CaptionSettings | undefined, output: Size, cut?: CutFadeTarget) { const fade = normalizeCutFade(cut?.fade); - const nextFade = cut && fade && cut.cuts?.length ? JSON.stringify([cut, fade]) : ''; + const nextFade = + cut && fade && normalizeCuts(cut.cuts, cut.trim).length ? JSON.stringify([cut, fade]) : ''; if (nextFade !== fadeKey) { fadeKey = nextFade; const at = nextFade ? createJoinFade(cut!.trim, cut!.cuts, fade) : null; diff --git a/src/ui/controller.ts b/src/ui/controller.ts index 75b0667..a365c81 100644 --- a/src/ui/controller.ts +++ b/src/ui/controller.ts @@ -2,8 +2,8 @@ import { autoAudioExtract, validateAudioExtract } from '../core/audioExtract'; import { retryTargetSize } from '../core/targetSize'; import { autoGifSettings, validateGifSettings } from '../core/gifExport'; import { clampTrim } from './trimBar'; -import { keptSeconds, withCuts } from './controllerCuts'; -import { keptDuration, normalizeCuts } from '../core/cuts'; +import { withCuts } from './controllerCuts'; +import { boundCuts } from '../core/cuts'; import { normalizeAudioProcessing } from '../core/audioProcess'; import { normalizeAudioMix, type AddedAudio, type AudioMix } from '../core/audioMix'; import { readAddedAudio } from '../core/addedAudio'; @@ -145,7 +145,7 @@ export function createController( if (state.kind === 'prepare' && !state.exporting) { addedToken++; addedRead?.abort(); - const mix = normalizeAudioMix(state.settings.mix, keptSeconds(state)); + const mix = normalizeAudioMix(state.settings.mix, state.source.durationSec); delete mix.added; applyMix({ ...state, addingAudio: undefined }, mix, undefined); } @@ -153,7 +153,7 @@ export function createController( case 'setMix': if (state.kind === 'prepare' && !state.exporting) { supersedePending(); - const mix = normalizeAudioMix(action.value, keptSeconds(state)); + const mix = normalizeAudioMix(action.value, state.source.durationSec); if (!state.addedAudio) delete mix.added; applyMix(state, mix, state.addedAudio); } @@ -161,7 +161,7 @@ export function createController( case 'setAudioProcessing': if (state.kind === 'prepare') { supersedePending(); - const audioProcessing = normalizeAudioProcessing(action.value, keptSeconds(state)); + const audioProcessing = normalizeAudioProcessing(action.value, state.source.durationSec); setState({ ...state, exporting: undefined, @@ -237,8 +237,7 @@ export function createController( supersedePending(); const moved = action.value.start !== state.settings.trim.start ? 'start' : 'end'; const trim = clampTrim(action.value, state.source.durationSec, moved); - const cuts = normalizeCuts(state.settings.cuts, trim); - const seconds = keptDuration(trim, cuts); + const cuts = boundCuts(state.settings.cuts, state.source.durationSec); setState({ ...state, notice: unlessMemoryConfirm(state.notice), @@ -254,33 +253,11 @@ export function createController( ...state.settings, trim, cuts, - ...(state.settings.mix - ? { mix: normalizeAudioMix(state.settings.mix, seconds) } - : {}), - ...(state.settings.audioProcessing - ? { - audioProcessing: normalizeAudioProcessing( - state.settings.audioProcessing, - seconds, - ), - } - : {}), }, audioSettings: state.audioSettings && { ...state.audioSettings, trim, cuts, - ...(state.audioSettings.mix - ? { mix: normalizeAudioMix(state.audioSettings.mix, seconds) } - : {}), - ...(state.audioSettings.audioProcessing - ? { - audioProcessing: normalizeAudioProcessing( - state.audioSettings.audioProcessing, - seconds, - ), - } - : {}), }, gifSettings: state.gifSettings && { ...state.gifSettings, trim, cuts }, }); @@ -490,7 +467,7 @@ export function createController( added.input.dispose(); return; } - const mix = normalizeAudioMix(state.settings.mix, keptSeconds(state)); + const mix = normalizeAudioMix(state.settings.mix, state.source.durationSec); mix.added = { volumeDb: 0, offset: 0, loop: false }; applyMix({ ...state, addingAudio: undefined }, mix, added); } catch (error) { diff --git a/src/ui/controllerCuts.ts b/src/ui/controllerCuts.ts index 1ffd1a2..7f58223 100644 --- a/src/ui/controllerCuts.ts +++ b/src/ui/controllerCuts.ts @@ -1,16 +1,10 @@ -import { normalizeAudioMix } from '../core/audioMix'; -import { normalizeAudioProcessing } from '../core/audioProcess'; -import { keptDuration, normalizeCuts, type CutSettings } from '../core/cuts'; -import type { Settings } from '../core/types'; +import { boundCuts, normalizeCuts, type CutSettings } from '../core/cuts'; import type { AppState } from './controllerTypes'; import { validateSettings } from './controllerSettings'; -export const keptSeconds = (state: { settings: Pick }) => - keptDuration(state.settings.trim, state.settings.cuts); - export function withCuts(state: Extract, value: CutSettings) { - const cuts = normalizeCuts(value.cuts, state.settings.trim); - const seconds = keptDuration(state.settings.trim, cuts); + const cuts = boundCuts(value.cuts, state.source.durationSec); + const activeCuts = normalizeCuts(cuts, state.settings.trim); return { ...state, notice: state.notice?.confirm === 'memory' ? undefined : state.notice, @@ -20,7 +14,7 @@ export function withCuts(state: Extract, value: C ...state.settings, cuts, cutFade: value.fade, - passthrough: cuts.length ? false : state.settings.passthrough, + passthrough: activeCuts.length ? false : state.settings.passthrough, targetRetryScale: undefined, }, state.auto, @@ -30,15 +24,7 @@ export function withCuts(state: Extract, value: C audioSettings: state.audioSettings && { ...state.audioSettings, cuts, - copy: cuts.length ? false : state.audioSettings.copy, - ...(state.audioSettings.mix - ? { mix: normalizeAudioMix(state.audioSettings.mix, seconds) } - : {}), - ...(state.audioSettings.audioProcessing - ? { - audioProcessing: normalizeAudioProcessing(state.audioSettings.audioProcessing, seconds), - } - : {}), + copy: activeCuts.length ? false : state.audioSettings.copy, }, gifSettings: state.gifSettings && { ...state.gifSettings, cuts }, }; diff --git a/src/ui/controllerSettings.ts b/src/ui/controllerSettings.ts index 32c4c49..563bffa 100644 --- a/src/ui/controllerSettings.ts +++ b/src/ui/controllerSettings.ts @@ -25,7 +25,7 @@ import type { } from '../core/types'; import { formatBitrate } from './format'; import { clampTrim } from './trimBar'; -import { keptDuration, normalizeCutFade, normalizeCuts } from '../core/cuts'; +import { boundCuts, normalizeCutFade, normalizeCuts } from '../core/cuts'; import type { AppState, Loaded } from './controllerTypes'; const CODEC_LABELS: Record = { @@ -236,9 +236,9 @@ export function validateSettings( ): Settings { const fallback = auto.settings; const trim = clampTrim(value.trim, source.durationSec, 'end'); - const cuts = normalizeCuts(value.cuts, trim); - const seconds = keptDuration(trim, cuts); - const mix = value.mix ? normalizeAudioMix(value.mix, seconds) : undefined; + const cuts = boundCuts(value.cuts, source.durationSec); + const activeCuts = normalizeCuts(cuts, trim); + const mix = value.mix ? normalizeAudioMix(value.mix, source.durationSec) : undefined; const overlays = normalizeOverlays(value.overlays); const style = normalizeStyle(value.style); const fit = normalizeFit(value.fit); @@ -312,7 +312,7 @@ export function validateSettings( const requested = requestedAudio.bitrate; const bitrate = allowed.includes(requested) ? requested : Math.min(requested, max); const copy = - !cuts.length && + !activeCuts.length && !hasAudioProcessing(value.audioProcessing) && !hasAudioMix(mix) && container === fallback.container && @@ -349,7 +349,7 @@ export function validateSettings( ...(mix ? { mix } : {}), ...(value.audioProcessing ? { - audioProcessing: normalizeAudioProcessing(value.audioProcessing, seconds), + audioProcessing: normalizeAudioProcessing(value.audioProcessing, source.durationSec), } : {}), targetSizeMB, @@ -365,7 +365,7 @@ export function validateSettings( effectiveRect || value.keyFrameInterval || value.trimPrecision === 'exact' || - cuts.length || + activeCuts.length || hasOverlays(overlays) || hasCaptions(value.captions) || hasStyleEffects(style) || @@ -400,13 +400,14 @@ export function validateSettings( /** Compare the effective values shown by the controls, after applying the same limits to both sides. */ // Trim and the edit sections (audio, colour, style, overlays) count in their own places. function editChangeCount(current: Settings, auto: AutoResult): number { + const hasCuts = normalizeCuts(current.cuts, current.trim).length > 0; return ( Number( current.trim.start !== auto.settings.trim.start || current.trim.end !== auto.settings.trim.end, ) + - Number(!!current.cuts?.length) + - Number(!!current.cuts?.length && normalizeCutFade(current.cutFade) > 0) + + Number(hasCuts) + + Number(hasCuts && normalizeCutFade(current.cutFade) > 0) + Number(hasCaptions(current.captions)) + Number(!!current.overlays?.text.enabled) + Number(!!current.overlays?.image.enabled) + diff --git a/src/ui/cutControls.ts b/src/ui/cutControls.ts index f3a53fb..de12742 100644 --- a/src/ui/cutControls.ts +++ b/src/ui/cutControls.ts @@ -34,7 +34,7 @@ export function mountCutControls( add.addEventListener('click', () => { if (!onChange || cuts.length >= 20) return; const start = Math.min(Math.max(video.currentTime, trim.start), trim.end - 0.1); - onChange(normalizeCuts([...cuts, { start, end: Math.min(trim.end, start + 1) }], trim)); + onChange([...cuts, { start, end: Math.min(trim.end, start + 1) }]); }); const transition = el('select', 'setting-select'); transition.setAttribute('aria-label', 'Cut transition'); @@ -75,7 +75,7 @@ export function mountCutControls( function render() { marks.replaceChildren( - ...cuts.map((cut) => { + ...normalizeCuts(cuts, trim).map((cut) => { const mark = el('span', 'trim-cut-mark'); mark.style.left = `${duration > 0 ? (cut.start / duration) * 100 : 0}%`; mark.style.width = `${duration > 0 ? ((cut.end - cut.start) / duration) * 100 : 0}%`; @@ -83,9 +83,15 @@ export function mountCutControls( }), ); list.replaceChildren(); - cuts.forEach((cut, index) => { + const visibleCuts = normalizeCuts(cuts, trim).map((cut) => ({ + cut, + indices: cuts.flatMap((item, index) => + item.start < cut.end && item.end > cut.start ? [index] : [], + ), + })); + visibleCuts.forEach(({ cut, indices }, rowIndex) => { const row = el('div', 'cut-row'); - row.append(el('span', 'cut-row-label', `Cut ${index + 1}`)); + row.append(el('span', 'cut-row-label', `Cut ${rowIndex + 1}`)); const error = el( 'span', 'setting-reason trim-error', @@ -97,7 +103,7 @@ export function mountCutControls( input.type = 'text'; input.inputMode = 'text'; input.value = formatDuration(cut[side], { tenths: true }); - input.setAttribute('aria-label', `Cut ${index + 1} ${side}`); + input.setAttribute('aria-label', `Cut ${rowIndex + 1} ${side}`); const commit = (time: number | null) => { const next = { ...cut, [side]: time }; // Moving one edge past the other carries the cut along at its current length, so @@ -120,9 +126,8 @@ export function mountCutControls( error.hidden = true; input.setAttribute('aria-invalid', 'false'); onChange?.( - normalizeCuts( - cuts.map((item, at) => (at === index ? (next as Cut) : item)), - trim, + cuts.flatMap((item, at) => + at === indices[0] ? [next as Cut] : indices.includes(at) ? [] : [item], ), ); }; @@ -145,7 +150,7 @@ export function mountCutControls( }); const set = el('button', 'btn btn-cue', 'Set'); set.type = 'button'; - set.title = `Set cut ${index + 1} ${side} to playhead`; + set.title = `Set cut ${rowIndex + 1} ${side} to playhead`; set.setAttribute('aria-label', set.title); set.addEventListener('click', () => commit(video.currentTime)); const group = el('span', `cut-time cut-time-${side}`); @@ -154,13 +159,15 @@ export function mountCutControls( } const remove = el('button', 'btn btn-cue cut-remove', 'Remove'); remove.type = 'button'; - remove.setAttribute('aria-label', `Remove cut ${index + 1}`); - remove.addEventListener('click', () => onChange?.(cuts.filter((_, at) => at !== index))); + remove.setAttribute('aria-label', `Remove cut ${rowIndex + 1}`); + remove.addEventListener('click', () => + onChange?.(cuts.filter((_, at) => !indices.includes(at))), + ); row.append(remove, error); list.append(row); }); add.disabled = !onChange || cuts.length >= 20 || trim.end - trim.start < 0.2; - transitionRow.hidden = !cuts.length; + transitionRow.hidden = !visibleCuts.length; transition.value = fade > 0 ? 'fade' : 'none'; fadeLength.value = String(fade > 0 ? fade : 0.5); fadeLabel.hidden = fade <= 0; @@ -184,6 +191,6 @@ export function mountCutControls( export function previewSkip(time: number, trim: Trim, cuts: Cut[]): number | null { if (time >= trim.end || time < trim.start) return trim.start; - const cut = cuts.find((item) => time >= item.start && time < item.end); + const cut = normalizeCuts(cuts, trim).find((item) => time >= item.start && time < item.end); return cut ? (cut.end < trim.end ? cut.end : trim.start) : null; } diff --git a/src/ui/mixWaveform.ts b/src/ui/mixWaveform.ts index d025039..dbb9fc6 100644 --- a/src/ui/mixWaveform.ts +++ b/src/ui/mixWaveform.ts @@ -2,6 +2,7 @@ import type { Input } from 'mediabunny'; import { addedOffsetRange, describeAddedOffset, + normalizeAudioMix, type AddedAudio, type AudioMix, } from '../core/audioMix'; @@ -14,7 +15,7 @@ import { } from '../core/audioProcess'; import { el } from './dom'; import type { Trim } from './trimBar'; -import { keptDuration, sourceToOutput, type Cut } from '../core/cuts'; +import { keptDuration, normalizeCuts, sourceToOutput, type Cut } from '../core/cuts'; import { computePeaks } from './waveform'; import '../styles/audioMix.css'; @@ -121,7 +122,11 @@ export function mountMixWaveform( let drag: { pointer: number; x: number; offset: number } | null = null; addedCanvas.addEventListener('pointerdown', (event) => { if (!value.mix?.added || !onAddedOffset) return; - drag = { pointer: event.pointerId, x: event.clientX, offset: value.mix.added.offset }; + drag = { + pointer: event.pointerId, + x: event.clientX, + offset: normalizeAudioMix(value.mix, keptDuration(value.trim, value.cuts)).added!.offset, + }; addedCanvas.setPointerCapture(event.pointerId); addedCanvas.classList.add('is-dragging'); }); @@ -138,7 +143,7 @@ export function mountMixWaveform( addedCanvas.addEventListener('pointerup', endDrag); addedCanvas.addEventListener('pointercancel', endDrag); addedCanvas.addEventListener('keydown', (event) => { - const offset = value.mix?.added?.offset; + const offset = normalizeAudioMix(value.mix, keptDuration(value.trim, value.cuts)).added?.offset; if (offset === undefined) return; const step = event.shiftKey ? 1 : 0.1; const { min, max } = offsetRange(); @@ -183,8 +188,9 @@ export function mountMixWaveform( const span = keptDuration(trim, value.cuts); const master = normalizeAudioProcessing(value.processing, span); const normalizationDb = value.normalizationDb ?? 0; - const original = value.mix?.original ?? { include: true, volumeDb: 0 }; - const added = value.mix?.added; + const mix = normalizeAudioMix(value.mix, span); + const original = mix.original; + const added = mix.added; section.hidden = !sourceHasAudio && !value.added; const theme = getComputedStyle(section); const accent = theme.getPropertyValue('--accent').trim(); @@ -300,7 +306,7 @@ export function mountMixWaveform( } } context.globalAlpha = 1; - for (const cut of value.cuts ?? []) { + for (const cut of normalizeCuts(value.cuts, trim)) { const left = (cut.start / sourceDuration) * width; const span = ((cut.end - cut.start) / sourceDuration) * width; context.fillStyle = 'rgba(0, 0, 0, 0.42)'; diff --git a/src/ui/previewAudio.ts b/src/ui/previewAudio.ts index a16a850..48ca844 100644 --- a/src/ui/previewAudio.ts +++ b/src/ui/previewAudio.ts @@ -3,6 +3,7 @@ import { createJoinFade, keptDuration, normalizeCutFade, + normalizeCuts, sourceToOutput, type Cut, } from '../core/cuts'; @@ -338,7 +339,7 @@ export function mountPreviewAudio( analysis = controller; onAnalysing(true); const measure = async () => { - if (hasAudioMix(mix) || cuts?.length) { + if (hasAudioMix(mix) || normalizeCuts(cuts, trim).length) { const processor = createMixProcessor( input, added, diff --git a/tests/unit/audioExtract.test.ts b/tests/unit/audioExtract.test.ts index ff0bc5f..1241dad 100644 --- a/tests/unit/audioExtract.test.ts +++ b/tests/unit/audioExtract.test.ts @@ -60,6 +60,19 @@ describe('audio extract rules', () => { expect(estimateAudioBytes(cut, src).mid).toBeCloseTo(((96000 * 54) / 8) * 1.01); expect(audioExtractChangeCount(cut, base, src, caps())).toBe(1); }); + it('keeps cuts outside the trim without counting them or disabling copy', () => { + const src = source({ bitrate: 96000 }); + const base = { ...auto(src), format: 'm4a' as const, copy: true }; + const selected = { + ...base, + trim: { start: 0, end: 5 }, + cuts: [{ start: 10, end: 12 }], + }; + const validated = validateAudioExtract(selected, base, src, caps()); + expect(validated.cuts).toEqual(selected.cuts); + expect(validated.copy).toBe(true); + expect(audioExtractChangeCount(selected, base, src, caps())).toBe(1); + }); it('normalizes mix settings and preserves copy eligibility only for a neutral mix', () => { const src = source({ bitrate: 96000 }); const automatic = auto(src); @@ -86,7 +99,7 @@ describe('audio extract rules', () => { expect(mixed.copy).toBe(false); expect(mixed.mix).toEqual({ original: { include: false, volumeDb: -20 }, - added: { volumeDb: 20, offset: 3, loop: true }, + added: { volumeDb: 20, offset: src.durationSec, loop: true }, }); }); diff --git a/tests/unit/captions.test.ts b/tests/unit/captions.test.ts index f22d0b6..c02872a 100644 --- a/tests/unit/captions.test.ts +++ b/tests/unit/captions.test.ts @@ -105,3 +105,10 @@ describe('captionAt and overlays', () => { expect(portrait.text.fontSize * 1920).toBeCloseTo(landscape.text.fontSize * 1080, 6); }); }); + +describe('subtitle character references', () => { + it('decodes WebVTT escapes in cue text', () => { + const vtt = 'WEBVTT\n\n00:00.000 --> 00:01.000\nTom & Jerry <3\n'; + expect(parseSubtitles(vtt)[0]!.text).toBe('Tom & Jerry <3'); + }); +}); diff --git a/tests/unit/controller.test.ts b/tests/unit/controller.test.ts index 5803ca5..7437ace 100644 --- a/tests/unit/controller.test.ts +++ b/tests/unit/controller.test.ts @@ -311,6 +311,38 @@ describe('trim changes', () => { }); }); +describe('trim and cuts', () => { + it('keeps a cut when a trim handle is dragged past it and back', async () => { + const { ctl, pending } = setup(); + ctl.dispatch({ type: 'pick', file: fakeFile('a.mp4') }); + await flush(); + pending[0]!.resolve(); + await flush(); + ctl.dispatch({ type: 'setCuts', value: { cuts: [{ start: 1.5, end: 2 }], fade: 0 } }); + // A drag dispatches every pointer move: across the cut, then back out. + ctl.dispatch({ type: 'setTrim', value: { start: 0, end: 1 } }); + ctl.dispatch({ type: 'setTrim', value: { start: 0, end: 3 } }); + expect(ctl.getState()).toMatchObject({ + kind: 'prepare', + settings: { cuts: [{ start: 1.5, end: 2 }] }, + }); + }); + it('keeps master fade when trim shrinks and widens', async () => { + const { ctl, pending } = setup(); + ctl.dispatch({ type: 'pick', file: fakeFile('a.mp4') }); + await flush(); + pending[0]!.resolve(); + await flush(); + ctl.dispatch({ + type: 'setAudioProcessing', + value: { gainDb: 0, fadeIn: 0, fadeOut: 2, normalize: null }, + }); + ctl.dispatch({ type: 'setTrim', value: { start: 0, end: 1 } }); + ctl.dispatch({ type: 'setTrim', value: { start: 0, end: 3 } }); + expect(ctl.getState()).toMatchObject({ settings: { audioProcessing: { fadeOut: 2 } } }); + }); +}); + describe('prepare settings', () => { it('carries trimmed-copy mode through the export action', () => { const { ctl } = setup(); diff --git a/tests/unit/convert.test.ts b/tests/unit/convert.test.ts index 15147f2..9263940 100644 --- a/tests/unit/convert.test.ts +++ b/tests/unit/convert.test.ts @@ -70,6 +70,15 @@ describe('conversion jobs', () => { audioExtractJob({ ...audio, cuts: [{ start: 0.8, end: 1.1 }] }).customAudio, ).toBeDefined(); }); + it('ignores stored cuts outside the trim in both export jobs', () => { + const cuts = [{ start: 3, end: 4 }]; + const video = shrinkJob({ ...base, passthrough: true, cuts }); + expect(video.copiesVideo).toBe(true); + expect(video.customAudio).toBeUndefined(); + expect( + audioExtractJob({ ...audio, format: 'm4a', copy: true, cuts }).customAudio, + ).toBeUndefined(); + }); it('re-encodes Fit at the foreground size and emits the fitted frame size', () => { const source = { video: { displayWidth: 1920, displayHeight: 1080 }, diff --git a/tests/unit/cuts.test.ts b/tests/unit/cuts.test.ts index 61188d7..a7ac412 100644 --- a/tests/unit/cuts.test.ts +++ b/tests/unit/cuts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + boundCuts, createJoinFade, keptDuration, keptRanges, @@ -16,6 +17,21 @@ const cuts = [ ]; describe('cut sections', () => { + it('bounds stored cuts to the source without trimming them', () => { + expect( + boundCuts( + [ + { start: -2, end: 2 }, + { start: 8, end: 12 }, + { start: NaN, end: 5 }, + ], + 10, + ), + ).toEqual([ + { start: 0, end: 2 }, + { start: 8, end: 10 }, + ]); + }); it('sorts, clamps, merges touching ranges, and drops short or invalid cuts', () => { expect( normalizeCuts( diff --git a/tests/unit/settings.test.ts b/tests/unit/settings.test.ts index f2c4b05..3265ff6 100644 --- a/tests/unit/settings.test.ts +++ b/tests/unit/settings.test.ts @@ -55,6 +55,38 @@ describe('settings validation', () => { expect(changed.audio?.copy).toBe(false); expect(changed.passthrough).toBe(true); }); + it('ignores stored cuts outside the trim for copy and change counts', () => { + const settings = validateSettings( + { + ...auto.settings, + trim: { start: 0, end: 5 }, + passthrough: true, + cuts: [{ start: 10, end: 11 }], + }, + auto, + caps, + source, + ); + expect(settings.cuts).toEqual([{ start: 10, end: 11 }]); + expect(settings.audio?.copy).toBe(auto.settings.audio?.copy); + expect(settings.passthrough).toBe(true); + expect(settingsChangeCount(settings, auto, caps, source)).toBe(1); + }); + it('retains an added-audio offset across trim changes', () => { + const mix = { + original: { include: true, volumeDb: 0 }, + added: { volumeDb: 0, offset: 10, loop: false }, + }; + const short = validateSettings( + { ...auto.settings, trim: { start: 0, end: 5 }, mix }, + auto, + caps, + source, + ); + expect(short.mix?.added?.offset).toBe(10); + const wide = validateSettings({ ...short, trim: { start: 0, end: 20 } }, auto, caps, source); + expect(wide.mix?.added?.offset).toBe(10); + }); it('uses original include as the audio removal control', () => { const removed = validateSettings( { ...auto.settings, mix: { original: { include: false, volumeDb: 0 } } }, @@ -71,7 +103,7 @@ describe('settings validation', () => { ); expect(restored.audio).not.toBeNull(); }); - it('budgets added audio on a silent source and clamps the mix to the trim', () => { + it('budgets added audio on a silent source and preserves its offset', () => { const silent = { ...source, audio: null }; const silentAuto = autoSettings(silent, caps); const mixed = validateSettings( @@ -89,7 +121,7 @@ describe('settings validation', () => { ); expect(mixed.mix).toEqual({ original: { include: false, volumeDb: -20 }, - added: { volumeDb: 20, offset: 2, loop: true }, + added: { volumeDb: 20, offset: 20, loop: true }, }); expect(mixed.audio).toMatchObject({ channels: 2, copy: false }); expect(estimateBytes(mixed, silent).mid).toBeGreaterThan( @@ -106,7 +138,7 @@ describe('settings validation', () => { expect(withAudio.audio).not.toBeNull(); expect(withAudio.video!.bitrate).toBeLessThan(withoutAudio.video!.bitrate); }); - it('keeps video copy eligible with processed audio and clamps fades to the trim', () => { + it('keeps video copy eligible with processed audio and preserves fades', () => { const settings = validateSettings( { ...auto.settings, @@ -120,7 +152,7 @@ describe('settings validation', () => { ); expect(settings.passthrough).toBe(true); expect(settings.audio?.copy).toBe(false); - expect(settings.audioProcessing).toMatchObject({ gainDb: 3, fadeIn: 1, fadeOut: 1 }); + expect(settings.audioProcessing).toMatchObject({ gainDb: 3, fadeIn: 2, fadeOut: 2 }); expect(settingsChangeCount(settings, auto, caps, source)).toBe(2); }); it('validates overlays and counts independently enabled layers', () => {