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
139 changes: 139 additions & 0 deletions scripts/playwright-issue-48-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'

const DEV_URL = process.env.DEV_URL ?? 'http://localhost:5175/'

function run(args) {
const result = spawnSync('npx', ['playwright-cli', ...args], {
cwd: process.cwd(),
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024,
})
if (result.stdout) process.stdout.write(result.stdout)
if (result.stderr) process.stderr.write(result.stderr)
if (result.error) console.error(result.error)
return result.status ?? 1
}

const demo = path.resolve('test-fixtures/demo')
const names = ['photo.jpg', 'photo2.jpg']
const payload = Object.fromEntries(
names.map((name) => [name, fs.readFileSync(path.join(demo, name)).toString('base64')]),
)
payload['slideshow.json'] = Buffer.from(JSON.stringify({
audioClips: [],
globalSettings: {
fitMode: 'cover',
imageDurationSecs: 3,
kenBurns: false,
transitionType: 'cut',
},
schemaVersion: 1,
slides: [
{
durationInFrames: 90,
excluded: false,
filename: 'photo.jpg',
id: 'photo-a',
type: 'image',
},
{
durationInFrames: 180,
excluded: false,
heading: 'Long title',
id: 'title-b',
kind: 'title',
style: 'dark',
},
],
}, null, 2)).toString('base64')

const code = `async page => {
const payload = ${JSON.stringify(payload)};
await page.goto(${JSON.stringify(DEV_URL)});
await page.evaluate((files) => {
function decodeBase64(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
return bytes;
}
class MockFileHandle {
constructor(name, base64) {
this.kind = 'file';
this.name = name;
this._base64 = base64;
}
async getFile() {
const bytes = decodeBase64(this._base64);
const type = this.name.endsWith('.jpg') ? 'image/jpeg' : 'application/json';
return new File([bytes], this.name, { type, lastModified: 1781145972852 });
}
async createWritable() {
const handle = this;
return {
write: async (data) => {
const buffer = data instanceof Blob ? await data.arrayBuffer() : data;
handle._base64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
},
close: async () => {},
};
}
}
class MockDirHandle {
constructor(files) {
this.name = 'demo';
this._files = files;
}
async getFileHandle(name, options) {
const handle = this._files.get(name);
if (handle) return handle;
if (options?.create) {
const created = new MockFileHandle(name, '');
this._files.set(name, created);
return created;
}
throw new DOMException('NotFoundError');
}
async *values() {
for (const handle of this._files.values()) yield handle;
}
}
const handles = new Map(
Object.entries(files).map(([name, base64]) => [name, new MockFileHandle(name, base64)]),
);
window.showDirectoryPicker = async () => new MockDirHandle(handles);
}, payload);

await page.getByRole('button', { name: 'Open Folder' }).click();
await page.getByText('Long title').first().waitFor();

const widths = await page.locator('[data-timeline-block]').evaluateAll((elements) => (
elements.map((element) => Math.round(element.getBoundingClientRect().width))
));
if (widths.length < 2) {
throw new Error('Expected at least two proportional media blocks, got: ' + JSON.stringify(widths));
}
if (widths[1] <= widths[0]) {
throw new Error('Expected second slide wider than first (3s vs 6s): ' + JSON.stringify(widths));
}

const secondSlide = page.locator('[data-timeline-block]').nth(1);
await secondSlide.click();
await page.waitForTimeout(300);

const currentHighlight = await secondSlide.evaluate((element) => element.className.includes('ring-emerald-500'));
if (!currentHighlight) {
throw new Error('Expected second slide highlighted as current after click');
}

await page.screenshot({ path: 'issue-48-proportional-timeline.png', fullPage: true });
}`

let status = run(['open', DEV_URL, '--browser=chrome'])
if (status !== 0) process.exit(status)

status = run(['run-code', code])
run(['close'])
process.exit(status)
39 changes: 39 additions & 0 deletions specs/issue-48-proportional-timeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Issue #48 — Proportional timeline UI + audio lane with gain controls

Closes #48.

## Scope

Replace `StoryboardFilmstrip` with a time-proportional `TimelinePanel`:

- Media lane: slide cards width ∝ first-pass duration from `RenderPlan`
- Audio lane: clip blocks aligned to `audioSegments.startFrame`, waveform peaks, gain sliders (dB)
- Shared horizontal scroll; playhead at `currentFrame`
- Drag-reorder on media lane (slides) and audio lane (clips)
- Gain/reorder removed from sidebar `SoundtrackPanel` (add track + beat grid remain)

## Pure modules

### `sequence-planner/timelineLayout.ts`

- `firstPassEntries(renderPlan)` — entries before loop boundary
- `buildTimelineLayout(renderPlan, pixelsPerFrame, minBlockWidthPx)` → `{ totalWidthPx, mediaBlocks, audioBlocks }`

### `audio-analysis/waveformPeaks.ts`

- `computeWaveformPeaks(samples, barCount)` → normalized 0–1 peaks

## Editor shell

- `TimelinePanel` — scroll container, playhead, lanes
- `useWaveformPeaks` — decode mono via beat-grid `decodeMono`, cache by filename
- `App` tracks `currentFrame` for playhead; click timeline → seek

## Testing

- Unit: `timelineLayout.test.ts`, `waveformPeaks.test.ts`
- Smoke: `scripts/playwright-issue-48-smoke.mjs` — proportional widths, playhead highlight

## HITL

Owner review on 10-clip project before merge.
6 changes: 6 additions & 0 deletions specs/multi-track-audio-timeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,9 @@ Closes #46.
Per-file `beatGridCache` in slideshow.json (migrates legacy single `BeatGrid`). `buildConcatenatedBeatTimes` shifts each clip's beats by clip start. Manual beat grid spans total audio duration. Planner uses position-aware `nudgeSlideEndFrame` when concatenated beat times are provided. `useBeatGrid` analyzes only clips missing from cache; reorder preserves cache.

Closes #49.

## Slice 22 — Proportional timeline UI (in progress)

`TimelinePanel` replaces `StoryboardFilmstrip`: proportional media widths, audio lane with waveforms and gain sliders, shared scroll + playhead.

Closes #48.
2 changes: 2 additions & 0 deletions src/audio-analysis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ export {
TARGET_RMS_DBFS,
} from './loudness'
export { isLoudnessCacheEntryValid, resolveEffectiveGainDb } from './gain'
export { computeWaveformPeaks, computeWaveformPeakPairs, DEFAULT_WAVEFORM_BAR_COUNT, DEFAULT_WAVEFORM_BUCKET_COUNT } from './waveformPeaks'
export type { WaveformPeakPair } from './waveformPeaks'
export type { LoudnessCache, LoudnessCacheEntry } from './types'
51 changes: 51 additions & 0 deletions src/audio-analysis/waveformPeaks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import {
buildSymmetricWaveformPath,
computeWaveformPeakPairs,
computeWaveformPeaks,
resampleWaveformPeaks,
} from './waveformPeaks'

describe('computeWaveformPeakPairs', () => {
it('returns normalized min/max pairs', () => {
const samples = new Float32Array([0, 0.5, -1, 0.25, 0, 0.75])
const pairs = computeWaveformPeakPairs(samples, 3)

expect(pairs).toHaveLength(3)
expect(Math.max(...pairs.map((pair) => Math.max(pair.max, Math.abs(pair.min))))).toBe(1)
expect(Math.min(...pairs.map((pair) => pair.min))).toBeGreaterThanOrEqual(-1)
})

it('returns empty array for empty input', () => {
expect(computeWaveformPeakPairs(new Float32Array(), 8)).toEqual([])
})
})

describe('computeWaveformPeaks', () => {
it('returns normalized peaks between 0 and 1', () => {
const samples = new Float32Array([0, 0.5, -1, 0.25, 0, 0.75])
const peaks = computeWaveformPeaks(samples, 3)

expect(peaks).toHaveLength(3)
expect(Math.max(...peaks)).toBeLessThanOrEqual(1)
expect(Math.max(...peaks)).toBeGreaterThan(0)
expect(Math.min(...peaks)).toBeGreaterThanOrEqual(0)
})
})

describe('resampleWaveformPeaks', () => {
it('downsamples to the requested count', () => {
const pairs = computeWaveformPeakPairs(new Float32Array([0, 1, -1, 0.5, -0.5, 0.25]), 6)
expect(resampleWaveformPeaks(pairs, 2)).toHaveLength(2)
})
})

