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
149 changes: 149 additions & 0 deletions scripts/playwright-folder-load-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// Smoke: folder open + audio playlist load timing. Run:
// pnpm dev (or pnpm build && pnpm preview)
// node scripts/playwright-folder-load-smoke.mjs
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'

const APP_URL = process.env.APP_URL ?? 'http://localhost:5173/'
const MAX_LOAD_MS = Number(process.env.MAX_LOAD_MS ?? 8000)
const MAX_ANALYSIS_MS = Number(process.env.MAX_ANALYSIS_MS ?? 15000)

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', 'theme.wav']
const payload = Object.fromEntries(
names.map((name) => [name, fs.readFileSync(path.join(demo, name)).toString('base64')]),
)
payload['slideshow.json'] = Buffer.from(JSON.stringify({
audioClips: [{ filename: 'theme.wav' }],
globalSettings: {
fitMode: 'cover',
imageDurationSecs: 3,
kenBurns: true,
transitionType: 'crossfade',
},
schemaVersion: 1,
slides: [
{ durationInFrames: 90, excluded: false, filename: 'photo.jpg', id: 'photo-a', type: 'image' },
{ durationInFrames: 90, excluded: false, filename: 'photo2.jpg', id: 'photo-b', type: 'image' },
],
}, null, 2)).toString('base64')

const code = `async page => {
const payload = ${JSON.stringify(payload)};
const maxLoadMs = ${MAX_LOAD_MS};
const maxAnalysisMs = ${MAX_ANALYSIS_MS};

await page.goto(${JSON.stringify(APP_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 = nameToType(this.name);
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 () => {},
};
}
}
function nameToType(name) {
if (name.endsWith('.jpg')) return 'image/jpeg';
if (name.endsWith('.wav')) return 'audio/wav';
if (name.endsWith('.json')) return 'application/json';
return 'application/octet-stream';
}
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);

const loadStart = Date.now();
await page.getByRole('button', { name: 'Open Folder' }).click();
await page.getByText('photo2.jpg').first().waitFor({ timeout: maxLoadMs });
const loadMs = Date.now() - loadStart;

const loadingBanner = page.getByText('Loading media…');
if (await loadingBanner.isVisible()) {
throw new Error('Loading media banner still visible after filmstrip appeared');
}

const player = page.locator('.__remotion-player');
await player.waitFor({ timeout: 5000 });
const playerMs = Date.now() - loadStart;

const analysisStart = Date.now();
const analyzing = page.getByText('Analyzing soundtrack…');
if (await analyzing.isVisible().catch(() => false)) {
await analyzing.waitFor({ state: 'hidden', timeout: maxAnalysisMs });
}
const analysisMs = Date.now() - analysisStart;

const errors = await page.evaluate(() => {
return window.__playwrightErrors ?? [];
});

console.log('TIMING loadMs=' + loadMs + ' playerMs=' + playerMs + ' analysisMs=' + analysisMs);
if (loadMs > maxLoadMs) {
throw new Error('folder load exceeded ' + maxLoadMs + 'ms: ' + loadMs);
}
console.log('PASS folder load smoke');
}`

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

status = run(['run-code', code])
run(['close'])
if (status !== 0) process.exit(status)
console.log('PASS folder load smoke complete')
154 changes: 154 additions & 0 deletions scripts/playwright-theme-toggle-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Smoke: theme toggle with heavy slideshow. Run: pnpm dev && node scripts/playwright-theme-toggle-smoke.mjs
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'

const APP_URL = process.env.APP_URL ?? 'http://localhost:5173/'
const MAX_TOGGLE_MS = Number(process.env.MAX_TOGGLE_MS ?? 3000)

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)
return result.status ?? 1
}

const demo = path.resolve('test-fixtures/demo')
const photo = fs.readFileSync(path.join(demo, 'photo.jpg')).toString('base64')
const photo2 = fs.readFileSync(path.join(demo, 'photo2.jpg')).toString('base64')
const theme = fs.readFileSync(path.join(demo, 'theme.wav')).toString('base64')
const slides = Array.from({ length: 80 }, (_, index) => ({
durationInFrames: 90,
excluded: false,
filename: index % 2 === 0 ? 'photo.jpg' : 'photo2.jpg',
id: `slide-${index}`,
type: 'image',
}))
const slideshow = {
audioClips: [
{ filename: 'theme.wav' },
{ filename: 'theme.wav' },
{ filename: 'theme.wav' },
],
globalSettings: {
beatSync: true,
fitMode: 'cover',
imageDurationSecs: 4,
kenBurns: true,
transitionType: 'crossfade',
},
schemaVersion: 1,
slides,
}
const payload = {
'photo.jpg': photo,
'photo2.jpg': photo2,
'theme.wav': theme,
'slideshow.json': Buffer.from(JSON.stringify(slideshow, null, 2)).toString('base64'),
}

const code = `async page => {
const payload = ${JSON.stringify(payload)};
const maxToggleMs = ${MAX_TOGGLE_MS};

await page.goto(${JSON.stringify(APP_URL)});
await page.setViewportSize({ width: 1280, height: 800 });

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' : this.name.endsWith('.wav') ? 'audio/wav' : '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('photo2.jpg').first().waitFor({ timeout: 15000 });

const analyzing = page.getByText('Analyzing soundtrack');
if (await analyzing.first().isVisible().catch(() => false)) {
await analyzing.first().waitFor({ state: 'hidden', timeout: 30000 });
}

await page.getByRole('radio', { name: 'Classic', exact: true }).waitFor({ timeout: 10000 });

async function toggleTheme(name) {
const start = Date.now();
await page.getByRole('radio', { name, exact: true }).click();
const banner = page.getByText('Updating preview');
if (await banner.isVisible().catch(() => false)) {
await banner.waitFor({ state: 'hidden', timeout: maxToggleMs });
}
const ms = Date.now() - start;
console.log('toggle ' + name + ' ms=' + ms);
if (ms > maxToggleMs) {
throw new Error('toggle ' + name + ' took ' + ms + 'ms');
}
}

await toggleTheme('Energetic');
await toggleTheme('Classic');
await toggleTheme('Energetic');
console.log('PASS theme toggle smoke');
}`

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

status = run(['run-code', code])
run(['close'])
if (status !== 0) {
console.error('FAIL theme toggle smoke (exit ' + status + ')')
process.exit(status)
}
console.log('PASS theme toggle smoke complete')
6 changes: 6 additions & 0 deletions specs/multi-track-audio-timeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,9 @@ Closes #47.
When audio exceeds visual timeline, `totalFrames` = sum of clip durations and slides loop in order until audio ends. Partial tail truncates last slide. Visual wins when longer than audio.

Closes #46.

## Slice 23 — Beat grid across multi-clip timeline (done)

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.
53 changes: 53 additions & 0 deletions src/beat-grid/concat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { buildConcatenatedBeatTimes } from './concat'
import { resolveConcatenatedBeatTimes } from './manual'
import type { BeatGrid, BeatGridCache } from './types'

const MANUAL: BeatGrid = { bpm: 120, beatIntervalSecs: 0.5, firstBeatOffsetSecs: 0.1 }
const FPS = 30

describe('buildConcatenatedBeatTimes', () => {
it('shifts beat times by each clip start on the concatenated timeline', () => {
const cache: BeatGridCache = {
'a.mp3': { bpm: 120, beatIntervalSecs: 0.5, firstBeatOffsetSecs: 0 },
'b.mp3': { bpm: 120, beatIntervalSecs: 0.5, firstBeatOffsetSecs: 0.1 },
}
const clips = [
{ filename: 'a.mp3', durationInFrames: 60 },
{ filename: 'b.mp3', durationInFrames: 60 },
]

expect(buildConcatenatedBeatTimes(clips, cache, FPS)).toEqual([
0,
0.5,
1,
1.5,
2.1,
2.6,
3.1,
3.6,
])
})
})

describe('resolveConcatenatedBeatTimes', () => {
const clips = [
{ filename: 'a.mp3', durationInFrames: 60 },
{ filename: 'b.mp3', durationInFrames: 60 },
]

it('uses manual grid across total audio duration', () => {
const beatTimes = resolveConcatenatedBeatTimes(MANUAL, undefined, clips, FPS)
expect(beatTimes?.[0]).toBe(0.1)
expect(beatTimes?.at(-1)).toBeLessThan(4)
})

it('builds beat times from per-file cache when manual is absent', () => {
const cache: BeatGridCache = {
'a.mp3': { bpm: 120, beatIntervalSecs: 0.5, firstBeatOffsetSecs: 0 },
'b.mp3': { bpm: 120, beatIntervalSecs: 0.5, firstBeatOffsetSecs: 0 },
}
const beatTimes = resolveConcatenatedBeatTimes(undefined, cache, clips, FPS)
expect(beatTimes).toEqual(buildConcatenatedBeatTimes(clips, cache, FPS))
})
})
Loading
Loading