diff --git a/export-video-smoke-configure.png b/export-video-smoke-configure.png new file mode 100644 index 0000000..1a307de Binary files /dev/null and b/export-video-smoke-configure.png differ diff --git a/export-video-smoke.png b/export-video-smoke.png new file mode 100644 index 0000000..1c51411 Binary files /dev/null and b/export-video-smoke.png differ diff --git a/package.json b/package.json index 4239106..acb00f2 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,10 @@ "dependencies": { "@fontsource-variable/geist": "^5.2.9", "@remotion/google-fonts": "^4.0.475", - "@remotion/media": "^4.0.289", - "@remotion/player": "^4.0.289", - "@remotion/transitions": "^4.0.289", + "@remotion/media": "4.0.475", + "@remotion/player": "4.0.475", + "@remotion/transitions": "4.0.475", + "@remotion/web-renderer": "4.0.475", "@tailwindcss/vite": "^4.3.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -27,7 +28,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "react-resizable-panels": "^4.11.2", - "remotion": "^4.0.289", + "remotion": "4.0.475", "shadcn": "^4.11.0", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fa2f6e..1a07458 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,13 +18,16 @@ importers: specifier: ^4.0.475 version: 4.0.475(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@remotion/media': - specifier: ^4.0.289 + specifier: 4.0.475 version: 4.0.475(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@remotion/player': - specifier: ^4.0.289 + specifier: 4.0.475 version: 4.0.475(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@remotion/transitions': - specifier: ^4.0.289 + specifier: 4.0.475 + version: 4.0.475(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@remotion/web-renderer': + specifier: 4.0.475 version: 4.0.475(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tailwindcss/vite': specifier: ^4.3.0 @@ -54,7 +57,7 @@ importers: specifier: ^4.11.2 version: 4.11.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) remotion: - specifier: ^4.0.289 + specifier: 4.0.475 version: 4.0.475(react-dom@19.2.7(react@19.2.7))(react@19.2.7) shadcn: specifier: ^4.11.0 @@ -573,6 +576,21 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mediabunny/aac-encoder@1.45.0': + resolution: {integrity: sha512-vLQw8cY7Me6pvTTMkMhOiH9UCuINzfTOETCeDxbGNeNfDqc/7QlxloUH1Ylp/Zz2ek0O8kc6YdygV2vWAPakrA==} + peerDependencies: + mediabunny: ^1.46.0 + + '@mediabunny/flac-encoder@1.45.0': + resolution: {integrity: sha512-LfKbAMZVkxRS7PpEIVnWOY/l0KcHv+rjO7pYY3O0TPCZvbHWfrnQjn8JPacPIfuq6Yv7r4f8lhcl7yHSynoRkQ==} + peerDependencies: + mediabunny: ^1.46.0 + + '@mediabunny/mp3-encoder@1.45.0': + resolution: {integrity: sha512-Bobi6AaQYEc7TWmPJ8Q0/hcUtBN7pLUC2qjoC7oZR4FcGqGztby6k7A1SWlmswoMOEIhYsOrgDaemrSDAC0QVQ==} + peerDependencies: + mediabunny: ^1.46.0 + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -1318,6 +1336,9 @@ packages: '@remotion/google-fonts@4.0.475': resolution: {integrity: sha512-YJR0foFqmO9Jkufz37X1iDiS1HjKqRrp3y7SASolz9FxO8U4Ukp+JRe+KZTAkeLkPMaqi/fXuDljqhVKUjcYRQ==} + '@remotion/licensing@4.0.475': + resolution: {integrity: sha512-ZBDKg+iRa/mVoX/EWM40MypICKokWcG4c4RKQ4QKmy9JgJssbnFSy9mikPKMi/64J3KPEfAT1hv4JRKlNdoX5g==} + '@remotion/media@4.0.475': resolution: {integrity: sha512-X4RqOqPDwmNsfig6x950TxWtfb4nUWsyPSTT/RTJiw13lEaNNesr8Ob2Bm1NWYmYQLqHmt+kZbJPF6zBt/yDyA==} peerDependencies: @@ -1345,6 +1366,12 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + '@remotion/web-renderer@4.0.475': + resolution: {integrity: sha512-3vftJV5R95PQuH/fk9qW4/e9u5MQ13d2UgmQYIiuWtLuQLXz6k0wDywpha3E3+Hg1yRb7wvod+j3eF9gzGRFEA==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + '@rolldown/binding-android-arm64@1.0.3': resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4164,6 +4191,18 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mediabunny/aac-encoder@1.45.0(mediabunny@1.46.0)': + dependencies: + mediabunny: 1.46.0 + + '@mediabunny/flac-encoder@1.45.0(mediabunny@1.46.0)': + dependencies: + mediabunny: 1.46.0 + + '@mediabunny/mp3-encoder@1.45.0(mediabunny@1.46.0)': + dependencies: + mediabunny: 1.46.0 + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.25) @@ -4975,6 +5014,8 @@ snapshots: - react - react-dom + '@remotion/licensing@4.0.475': {} + '@remotion/media@4.0.475(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: mediabunny: 1.46.0 @@ -5006,6 +5047,17 @@ snapshots: react-dom: 19.2.7(react@19.2.7) remotion: 4.0.475(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@remotion/web-renderer@4.0.475(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@mediabunny/aac-encoder': 1.45.0(mediabunny@1.46.0) + '@mediabunny/flac-encoder': 1.45.0(mediabunny@1.46.0) + '@mediabunny/mp3-encoder': 1.45.0(mediabunny@1.46.0) + '@remotion/licensing': 4.0.475 + mediabunny: 1.46.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + remotion: 4.0.475(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@rolldown/binding-android-arm64@1.0.3': optional: true diff --git a/scripts/playwright-export-smoke.mjs b/scripts/playwright-export-smoke.mjs new file mode 100644 index 0000000..d3d8b45 --- /dev/null +++ b/scripts/playwright-export-smoke.mjs @@ -0,0 +1,224 @@ +// Smoke check for video export: open a demo project (photos + wav soundtrack), +// click Export Video, and assert a non-trivial mp4 lands in exports/. +// pnpm build && pnpm preview then node scripts/playwright-export-smoke.mjs +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' + +const PREVIEW_URL = process.env.PREVIEW_URL ?? 'http://localhost:4173/' + +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) + // playwright-cli exits 0 even when the page code throws — detect its error block. + if ((result.stdout ?? '').includes('### Error')) return 1 + 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({ + globalSettings: { + fitMode: 'cover', + imageDurationSecs: 1, + kenBurns: true, + transitionType: 'crossfade', + }, + schemaVersion: 1, + slides: [ + { durationInFrames: 30, excluded: false, filename: 'photo.jpg', id: 'photo-a', type: 'image' }, + { durationInFrames: 30, excluded: false, filename: 'photo2.jpg', id: 'photo-b', type: 'image' }, + ], + soundtrackFilename: 'theme.wav', +}, null, 2)).toString('base64') + +const code = `async page => { + const payload = ${JSON.stringify(payload)}; + + await page.goto(${JSON.stringify(PREVIEW_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; + } + function mimeFor(name) { + if (name.endsWith('.jpg')) return 'image/jpeg'; + if (name.endsWith('.wav')) return 'audio/wav'; + if (name.endsWith('.mp4')) return 'video/mp4'; + return 'application/json'; + } + class MockFileHandle { + constructor(name, base64) { + this.kind = 'file'; + this.name = name; + this._base64 = base64; + this.writtenBytes = 0; + } + async getFile() { + const bytes = decodeBase64(this._base64); + return new File([bytes], this.name, { type: mimeFor(this.name), lastModified: 1781145972852 }); + } + async createWritable() { + const handle = this; + return { + // Keep the written blob so the test can decode and verify it. + write: async (data) => { + const blob = data instanceof Blob ? data : new Blob([data]); + handle.writtenBlob = blob; + handle.writtenBytes += blob.size; + }, + close: async () => {}, + }; + } + } + class MockDirHandle { + constructor(name, files) { + this.kind = 'directory'; + this.name = name; + this._files = files; + this._dirs = new Map(); + } + 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 getDirectoryHandle(name, options) { + const dir = this._dirs.get(name); + if (dir) return dir; + if (options?.create) { + const created = new MockDirHandle(name, new Map()); + this._dirs.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.__mockRoot = new MockDirHandle('demo', handles); + window.showDirectoryPicker = async () => window.__mockRoot; + }, payload); + + await page.getByRole('button', { name: 'Open Folder' }).click(); + await page.getByText('photo2.jpg').first().waitFor(); + + const exportButton = page.getByRole('button', { name: 'Export Video' }); + await exportButton.waitFor(); + if (await exportButton.isDisabled()) throw new Error('Export Video button is disabled with slides present'); + + // --- 1. Default flow: MP4 into the project exports/ folder --- + await exportButton.click(); + await page.getByLabel('Format').waitFor(); + await page.screenshot({ path: 'export-video-smoke-configure.png' }); + await page.getByRole('button', { name: 'Start Export' }).click(); + + // Render + encode of the ~1.5s show. Generous timeout for software encoding. + await page.getByText('Saved as').waitFor({ timeout: 180000 }); + await page.screenshot({ path: 'export-video-smoke.png' }); + + const result = await page.evaluate(async () => { + const exportsDir = window.__mockRoot._dirs.get('exports'); + if (!exportsDir) return { error: 'no exports/ directory created' }; + const entry = [...exportsDir._files.entries()].find(([name]) => name.endsWith('.mp4')); + if (!entry) return { error: 'no .mp4 written to exports/' }; + const [name, handle] = entry; + const url = URL.createObjectURL(handle.writtenBlob); + const video = document.createElement('video'); + video.muted = true; + video.src = url; + try { + await new Promise((resolve, reject) => { + video.onloadedmetadata = resolve; + video.onerror = () => reject(new Error('exported mp4 failed to decode')); + }); + // Play briefly so Chrome reports decoded audio bytes (proves the aac track exists). + await video.play(); + await new Promise((resolve) => setTimeout(resolve, 1200)); + video.pause(); + return { + audioBytes: video.webkitAudioDecodedByteCount ?? -1, + bytes: handle.writtenBytes, + duration: video.duration, + height: video.videoHeight, + name, + width: video.videoWidth, + }; + } catch (error) { + return { error: String(error) }; + } finally { + URL.revokeObjectURL(url); + } + }); + if (result.error) throw new Error(result.error); + if (result.width !== 1920 || result.height !== 1080) { + throw new Error('expected 1920x1080, got ' + result.width + 'x' + result.height); + } + // Two 30-frame slides minus the crossfade overlap: between 1s and 3s. + if (!(result.duration > 1 && result.duration < 3)) { + throw new Error('expected 1-3s duration, got ' + result.duration); + } + if (result.audioBytes === 0) throw new Error('exported mp4 has no decodable audio track'); + console.log('PASS export: exports/' + result.name + ' decodes — ' + + result.width + 'x' + result.height + ', ' + result.duration.toFixed(2) + 's, ' + + result.bytes + ' bytes, audioBytes=' + result.audioBytes); + await page.getByRole('button', { name: 'Done' }).click(); + + // --- 2. Second path: WebM to a user-chosen location via showSaveFilePicker --- + await page.evaluate(() => { + window.__savePicked = null; + window.showSaveFilePicker = async (options) => { + const MockFileHandle = window.__mockRoot._files.get('photo.jpg').constructor; + const handle = new MockFileHandle(options?.suggestedName ?? 'export.webm', ''); + window.__savePicked = handle; + return handle; + }; + }); + await exportButton.click(); + await page.getByLabel('Format').click(); + await page.getByRole('option', { name: /WebM/ }).click(); + await page.getByLabel('Save to').click(); + await page.getByRole('option', { name: /Choose where/ }).click(); + await page.getByRole('button', { name: 'Start Export' }).click(); + await page.getByText('Saved as').waitFor({ timeout: 180000 }); + + const picked = await page.evaluate(() => { + const handle = window.__savePicked; + if (!handle) return { error: 'showSaveFilePicker was never called' }; + return { bytes: handle.writtenBytes, name: handle.name }; + }); + if (picked.error) throw new Error(picked.error); + if (!picked.name.endsWith('.webm')) throw new Error('expected .webm suggested name, got ' + picked.name); + // The gray fixture frames compress brutally under vp9 — any non-trivial size passes. + if (picked.bytes < 2000) throw new Error('webm suspiciously small: ' + picked.bytes + ' bytes'); + console.log('PASS save-as: ' + picked.name + ' (' + picked.bytes + ' bytes) written to picked location'); + await page.getByRole('button', { name: 'Done' }).click(); +}` + +let status = run(['open', PREVIEW_URL, '--browser=chrome']) +if (status !== 0) process.exit(status) + +status = run(['run-code', code]) +run(['close']) +process.exit(status) diff --git a/src/composition/SlideshowComposition.tsx b/src/composition/SlideshowComposition.tsx index f3181f7..aae4396 100644 --- a/src/composition/SlideshowComposition.tsx +++ b/src/composition/SlideshowComposition.tsx @@ -1,6 +1,6 @@ -import React from 'react' -import { AbsoluteFill, Html5Audio, Img, interpolate, useCurrentFrame } from 'remotion' -import { Video } from '@remotion/media' +import React, { useCallback } from 'react' +import { AbsoluteFill, Img, interpolate, useCurrentFrame } from 'remotion' +import { Audio, Video } from '@remotion/media' import { TransitionSeries, linearTiming } from '@remotion/transitions' import { fade } from '@remotion/transitions/fade' import type { TransitionPresentation, TransitionPresentationComponentProps } from '@remotion/transitions' @@ -9,7 +9,7 @@ import type { RenderPlanEntry, RenderPlan } from '../sequence-planner/types' import type { TransitionType } from '../timeline-core/settings' import { isTitleSlide } from '../timeline-core/types' import type { MediaSlide, TitleSlide } from '../timeline-core/types' -import { useSoundtrackVolume } from './soundtrackVolume' +import { volumeAtFrame } from './soundtrackVolume' type MediaRenderPlanEntry = Omit & { slide: MediaSlide } type TitleRenderPlanEntry = Omit & { slide: TitleSlide } @@ -88,9 +88,14 @@ export function SlideshowComposition({ plan }: SlideshowProps) { ) } +// @remotion/media's Audio (not Html5Audio) — required for renderMediaOnWeb export. function SoundtrackAudio({ track }: { track: NonNullable }) { - const volume = useSoundtrackVolume(track.duckingEnvelope) - return + const { duckingEnvelope } = track + const volume = useCallback( + (frame: number) => volumeAtFrame(duckingEnvelope, frame), + [duckingEnvelope], + ) + return