describe('buildSymmetricWaveformPath', () => {
it('returns a closed SVG path', () => {
const pairs = computeWaveformPeakPairs(new Float32Array([0, 1, -1, 0.5, -0.5, 0.25]), 6)
const path = buildSymmetricWaveformPath(pairs, 120, 40)

expect(path.startsWith('M')).toBe(true)
expect(path.endsWith('Z')).toBe(true)
})
})
116 changes: 116 additions & 0 deletions src/audio-analysis/waveformPeaks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
export const DEFAULT_WAVEFORM_BUCKET_COUNT = 4096

export type WaveformPeakPair = {
max: number
min: number
}

export function computeWaveformPeakPairs(
samples: Float32Array,
bucketCount = DEFAULT_WAVEFORM_BUCKET_COUNT,
): WaveformPeakPair[] {
if (samples.length === 0 || bucketCount <= 0) return []

const pairs: WaveformPeakPair[] = []
const samplesPerBucket = Math.max(1, Math.floor(samples.length / bucketCount))

for (let bucketIndex = 0; bucketIndex < bucketCount; bucketIndex++) {
const start = bucketIndex * samplesPerBucket
const end = bucketIndex === bucketCount - 1 ? samples.length : start + samplesPerBucket
let max = 0
let min = 0

for (let sampleIndex = start; sampleIndex < end; sampleIndex++) {
const sample = samples[sampleIndex]
if (sample > max) max = sample
if (sample < min) min = sample
}

pairs.push({ max, min })
}

const globalPeak = pairs.reduce(
(currentPeak, pair) => Math.max(currentPeak, pair.max, Math.abs(pair.min)),
0,
)
if (globalPeak <= 0) return pairs.map(() => ({ max: 0, min: 0 }))

return pairs.map((pair) => ({
max: pair.max / globalPeak,
min: pair.min / globalPeak,
}))
}

/** @deprecated Use computeWaveformPeakPairs for timeline display */
export const DEFAULT_WAVEFORM_BAR_COUNT = 64

/** @deprecated Use computeWaveformPeakPairs for timeline display */
export function computeWaveformPeaks(
samples: Float32Array,
barCount = DEFAULT_WAVEFORM_BAR_COUNT,
): number[] {
return computeWaveformPeakPairs(samples, barCount).map((pair) => pair.max)
}

export function resampleWaveformPeaks(
pairs: WaveformPeakPair[],
targetCount: number,
): WaveformPeakPair[] {
if (pairs.length === 0 || targetCount <= 0) return []
if (pairs.length === targetCount) return pairs

const resampled: WaveformPeakPair[] = []
const sourceCount = pairs.length

for (let targetIndex = 0; targetIndex < targetCount; targetIndex++) {
const sourceStart = Math.floor((targetIndex * sourceCount) / targetCount)
const sourceEnd = Math.max(
sourceStart + 1,
Math.floor(((targetIndex + 1) * sourceCount) / targetCount),
)
let max = 0
let min = 0

for (let sourceIndex = sourceStart; sourceIndex < sourceEnd; sourceIndex++) {
max = Math.max(max, pairs[sourceIndex].max)
min = Math.min(min, pairs[sourceIndex].min)
}

resampled.push({ max, min })
}

return resampled
}

export function buildSymmetricWaveformPath(
pairs: WaveformPeakPair[],
width: number,
height: number,
): string {
if (pairs.length === 0 || width <= 0 || height <= 0) return ''

const sampleCount = Math.max(16, Math.floor(width / 2))
const resampled = resampleWaveformPeaks(pairs, sampleCount)
const centerY = height / 2
const halfHeight = (height / 2) * 0.92
const stepX = width / Math.max(1, resampled.length - 1)

let path = `M 0 ${centerY}`

for (let index = 0; index < resampled.length; index++) {
const x = index * stepX
const amplitude = Math.max(resampled[index].max, Math.abs(resampled[index].min))
path += ` L ${x.toFixed(2)} ${(centerY - amplitude * halfHeight).toFixed(2)}`
}

path += ` L ${width} ${centerY}`

for (let index = resampled.length - 1; index >= 0; index--) {
const x = index * stepX
const amplitude = Math.max(resampled[index].max, Math.abs(resampled[index].min))
path += ` L ${x.toFixed(2)} ${(centerY + amplitude * halfHeight).toFixed(2)}`
}

path += ' Z'
return path
}
Loading
Loading