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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 9 additions & 12 deletions src/core/audioExtract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 &&
Expand All @@ -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,
Expand All @@ -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) +
Expand Down Expand Up @@ -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 +
Expand Down
29 changes: 25 additions & 4 deletions src/core/captions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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[] = [];
Expand All @@ -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;
}
Expand Down
9 changes: 9 additions & 0 deletions src/core/cuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/core/gifExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
22 changes: 8 additions & 14 deletions src/ui/audioProcessingControls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 });
Expand All @@ -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'));
Expand All @@ -88,12 +81,13 @@ export function mountAudioProcessing(
host.append(section);
function update(value: Partial<AudioProcessing> | 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);
Expand Down
9 changes: 5 additions & 4 deletions src/ui/audioTracksControls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
11 changes: 9 additions & 2 deletions src/ui/captionPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
Expand Down
37 changes: 7 additions & 30 deletions src/ui/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -145,23 +145,23 @@ 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);
}
return;
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);
}
return;
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,
Expand Down Expand Up @@ -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),
Expand All @@ -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 },
});
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading