Skip to content
Open
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
57 changes: 57 additions & 0 deletions src/camera-move.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,63 @@ export function shiftCameraMoves(moves: CameraMove[], offsetMs: number): CameraM
}));
}

/**
* Remap camera move times from the recording timeline onto the export one.
*
* The ramp's `trim`/`setpts` segments run upstream of `zoompan`, so the
* `in_time` a move matches against is the ramped timestamp, not the recorded
* one. Both ends are remapped, not just the start: a move spanning a compressed
* gap occupies less of the output timeline than it did of the recording, so
* shifting without shrinking would leave the zoom running past its content.
*/
export function remapCameraMoves(
moves: CameraMove[],
remap: (timeMs: number) => number,
): CameraMove[] {
return moves.map((m) => {
const durationMs = m.durationMs;
const holdMs = m.holdMs ?? 0;
const spanMs = moveEndMs(m) - m.startMs;
const startMs = remap(m.startMs);
if (spanMs <= 0) return { ...m, startMs };

const endMs = remap(m.startMs + spanMs);
const factor = (endMs - startMs) / spanMs;
return {
...m,
startMs,
// Never round a fade to zero: buildCameraMoveFilter divides by it, and
// `(in_time-S)/0.0000` is a move ffmpeg renders as doing nothing at all.
// A short fade in a heavily sped-up scene can compress below half a ms.
durationMs: Math.max(1, Math.round(durationMs * factor)),
...(m.holdMs === undefined ? {} : { holdMs: Math.round(holdMs * factor) }),
};
});
}

/**
* Compose the two rewrites a camera move has to survive, the speed ramp and any
* freezes, into one recording-time to export-time mapping.
*
* Shared by every export path, since a correction that reaches some and not the
* others diverges silently.
*/
export function exportTimelineRemap(
remapForSpeedRamp: (timeMs: number) => number,
freezes: readonly { absoluteMs: number; durationMs: number }[],
): (timeMs: number) => number {
return (timeMs) => {
const ramped = remapForSpeedRamp(timeMs);
// Freezes are positioned on the post-ramp timeline and insert time, so
// anything at or after one moves later by its duration.
let inserted = 0;
for (const freeze of freezes) {
if (freeze.absoluteMs <= ramped) inserted += freeze.durationMs;
}
return ramped + inserted;
};
}

/**
* Scale camera move coordinates from CSS layout pixels to output-frame pixels.
* During recording, bounding boxes are measured in CSS pixels; export may
Expand Down
23 changes: 21 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,19 @@ import {
} from './timeline.js';
import { generateChapterMetadata } from './chapters.js';
import { generateSrt, generateVtt } from './subtitles.js';
import { applySpeedRampToTimeline, type Segment, type SceneSpeedMap } from './speed-ramp.js';
import { scaleCameraMoves, shiftCameraMoves, type CameraMove } from './camera-move.js';
import {
applySpeedRampToTimeline,
remapTimeMs,
type Segment,
type SceneSpeedMap,
} from './speed-ramp.js';
import {
exportTimelineRemap,
remapCameraMoves,
scaleCameraMoves,
shiftCameraMoves,
type CameraMove,
} from './camera-move.js';
import { resolveFreezes, adjustPlacementsForFreezes, totalFreezeDurationMs, type FreezeSpec } from './freeze.js';
import { renderShaderTransitions } from './transitions/shader-render.js';
import type { Placement } from './tts/align.js';
Expand Down Expand Up @@ -299,6 +310,14 @@ export function createProgram(): Command {
if (existsSync(cameraMovesPath)) {
let moves: CameraMove[] = JSON.parse(readFileSync(cameraMovesPath, 'utf-8'));
if (headTrimMs && headTrimMs > 0) moves = shiftCameraMoves(moves, headTrimMs);
// Same trip the pipeline path makes; see exportTimelineRemap.
moves = remapCameraMoves(
moves,
exportTimelineRemap(
(timeMs) => remapTimeMs(timeMs, speedRampSegments ?? []),
resolvedFreezes,
),
);
const scaleX = exportSize.width / config.video.width;
const scaleY = exportSize.height / config.video.height;
moves = scaleCameraMoves(moves, scaleX, scaleY);
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export {
} from './freeze.js';

// Camera Moves
export { buildCameraMoveFilter, buildMotionBlurFilter, detectChainedPairs, shiftCameraMoves, scaleCameraMoves, type CameraMove } from './camera-move.js';
export { buildCameraMoveFilter, buildMotionBlurFilter, detectChainedPairs, shiftCameraMoves, scaleCameraMoves, remapCameraMoves, exportTimelineRemap, type CameraMove } from './camera-move.js';

// Frame
export { buildFrameFilter, generateFramePng, type FrameFilterResult } from './frame.js';
Expand Down
32 changes: 28 additions & 4 deletions src/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@ import { generateFramePng } from './frame.js';
import { generateSrt, generateVtt } from './subtitles.js';
import { generateChapterMetadata } from './chapters.js';
import { buildSceneReport, formatSceneReport } from './report.js';
import { applySpeedRampToTimeline, type SceneSpeedMap } from './speed-ramp.js';
import { scaleCameraMoves, shiftCameraMoves, type CameraMove } from './camera-move.js';
import { applySpeedRampToTimeline, remapTimeMs, type SceneSpeedMap } from './speed-ramp.js';
import {
exportTimelineRemap,
remapCameraMoves,
scaleCameraMoves,
shiftCameraMoves,
type CameraMove,
} from './camera-move.js';
import {
resolveFreezes,
adjustPlacementsForFreezes,
Expand Down Expand Up @@ -414,10 +420,22 @@ export async function runPipeline(
if (tailPadMs !== undefined) exportOptions.tailPadMs = tailPadMs;
if (headTrimMs > 0) exportOptions.headTrimMs = headTrimMs;

// Apply camera moves — shift for head trim, then scale from CSS layout
// coordinates to the final export dimensions when those differ.
// Apply camera moves: shift for head trim, remap onto the timeline the
// export actually produces, then scale from CSS layout coordinates to the
// final export dimensions when those differ.
if (cameraMoves.length > 0) {
let moves = shiftCameraMoves(cameraMoves, headTrimMs);

// Placements already make this trip, through applySpeedRampToTimeline and
// adjustPlacementsForFreezes above; moves have to make it too.
moves = remapCameraMoves(
moves,
exportTimelineRemap(
(timeMs) => remapTimeMs(timeMs, speedRampPlan.segments),
resolvedFreezes,
),
);

const scaleX = exportSize.width / config.video.width;
const scaleY = exportSize.height / config.video.height;
moves = scaleCameraMoves(moves, scaleX, scaleY);
Expand Down Expand Up @@ -620,6 +638,12 @@ export async function runPipeline(

if (variantCameraMoves.length > 0) {
variantCameraMoves = shiftCameraMoves(variantCameraMoves, variantHeadTrimMs);
// Variants never pass speedRampSegments, so only the freeze half
// applies. It does apply: variantPlacements were freeze-adjusted above.
variantCameraMoves = remapCameraMoves(
variantCameraMoves,
exportTimelineRemap((timeMs) => timeMs, variantResolvedFreezes),
);
}

// Render overlay PNGs for imported video variants
Expand Down
18 changes: 16 additions & 2 deletions src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,14 @@ import type { OverlayManifestEntry, SceneEffect, Zone } from './overlays/types.j
import { generateSrt, generateVtt } from './subtitles.js';
import { generateChapterMetadata } from './chapters.js';
import { exportVideo, checkFfmpeg } from './export.js';
import { applySpeedRampToTimeline } from './speed-ramp.js';
import { scaleCameraMoves, shiftCameraMoves, type CameraMove } from './camera-move.js';
import { applySpeedRampToTimeline, remapTimeMs } from './speed-ramp.js';
import {
exportTimelineRemap,
remapCameraMoves,
scaleCameraMoves,
shiftCameraMoves,
type CameraMove,
} from './camera-move.js';
import { generateFramePng } from './frame.js';
import { resolveFreezes, adjustPlacementsForFreezes, totalFreezeDurationMs, type FreezeSpec } from './freeze.js';
import { buildOverlayPngsForImport, isImportedVideo, type RenderedOverlayPng } from './overlays/render-to-png.js';
Expand Down Expand Up @@ -1022,6 +1028,14 @@ export async function startPreviewServer(options: PreviewOptions): Promise<{ url
if (existsSync(cameraMovesPath)) {
let moves: CameraMove[] = JSON.parse(readFileSync(cameraMovesPath, 'utf-8'));
if (headTrimMs > 0) moves = shiftCameraMoves(moves, headTrimMs);
// Same trip the pipeline and CLI paths make; see exportTimelineRemap.
moves = remapCameraMoves(
moves,
exportTimelineRemap(
(timeMs) => remapTimeMs(timeMs, speedRampSegments ?? []),
previewResolvedFreezes,
),
);
const captureW = ec?.captureWidth ?? ec?.outputWidth ?? 1920;
const captureH = ec?.captureHeight ?? ec?.outputHeight ?? 1080;
const outW = ec?.outputWidth ?? captureW;
Expand Down
6 changes: 5 additions & 1 deletion src/speed-ramp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,11 @@ export function remapTimeMs(timeMs: number, segments: Segment[]): number {
}
return Math.round(outputMs);
}
return Math.round(outputMs);
// Past the last segment, keep going at its speed rather than saturating. No
// placement lands here, but remapCameraMoves divides by the distance between
// two remapped times, and a move's zoom-out tail can overhang the recording.
const last = segments[segments.length - 1];
return Math.round(outputMs + (timeMs - last.endMs) / last.speed);
}

export function applySpeedRampToTimeline(
Expand Down
128 changes: 128 additions & 0 deletions tests/camera-move.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import { describe, it, expect } from 'vitest';
import {
buildCameraMoveFilter,
detectChainedPairs,
exportTimelineRemap,
remapCameraMoves,
shiftCameraMoves,
scaleCameraMoves,
type CameraMove,
} from '../src/camera-move.js';
import { computeSegments, remapTimeMs } from '../src/speed-ramp.js';
import { adjustPlacementsForFreezes } from '../src/freeze.js';

describe('buildCameraMoveFilter', () => {
const baseMove: CameraMove = {
Expand Down Expand Up @@ -301,3 +305,127 @@ describe('scaleCameraMoves', () => {
expect(scaled[0].h).toBe(600);
});
});

describe('remapCameraMoves', () => {
// Two one-second scenes in a seven-second timeline, gaps at double speed:
// [0,1000]@2, [1000,2000]@1, [2000,5000]@2, [5000,6000]@1, [6000,7000]@2.
const segments = computeSegments(
[
{ scene: 'intro', startMs: 1000, endMs: 2000 },
{ scene: 'outro', startMs: 5000, endMs: 6000 },
],
7000,
{ gapSpeed: 2.0, minGapMs: 500 },
);
const remap = (timeMs: number) => remapTimeMs(timeMs, segments);

it('shifts a move by the gap time removed ahead of it, keeping its duration', () => {
const moves: CameraMove[] = [
{ startMs: 1100, durationMs: 300, holdMs: 100, x: 10, y: 20, w: 30, h: 40 },
];

const [move] = remapCameraMoves(moves, remap);

// 500ms of the leading gap is removed, and the move itself sits inside a
// scene the ramp does not touch, so it keeps its shape exactly.
expect(move.startMs).toBe(600);
expect(move.durationMs).toBe(300);
expect(move.holdMs).toBe(100);
});

it('shrinks a move that spans a compressed gap instead of only shifting it', () => {
const moves: CameraMove[] = [
{ startMs: 1800, durationMs: 200, holdMs: 800, x: 10, y: 20, w: 30, h: 40 },
];

const [move] = remapCameraMoves(moves, remap);

// Recorded span is 200 + 800 + 200 = 1200ms running from 1800 to 3000.
// On the ramped timeline that is 1300 to 2000, so the move has to occupy
// 700ms. Shifting alone would leave it running 500ms past its content.
expect(move.startMs).toBe(1300);
const spanMs = move.durationMs * 2 + (move.holdMs ?? 0);
// Within a millisecond of the ramped span: duration and hold are rounded to
// whole milliseconds independently, so they can disagree with the exact
// span by 1ms. At 30fps that is a thirtieth of a frame.
expect(Math.abs(spanMs - (remap(3000) - remap(1800)))).toBeLessThanOrEqual(1);
});

it('leaves moves untouched when the timeline is not remapped', () => {
const moves: CameraMove[] = [
{ startMs: 1800, durationMs: 200, holdMs: 800, x: 10, y: 20, w: 30, h: 40 },
];

expect(remapCameraMoves(moves, (timeMs) => timeMs)).toEqual(moves);
});

it('keeps a closing move at its authored speed when its tail overhangs the end', () => {
// Second scene runs to the very end, so there is no trailing segment for
// the zoom-out to land in and the move's span runs past 7000.
const closing = computeSegments(
[{ scene: 'intro', startMs: 1000, endMs: 2000 }, { scene: 'outro', startMs: 4000, endMs: 7000 }],
7000,
{ gapSpeed: 2.0, minGapMs: 500 },
);
const moves: CameraMove[] = [
{ startMs: 6500, durationMs: 400, holdMs: 2200, x: 10, y: 20, w: 30, h: 40 },
];

const [move] = remapCameraMoves(moves, (timeMs) => remapTimeMs(timeMs, closing));

// The ramp leaves this scene alone, so only the start shifts. Measuring the
// span against a saturated endpoint charges the overhang against the 500ms
// of timeline left, rendering the 400ms ease in two frames.
expect(move.durationMs).toBe(400);
expect(move.holdMs).toBe(2200);
});

it('never rounds a fade down to zero', () => {
const moves: CameraMove[] = [
{ startMs: 0, durationMs: 10, holdMs: 0, x: 10, y: 20, w: 30, h: 40 },
];

// A 25x scene compresses a 10ms fade to 0.4ms. Rounding that to 0 would
// make buildCameraMoveFilter divide by zero, which ffmpeg accepts and
// renders as a move that does nothing at all.
const [move] = remapCameraMoves(moves, (timeMs) => timeMs / 25);
expect(move.durationMs).toBeGreaterThanOrEqual(1);
});
});

describe('exportTimelineRemap', () => {
it('measures freezes against the ramped clock, not the recorded one', () => {
// Halve everything, then hold 1000ms at ramped t=1000.
const remap = exportTimelineRemap(
(timeMs) => timeMs / 2,
[{ absoluteMs: 1000, durationMs: 1000 }],
);

// Recorded 1000 lands at ramped 500, which is before the freeze, so it is
// untouched. Comparing the freeze against the recorded 1000 instead would
// wrongly push it to 1500.
expect(remap(1000)).toBe(500);

// Recorded 4000 lands at ramped 2000, past the freeze, so it takes the
// full inserted hold.
expect(remap(4000)).toBe(3000);
});

it('pushes a time landing exactly on a freeze, as adjustPlacementsForFreezes does', () => {
const freezes = [{ absoluteMs: 1000, durationMs: 500 }];
const remap = exportTimelineRemap((timeMs) => timeMs, freezes);

// `<=`, matching adjustPlacementsForFreezes. The two have to agree or a
// move and the scene it belongs to drift apart by the whole hold.
expect(remap(1000)).toBe(1500);
expect(remap(999)).toBe(999);
expect(adjustPlacementsForFreezes([{ scene: 'a', startMs: 1000, endMs: 2000 }], freezes)[0].startMs)
.toBe(1500);
});

it('is an identity when there is neither a ramp nor a freeze', () => {
const remap = exportTimelineRemap((timeMs) => timeMs, []);
expect(remap(0)).toBe(0);
expect(remap(12_345)).toBe(12_345);
});
});
18 changes: 18 additions & 0 deletions tests/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,24 @@ describe('runPipeline', () => {
}));
});

it('hands exportVideo camera moves on the ramped timeline, not the recorded one', async () => {
writeFileSync(join(ARGO_DIR, '.timing.camera-moves.json'), JSON.stringify([
{ startMs: 5000, durationMs: 400, holdMs: 600, x: 100, y: 120, w: 300, h: 240, scale: 1.4 },
]));

await runPipeline(DEMO_NAME, {
...defaultConfig,
export: { ...defaultConfig.export, speedRamp: { gapSpeed: 2.0, minGapMs: 500 } },
});

// Recorded at 5000, so 4200 once the 800ms head trim comes off. The 3000ms
// gap ahead of it runs at 2x, taking 1500ms off the front. Without the
// remap the move reaches exportVideo at 4200 and fires on the wrong scene.
expect(mockedExportVideo).toHaveBeenCalledWith(expect.objectContaining({
cameraMoves: [expect.objectContaining({ startMs: 2700 })],
}));
});

it('writes scene durations metadata for recording-time pacing', async () => {
mockedGenerateClips.mockResolvedValue([
{ scene: 'intro', clipPath: join(ARGO_DIR, 'clips', 'intro.wav'), durationMs: 1200 },
Expand Down
Loading