release: publish Twitch VOD Manager 1.0.5

This commit is contained in:
Sucukdeluxe
2026-08-11 21:06:37 +02:00
parent 74ebcd895c
commit aa53fcf7e8
27 changed files with 6501 additions and 349 deletions
+1 -1
View File
@@ -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));
+116
View File
@@ -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);
});
});
+109
View File
@@ -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));
}
+147
View File
@@ -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([]);
});
});
+268
View File
@@ -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),
};
}