release: publish Twitch VOD Manager 1.0.5
This commit is contained in:
@@ -25,7 +25,7 @@ describe.runIf(process.platform === 'win32')('prepareWindowsDevExecutable', () =
|
||||
sourcePath,
|
||||
destinationPath,
|
||||
iconPath,
|
||||
version: '1.0.4'
|
||||
version: '1.0.5'
|
||||
});
|
||||
|
||||
const executable = ResEdit.NtExecutable.from(fs.readFileSync(destinationPath));
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { calculateCutterExportProgress, createCutterExportPlan } from './cutter-export';
|
||||
|
||||
describe('cutter export segments', () => {
|
||||
test('sorts playable segments and preserves the caller input', () => {
|
||||
const segments = [
|
||||
{ start: 30, end: 45 },
|
||||
{ start: 5, end: 20 },
|
||||
];
|
||||
|
||||
const plan = createCutterExportPlan({
|
||||
inputFile: 'D:\\media\\source.mp4',
|
||||
outputFile: 'D:\\media\\result.mp4',
|
||||
segments,
|
||||
hasAudio: true,
|
||||
});
|
||||
|
||||
expect(plan.segments).toEqual([
|
||||
{ start: 5, end: 20 },
|
||||
{ start: 30, end: 45 },
|
||||
]);
|
||||
expect(plan.remainingDuration).toBe(30);
|
||||
expect(segments).toEqual([
|
||||
{ start: 30, end: 45 },
|
||||
{ start: 5, end: 20 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('builds an audio and video concat filter with a separated argument list', () => {
|
||||
const plan = createCutterExportPlan({
|
||||
inputFile: 'D:\\media folder\\source.mp4',
|
||||
outputFile: 'D:\\exports\\result.mp4',
|
||||
segments: [
|
||||
{ start: 5, end: 20 },
|
||||
{ start: 30.5, end: 45.25 },
|
||||
],
|
||||
hasAudio: true,
|
||||
});
|
||||
|
||||
expect(plan.filterComplex).toBe('[0:v]trim=start=5:end=20,setpts=PTS-STARTPTS[v0];[0:a]atrim=start=5:end=20,asetpts=PTS-STARTPTS[a0];[0:v]trim=start=30.5:end=45.25,setpts=PTS-STARTPTS[v1];[0:a]atrim=start=30.5:end=45.25,asetpts=PTS-STARTPTS[a1];[v0][a0][v1][a1]concat=n=2:v=1:a=1[outv][outa]');
|
||||
expect(plan.ffmpegArgs).toEqual([
|
||||
'-i', 'D:\\media folder\\source.mp4',
|
||||
'-filter_complex', plan.filterComplex,
|
||||
'-map', '[outv]',
|
||||
'-map', '[outa]',
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'veryfast',
|
||||
'-crf', '20',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '160k',
|
||||
'-movflags', '+faststart',
|
||||
'-progress', 'pipe:1',
|
||||
'-y', 'D:\\exports\\result.mp4',
|
||||
]);
|
||||
});
|
||||
|
||||
test('builds a video-only concat filter without audio mappings or codecs', () => {
|
||||
const plan = createCutterExportPlan({
|
||||
inputFile: 'input.mkv',
|
||||
outputFile: 'output.mp4',
|
||||
segments: [
|
||||
{ start: 0, end: 10 },
|
||||
{ start: 12, end: 18 },
|
||||
],
|
||||
hasAudio: false,
|
||||
});
|
||||
|
||||
expect(plan.filterComplex).toBe('[0:v]trim=start=0:end=10,setpts=PTS-STARTPTS[v0];[0:v]trim=start=12:end=18,setpts=PTS-STARTPTS[v1];[v0][v1]concat=n=2:v=1:a=0[outv]');
|
||||
expect(plan.ffmpegArgs).toEqual([
|
||||
'-i', 'input.mkv',
|
||||
'-filter_complex', plan.filterComplex,
|
||||
'-map', '[outv]',
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'veryfast',
|
||||
'-crf', '20',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-an',
|
||||
'-movflags', '+faststart',
|
||||
'-progress', 'pipe:1',
|
||||
'-y', 'output.mp4',
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ name: 'empty segment list', segments: [] },
|
||||
{ name: 'negative start', segments: [{ start: -1, end: 2 }] },
|
||||
{ name: 'zero duration', segments: [{ start: 2, end: 2 }] },
|
||||
{ name: 'reversed range', segments: [{ start: 3, end: 2 }] },
|
||||
{ name: 'non-finite start', segments: [{ start: Number.NaN, end: 2 }] },
|
||||
{ name: 'non-finite end', segments: [{ start: 1, end: Number.POSITIVE_INFINITY }] },
|
||||
{ name: 'range below export precision', segments: [{ start: 1, end: 1.0000000001 }] },
|
||||
{ name: 'overlap after sorting', segments: [{ start: 10, end: 20 }, { start: 5, end: 12 }] },
|
||||
])('rejects $name', ({ segments }) => {
|
||||
expect(() => createCutterExportPlan({
|
||||
inputFile: 'input.mp4',
|
||||
outputFile: 'output.mp4',
|
||||
segments,
|
||||
hasAudio: true,
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
test('calculates progress against the remaining segment duration and clamps it', () => {
|
||||
const plan = createCutterExportPlan({
|
||||
inputFile: 'input.mp4',
|
||||
outputFile: 'output.mp4',
|
||||
segments: [{ start: 10, end: 20 }, { start: 50, end: 70 }],
|
||||
hasAudio: true,
|
||||
});
|
||||
|
||||
expect(calculateCutterExportProgress(0, plan)).toBe(0);
|
||||
expect(calculateCutterExportProgress(15, plan)).toBe(50);
|
||||
expect(calculateCutterExportProgress(45, plan)).toBe(100);
|
||||
expect(calculateCutterExportProgress(-5, plan)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { EditorSegment } from './video-editor';
|
||||
|
||||
export interface CutterExportPlanOptions {
|
||||
inputFile: string;
|
||||
outputFile: string;
|
||||
segments: readonly EditorSegment[];
|
||||
hasAudio: boolean;
|
||||
}
|
||||
|
||||
export interface CutterExportPlan {
|
||||
segments: EditorSegment[];
|
||||
remainingDuration: number;
|
||||
filterComplex: string;
|
||||
ffmpegArgs: string[];
|
||||
}
|
||||
|
||||
const precision = 9;
|
||||
|
||||
function round(value: number): number {
|
||||
return Number(value.toFixed(precision));
|
||||
}
|
||||
|
||||
function formatSeconds(value: number): string {
|
||||
return value.toFixed(precision).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
function validatePath(value: string, name: string): string {
|
||||
if (!value.trim()) throw new Error(`${name} must not be empty`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeSegments(segments: readonly EditorSegment[]): EditorSegment[] {
|
||||
if (segments.length === 0) throw new Error('At least one playable segment is required');
|
||||
const normalized = segments.map((segment) => {
|
||||
if (!Number.isFinite(segment.start) || !Number.isFinite(segment.end)) {
|
||||
throw new Error('Segment boundaries must be finite');
|
||||
}
|
||||
if (segment.start < 0 || segment.end <= segment.start) {
|
||||
throw new Error('Segment boundaries must be ordered and non-negative');
|
||||
}
|
||||
const start = round(segment.start);
|
||||
const end = round(segment.end);
|
||||
if (end <= start) throw new Error('Segment duration is below export precision');
|
||||
return { start, end };
|
||||
}).sort((left, right) => left.start - right.start || left.end - right.end);
|
||||
|
||||
for (let index = 1; index < normalized.length; index += 1) {
|
||||
if (normalized[index].start < normalized[index - 1].end) {
|
||||
throw new Error('Playable segments must not overlap');
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function createFilterComplex(segments: readonly EditorSegment[], hasAudio: boolean): string {
|
||||
const filters: string[] = [];
|
||||
const concatInputs: string[] = [];
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
const start = formatSeconds(segment.start);
|
||||
const end = formatSeconds(segment.end);
|
||||
filters.push(`[0:v]trim=start=${start}:end=${end},setpts=PTS-STARTPTS[v${index}]`);
|
||||
concatInputs.push(`[v${index}]`);
|
||||
if (hasAudio) {
|
||||
filters.push(`[0:a]atrim=start=${start}:end=${end},asetpts=PTS-STARTPTS[a${index}]`);
|
||||
concatInputs.push(`[a${index}]`);
|
||||
}
|
||||
});
|
||||
|
||||
filters.push(`${concatInputs.join('')}concat=n=${segments.length}:v=1:a=${hasAudio ? 1 : 0}[outv]${hasAudio ? '[outa]' : ''}`);
|
||||
return filters.join(';');
|
||||
}
|
||||
|
||||
function createFfmpegArgs(inputFile: string, outputFile: string, filterComplex: string, hasAudio: boolean): string[] {
|
||||
const args = [
|
||||
'-i', inputFile,
|
||||
'-filter_complex', filterComplex,
|
||||
'-map', '[outv]',
|
||||
];
|
||||
if (hasAudio) args.push('-map', '[outa]');
|
||||
args.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p');
|
||||
if (hasAudio) args.push('-c:a', 'aac', '-b:a', '160k');
|
||||
else args.push('-an');
|
||||
args.push('-movflags', '+faststart', '-progress', 'pipe:1', '-y', outputFile);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function createCutterExportPlan(options: CutterExportPlanOptions): CutterExportPlan {
|
||||
const inputFile = validatePath(options.inputFile, 'inputFile');
|
||||
const outputFile = validatePath(options.outputFile, 'outputFile');
|
||||
const segments = normalizeSegments(options.segments);
|
||||
const remainingDuration = round(segments.reduce((total, segment) => total + segment.end - segment.start, 0));
|
||||
const filterComplex = createFilterComplex(segments, options.hasAudio);
|
||||
return {
|
||||
segments,
|
||||
remainingDuration,
|
||||
filterComplex,
|
||||
ffmpegArgs: createFfmpegArgs(inputFile, outputFile, filterComplex, options.hasAudio),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateCutterExportProgress(processedSeconds: number, plan: Pick<CutterExportPlan, 'remainingDuration'>): number {
|
||||
if (!Number.isFinite(processedSeconds) || processedSeconds <= 0) return 0;
|
||||
if (!Number.isFinite(plan.remainingDuration) || plan.remainingDuration <= 0) {
|
||||
throw new Error('remainingDuration must be greater than zero');
|
||||
}
|
||||
return Math.min(100, round(processedSeconds / plan.remainingDuration * 100));
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
addCutAt,
|
||||
commitEditorState,
|
||||
createEditorHistory,
|
||||
createVideoEditorState,
|
||||
formatEditorTimecode,
|
||||
getPlayableSegments,
|
||||
movePreviewTimeOutOfCuts,
|
||||
parseEditorTimecode,
|
||||
redoEditorState,
|
||||
setCutRange,
|
||||
setTrimRange,
|
||||
timeToTimelinePercent,
|
||||
timelinePercentToTime,
|
||||
undoEditorState,
|
||||
} from './video-editor';
|
||||
|
||||
describe('video editor timecodes', () => {
|
||||
test('formats minute and hour timecodes with a frame field', () => {
|
||||
expect(formatEditorTimecode(66.52, 25)).toBe('01:06:13');
|
||||
expect(formatEditorTimecode(3666.52, 25)).toBe('01:01:06:13');
|
||||
});
|
||||
|
||||
test('parses minute and hour timecodes and snaps to a real frame', () => {
|
||||
expect(parseEditorTimecode('01:06:13', 25)).toBeCloseTo(66.52, 8);
|
||||
expect(parseEditorTimecode('01:01:06:13', 25)).toBeCloseTo(3666.52, 8);
|
||||
expect(parseEditorTimecode('00:02:29', 30)).toBeCloseTo(2.9666666667, 8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('video editor ranges', () => {
|
||||
test('keeps global trim boundaries frame-aligned and ordered', () => {
|
||||
const state = setTrimRange(createVideoEditorState(120, 25), 10.019, 90.021);
|
||||
expect(state.trimStart).toBe(10);
|
||||
expect(state.trimEnd).toBe(90.04);
|
||||
});
|
||||
|
||||
test('adds multiple cuts in timeline order without allowing overlaps', () => {
|
||||
const first = addCutAt(createVideoEditorState(120, 25), 40, 8);
|
||||
const second = addCutAt(first.state, 12, 5);
|
||||
expect(second.state.cuts.map((cut) => [cut.start, cut.end])).toEqual([[12, 17], [40, 48]]);
|
||||
|
||||
const rejected = setCutRange(second.state, first.cut.id, 15, 44);
|
||||
expect(rejected).toEqual(second.state);
|
||||
});
|
||||
|
||||
test('removes cut ranges from the export while preserving every playable segment', () => {
|
||||
let state = setTrimRange(createVideoEditorState(100, 25), 5, 95);
|
||||
state = addCutAt(state, 20, 10).state;
|
||||
state = addCutAt(state, 60, 5).state;
|
||||
expect(getPlayableSegments(state)).toEqual([
|
||||
{ start: 5, end: 20 },
|
||||
{ start: 30, end: 60 },
|
||||
{ start: 65, end: 95 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('preview playback jumps to the end of any removed range', () => {
|
||||
let state = addCutAt(createVideoEditorState(100, 25), 20, 10).state;
|
||||
state = addCutAt(state, 60, 5).state;
|
||||
expect(movePreviewTimeOutOfCuts(state, 24)).toBe(30);
|
||||
expect(movePreviewTimeOutOfCuts(state, 64.99)).toBe(65);
|
||||
expect(movePreviewTimeOutOfCuts(state, 40)).toBe(40);
|
||||
});
|
||||
|
||||
test('clips cuts to a smaller trim and removes empty remainders', () => {
|
||||
let state = addCutAt(createVideoEditorState(100, 25), 10, 15).state;
|
||||
state = addCutAt(state, 70, 20).state;
|
||||
state = setTrimRange(state, 20, 75);
|
||||
expect(state.cuts.map((cut) => [cut.start, cut.end])).toEqual([[20, 25], [70, 75]]);
|
||||
state = setTrimRange(state, 30, 60);
|
||||
expect(state.cuts).toEqual([]);
|
||||
});
|
||||
|
||||
test('rejects empty cuts while allowing adjacent cut boundaries', () => {
|
||||
const initial = createVideoEditorState(100, 25);
|
||||
expect(() => addCutAt(initial, 10, 0)).toThrow();
|
||||
let state = addCutAt(initial, 10, 5).state;
|
||||
state = addCutAt(state, 15, 5).state;
|
||||
expect(movePreviewTimeOutOfCuts(state, 10)).toBe(20);
|
||||
expect(getPlayableSegments(state)).toEqual([
|
||||
{ start: 0, end: 10 },
|
||||
{ start: 20, end: 100 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps at least one playable frame', () => {
|
||||
const state = createVideoEditorState(10, 25);
|
||||
expect(() => addCutAt(state, 0, 10)).toThrow('playable frame');
|
||||
const cut = addCutAt(state, 1, 2);
|
||||
expect(setCutRange(cut.state, cut.cut.id, 0, 10)).toEqual(cut.state);
|
||||
expect(setTrimRange(cut.state, 1, 2)).toEqual(cut.state);
|
||||
});
|
||||
|
||||
test('accepts exactly one playable frame at repeating frame rates', () => {
|
||||
const state = createVideoEditorState(1, 30);
|
||||
const edited = addCutAt(state, 0, 29 / 30).state;
|
||||
expect(getPlayableSegments(edited)).toEqual([{ start: 0.966666667, end: 1 }]);
|
||||
});
|
||||
|
||||
test('limits an edit to 64 removed ranges', () => {
|
||||
let state = createVideoEditorState(200, 25);
|
||||
for (let index = 0; index < 64; index += 1) state = addCutAt(state, index * 2, 1).state;
|
||||
expect(state.cuts).toHaveLength(64);
|
||||
expect(() => addCutAt(state, 150, 1)).toThrow('64');
|
||||
});
|
||||
|
||||
test('converts timeline percentages and times at frame precision', () => {
|
||||
const state = createVideoEditorState(120, 25);
|
||||
expect(timeToTimelinePercent(state, 60)).toBe(50);
|
||||
expect(timelinePercentToTime(state, 50.01)).toBe(60);
|
||||
expect(timelinePercentToTime(state, 100)).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe('video editor history', () => {
|
||||
test('undo and redo restore complete trim and cut states', () => {
|
||||
const initial = createVideoEditorState(100, 25);
|
||||
const trimmed = setTrimRange(initial, 5, 90);
|
||||
const cut = addCutAt(trimmed, 20, 10).state;
|
||||
let history = createEditorHistory(initial);
|
||||
history = commitEditorState(history, trimmed);
|
||||
history = commitEditorState(history, cut);
|
||||
|
||||
history = undoEditorState(history);
|
||||
expect(history.present).toEqual(trimmed);
|
||||
history = undoEditorState(history);
|
||||
expect(history.present).toEqual(initial);
|
||||
history = redoEditorState(history);
|
||||
expect(history.present).toEqual(trimmed);
|
||||
history = redoEditorState(history);
|
||||
expect(history.present).toEqual(cut);
|
||||
});
|
||||
|
||||
test('does not record no-op changes and clears redo after a new edit', () => {
|
||||
const initial = createVideoEditorState(100, 25);
|
||||
const trimmed = setTrimRange(initial, 5, 90);
|
||||
let history = commitEditorState(createEditorHistory(initial), trimmed);
|
||||
history = commitEditorState(history, trimmed);
|
||||
expect(history.past).toHaveLength(1);
|
||||
history = undoEditorState(history);
|
||||
expect(history.future).toHaveLength(1);
|
||||
history = commitEditorState(history, setTrimRange(history.present, 10, 80));
|
||||
expect(history.future).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
export interface EditorCut {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface EditorSegment {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface VideoEditorState {
|
||||
duration: number;
|
||||
fps: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
cuts: EditorCut[];
|
||||
}
|
||||
|
||||
export interface EditorHistory {
|
||||
past: VideoEditorState[];
|
||||
present: VideoEditorState;
|
||||
future: VideoEditorState[];
|
||||
}
|
||||
|
||||
const precision = 9;
|
||||
const frameTolerance = 1e-8;
|
||||
export const maxVideoEditorCuts = 64;
|
||||
|
||||
function finitePositive(value: number, name: string): number {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${name} must be greater than zero`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function rounded(value: number): number {
|
||||
return Number(value.toFixed(precision));
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
function snapToFrame(value: number, fps: number): number {
|
||||
return rounded(Math.round(value * fps) / fps);
|
||||
}
|
||||
|
||||
function cloneState(state: VideoEditorState): VideoEditorState {
|
||||
return {
|
||||
duration: state.duration,
|
||||
fps: state.fps,
|
||||
trimStart: state.trimStart,
|
||||
trimEnd: state.trimEnd,
|
||||
cuts: state.cuts.map((cut) => ({ ...cut })),
|
||||
};
|
||||
}
|
||||
|
||||
function statesEqual(left: VideoEditorState, right: VideoEditorState): boolean {
|
||||
return left.duration === right.duration
|
||||
&& left.fps === right.fps
|
||||
&& left.trimStart === right.trimStart
|
||||
&& left.trimEnd === right.trimEnd
|
||||
&& left.cuts.length === right.cuts.length
|
||||
&& left.cuts.every((cut, index) => {
|
||||
const other = right.cuts[index];
|
||||
return cut.id === other.id && cut.start === other.start && cut.end === other.end;
|
||||
});
|
||||
}
|
||||
|
||||
function nextCutId(cuts: EditorCut[]): string {
|
||||
const used = new Set(cuts.map((cut) => cut.id));
|
||||
let index = cuts.length + 1;
|
||||
while (used.has(`cut-${index}`)) index += 1;
|
||||
return `cut-${index}`;
|
||||
}
|
||||
|
||||
function overlapsAnotherCut(cuts: EditorCut[], id: string, start: number, end: number): boolean {
|
||||
return cuts.some((cut) => cut.id !== id && start < cut.end && end > cut.start);
|
||||
}
|
||||
|
||||
function sortCuts(cuts: EditorCut[]): EditorCut[] {
|
||||
return [...cuts].sort((left, right) => left.start - right.start || left.end - right.end || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
function hasPlayableFrame(state: VideoEditorState, cuts: EditorCut[]): boolean {
|
||||
const removedDuration = cuts.reduce((total, cut) => total + cut.end - cut.start, 0);
|
||||
return state.trimEnd - state.trimStart - removedDuration >= 1 / state.fps - frameTolerance;
|
||||
}
|
||||
|
||||
export function createVideoEditorState(duration: number, fps: number): VideoEditorState {
|
||||
const safeDuration = finitePositive(duration, 'duration');
|
||||
const safeFps = finitePositive(fps, 'fps');
|
||||
return {
|
||||
duration: rounded(safeDuration),
|
||||
fps: safeFps,
|
||||
trimStart: 0,
|
||||
trimEnd: rounded(safeDuration),
|
||||
cuts: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function formatEditorTimecode(time: number, fps: number): string {
|
||||
const safeFps = finitePositive(fps, 'fps');
|
||||
let wholeSeconds = Math.floor(Math.max(0, time));
|
||||
let frames = Math.round((Math.max(0, time) - wholeSeconds) * safeFps);
|
||||
const frameBase = Math.max(1, Math.round(safeFps));
|
||||
if (frames >= frameBase) {
|
||||
wholeSeconds += 1;
|
||||
frames = 0;
|
||||
}
|
||||
const hours = Math.floor(wholeSeconds / 3600);
|
||||
const minutes = Math.floor((wholeSeconds % 3600) / 60);
|
||||
const seconds = wholeSeconds % 60;
|
||||
const fields = hours > 0
|
||||
? [hours, minutes, seconds, frames]
|
||||
: [minutes, seconds, frames];
|
||||
return fields.map((field) => String(field).padStart(2, '0')).join(':');
|
||||
}
|
||||
|
||||
export function parseEditorTimecode(value: string, fps: number): number {
|
||||
const safeFps = finitePositive(fps, 'fps');
|
||||
const fields = value.trim().split(':');
|
||||
if (fields.length !== 3 && fields.length !== 4) {
|
||||
throw new Error('Timecode must use MM:SS:FF or HH:MM:SS:FF');
|
||||
}
|
||||
const numbers = fields.map((field) => Number(field));
|
||||
if (numbers.some((field) => !Number.isInteger(field) || field < 0)) {
|
||||
throw new Error('Timecode fields must be non-negative integers');
|
||||
}
|
||||
const [hours, minutes, seconds, frames] = numbers.length === 4
|
||||
? numbers
|
||||
: [0, numbers[0], numbers[1], numbers[2]];
|
||||
if (minutes >= 60 || seconds >= 60 || frames >= Math.max(1, Math.round(safeFps))) {
|
||||
throw new Error('Timecode field is out of range');
|
||||
}
|
||||
return rounded(hours * 3600 + minutes * 60 + seconds + frames / safeFps);
|
||||
}
|
||||
|
||||
export function setTrimRange(state: VideoEditorState, start: number, end: number): VideoEditorState {
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return state;
|
||||
const frameDuration = 1 / state.fps;
|
||||
const nextStart = clamp(snapToFrame(start, state.fps), 0, state.duration);
|
||||
const nextEnd = clamp(snapToFrame(end, state.fps), 0, state.duration);
|
||||
if (nextEnd - nextStart < frameDuration - frameTolerance) return state;
|
||||
const cuts = state.cuts
|
||||
.map((cut) => ({
|
||||
...cut,
|
||||
start: Math.max(cut.start, nextStart),
|
||||
end: Math.min(cut.end, nextEnd),
|
||||
}))
|
||||
.filter((cut) => cut.end - cut.start >= frameDuration - frameTolerance);
|
||||
const nextState = {
|
||||
...state,
|
||||
trimStart: rounded(nextStart),
|
||||
trimEnd: rounded(nextEnd),
|
||||
cuts: sortCuts(cuts),
|
||||
};
|
||||
return hasPlayableFrame(nextState, nextState.cuts) ? nextState : state;
|
||||
}
|
||||
|
||||
export function addCutAt(state: VideoEditorState, start: number, duration: number): { state: VideoEditorState; cut: EditorCut } {
|
||||
if (state.cuts.length >= maxVideoEditorCuts) throw new Error(`Edit supports up to ${maxVideoEditorCuts} removed ranges`);
|
||||
const nextStart = clamp(snapToFrame(start, state.fps), state.trimStart, state.trimEnd);
|
||||
const nextEnd = clamp(snapToFrame(start + duration, state.fps), state.trimStart, state.trimEnd);
|
||||
const frameDuration = 1 / state.fps;
|
||||
if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0 || nextEnd - nextStart < frameDuration - frameTolerance) {
|
||||
throw new Error('Cut must contain at least one frame');
|
||||
}
|
||||
const cut: EditorCut = { id: nextCutId(state.cuts), start: rounded(nextStart), end: rounded(nextEnd) };
|
||||
if (overlapsAnotherCut(state.cuts, cut.id, cut.start, cut.end)) {
|
||||
throw new Error('Cut overlaps another cut');
|
||||
}
|
||||
const cuts = sortCuts([...state.cuts, cut]);
|
||||
if (!hasPlayableFrame(state, cuts)) throw new Error('Edit must keep at least one playable frame');
|
||||
return {
|
||||
cut,
|
||||
state: { ...state, cuts },
|
||||
};
|
||||
}
|
||||
|
||||
export function setCutRange(state: VideoEditorState, id: string, start: number, end: number): VideoEditorState {
|
||||
const existing = state.cuts.find((cut) => cut.id === id);
|
||||
if (!existing || !Number.isFinite(start) || !Number.isFinite(end)) return state;
|
||||
const nextStart = clamp(snapToFrame(start, state.fps), state.trimStart, state.trimEnd);
|
||||
const nextEnd = clamp(snapToFrame(end, state.fps), state.trimStart, state.trimEnd);
|
||||
if (nextEnd - nextStart < 1 / state.fps - frameTolerance) return state;
|
||||
if (overlapsAnotherCut(state.cuts, id, nextStart, nextEnd)) return state;
|
||||
const cuts = sortCuts(state.cuts.map((cut) => cut.id === id
|
||||
? { ...cut, start: rounded(nextStart), end: rounded(nextEnd) }
|
||||
: cut));
|
||||
if (!hasPlayableFrame(state, cuts)) return state;
|
||||
return {
|
||||
...state,
|
||||
cuts,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeCut(state: VideoEditorState, id: string): VideoEditorState {
|
||||
if (!state.cuts.some((cut) => cut.id === id)) return state;
|
||||
return { ...state, cuts: state.cuts.filter((cut) => cut.id !== id) };
|
||||
}
|
||||
|
||||
export function getPlayableSegments(state: VideoEditorState): EditorSegment[] {
|
||||
const segments: EditorSegment[] = [];
|
||||
let cursor = state.trimStart;
|
||||
for (const cut of sortCuts(state.cuts)) {
|
||||
const start = clamp(cut.start, state.trimStart, state.trimEnd);
|
||||
const end = clamp(cut.end, state.trimStart, state.trimEnd);
|
||||
if (start > cursor) segments.push({ start: rounded(cursor), end: rounded(start) });
|
||||
cursor = Math.max(cursor, end);
|
||||
}
|
||||
if (cursor < state.trimEnd) segments.push({ start: rounded(cursor), end: rounded(state.trimEnd) });
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function getPlayableDuration(state: VideoEditorState): number {
|
||||
return rounded(getPlayableSegments(state).reduce((total, segment) => total + segment.end - segment.start, 0));
|
||||
}
|
||||
|
||||
export function movePreviewTimeOutOfCuts(state: VideoEditorState, time: number): number {
|
||||
let nextTime = clamp(time, state.trimStart, state.trimEnd);
|
||||
for (const cut of sortCuts(state.cuts)) {
|
||||
if (nextTime >= cut.start && nextTime < cut.end) nextTime = cut.end;
|
||||
}
|
||||
return rounded(clamp(nextTime, state.trimStart, state.trimEnd));
|
||||
}
|
||||
|
||||
export function timeToTimelinePercent(state: VideoEditorState, time: number): number {
|
||||
return clamp((time / state.duration) * 100, 0, 100);
|
||||
}
|
||||
|
||||
export function timelinePercentToTime(state: VideoEditorState, percent: number): number {
|
||||
return snapToFrame(clamp(percent, 0, 100) / 100 * state.duration, state.fps);
|
||||
}
|
||||
|
||||
export function createEditorHistory(initial: VideoEditorState): EditorHistory {
|
||||
return { past: [], present: cloneState(initial), future: [] };
|
||||
}
|
||||
|
||||
export function commitEditorState(history: EditorHistory, next: VideoEditorState): EditorHistory {
|
||||
if (statesEqual(history.present, next)) return history;
|
||||
return {
|
||||
past: [...history.past, cloneState(history.present)],
|
||||
present: cloneState(next),
|
||||
future: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function undoEditorState(history: EditorHistory): EditorHistory {
|
||||
const previous = history.past.at(-1);
|
||||
if (!previous) return history;
|
||||
return {
|
||||
past: history.past.slice(0, -1),
|
||||
present: cloneState(previous),
|
||||
future: [cloneState(history.present), ...history.future],
|
||||
};
|
||||
}
|
||||
|
||||
export function redoEditorState(history: EditorHistory): EditorHistory {
|
||||
const next = history.future[0];
|
||||
if (!next) return history;
|
||||
return {
|
||||
past: [...history.past, cloneState(history.present)],
|
||||
present: cloneState(next),
|
||||
future: history.future.slice(1),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user