diff --git a/index.html b/index.html index dfb7022..2a00bbb 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,8 @@ + + Slideshow Maker diff --git a/issue-19-pwa-aspect-ratio.png b/issue-19-pwa-aspect-ratio.png new file mode 100644 index 0000000..313b638 Binary files /dev/null and b/issue-19-pwa-aspect-ratio.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..21c70e6 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..c27a420 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..81e7014 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "Slideshow Maker", + "short_name": "Slideshow", + "description": "Turn folders of photos and videos into beat-synced slideshows. Local-first: your media never leaves your disk.", + "id": "/", + "start_url": "/", + "display": "standalone", + "background_color": "#18181b", + "theme_color": "#18181b", + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..cba1b2c --- /dev/null +++ b/public/sw.js @@ -0,0 +1,69 @@ +/** + * App-shell service worker. + * + * Caches only the same-origin app shell (HTML, hashed JS/CSS/font/icon assets) + * so the editor loads offline. It must NEVER cache: + * - project media: served from blob: URLs, which never reach this handler + * - Jamendo (or any cross-origin) requests: search/preview/download stay + * network-only and fail gracefully offline + */ +const CACHE_NAME = 'slideshow-shell-v1' +const SHELL_URL = '/' + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.add(SHELL_URL)), + ) + self.skipWaiting() +}) + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))), + ).then(() => self.clients.claim()), + ) +}) + +async function networkFirstShell(request) { + const cache = await caches.open(CACHE_NAME) + try { + const response = await fetch(request) + if (response.ok) cache.put(SHELL_URL, response.clone()) + return response + } catch { + const cached = await cache.match(SHELL_URL) + if (cached) return cached + throw new Error('offline and app shell not cached') + } +} + +async function staleWhileRevalidate(request) { + const cache = await caches.open(CACHE_NAME) + const cached = await cache.match(request) + const refresh = fetch(request) + .then((response) => { + if (response.ok) cache.put(request, response.clone()) + return response + }) + .catch(() => undefined) + if (cached) return cached + const fresh = await refresh + if (fresh) return fresh + throw new Error('offline and asset not cached') +} + +self.addEventListener('fetch', (event) => { + const { request } = event + if (request.method !== 'GET') return + + const url = new URL(request.url) + // Cross-origin (e.g. Jamendo) and non-http schemes bypass the cache entirely. + if (url.origin !== self.location.origin) return + + if (request.mode === 'navigate') { + event.respondWith(networkFirstShell(request)) + return + } + event.respondWith(staleWhileRevalidate(request)) +}) diff --git a/scripts/generate-pwa-icons.mjs b/scripts/generate-pwa-icons.mjs new file mode 100644 index 0000000..d5c0bb8 --- /dev/null +++ b/scripts/generate-pwa-icons.mjs @@ -0,0 +1,45 @@ +// One-off generator: renders public/favicon.svg onto a dark square canvas and +// screenshots PNG app icons for the PWA manifest. Re-run if the favicon changes: +// node scripts/generate-pwa-icons.mjs +import { createRequire } from 'node:module' +import { mkdir, readdir, readFile } from 'node:fs/promises' +import path from 'node:path' + +const root = path.resolve(import.meta.dirname, '..') + +// playwright is a transitive dep of @playwright/cli; resolve it through the pnpm store. +async function resolvePlaywright() { + const pnpmDir = path.join(root, 'node_modules', '.pnpm') + const entries = await readdir(pnpmDir) + const cliDir = entries.find((name) => name.startsWith('@playwright+cli@')) + if (!cliDir) throw new Error('@playwright/cli not found in node_modules/.pnpm') + const requireFromCli = createRequire( + path.join(pnpmDir, cliDir, 'node_modules', '@playwright', 'cli', 'package.json'), + ) + return import(requireFromCli.resolve('playwright')) +} + +const SIZES = [192, 512] +const BACKGROUND = '#18181b' +// Logo occupies 60% of the canvas so it stays inside the maskable safe zone. +const LOGO_SCALE = 0.6 + +const playwright = await resolvePlaywright() +const chromium = playwright.chromium ?? playwright.default.chromium +const svg = await readFile(path.join(root, 'public', 'favicon.svg'), 'utf8') +const outDir = path.join(root, 'public', 'icons') +await mkdir(outDir, { recursive: true }) + +// channel: 'chrome' uses the system Chrome so no Playwright browser download is needed +const browser = await chromium.launch({ channel: 'chrome' }) +const page = await browser.newPage() +for (const size of SIZES) { + await page.setViewportSize({ width: size, height: size }) + await page.setContent(` + +
${svg.replace(' + `) + await page.screenshot({ path: path.join(outDir, `icon-${size}.png`) }) + console.log(`wrote public/icons/icon-${size}.png`) +} +await browser.close() diff --git a/scripts/playwright-issue-19-smoke.mjs b/scripts/playwright-issue-19-smoke.mjs new file mode 100644 index 0000000..077bbf0 --- /dev/null +++ b/scripts/playwright-issue-19-smoke.mjs @@ -0,0 +1,173 @@ +// Smoke check for issue #19 (PWA + aspect ratio). Run against a PROD preview, +// not the dev server — the service worker only registers in PROD builds: +// pnpm build && pnpm preview then node scripts/playwright-issue-19-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) + 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({ + aspectRatio: '9:16', + 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)}; + + // --- 1. Installability signals: manifest + active service worker --- + await page.goto(${JSON.stringify(PREVIEW_URL)}); + const manifest = await page.evaluate(async () => { + const link = document.querySelector('link[rel="manifest"]'); + if (!link) throw new Error('no in document'); + const res = await fetch(link.href); + if (!res.ok) throw new Error('manifest fetch failed: ' + res.status); + return res.json(); + }); + if (!manifest.icons || manifest.icons.length < 2 || manifest.display !== 'standalone') { + throw new Error('manifest not installable: ' + JSON.stringify(manifest)); + } + await page.evaluate(() => Promise.race([ + navigator.serviceWorker.ready, + new Promise((_, reject) => setTimeout(() => reject(new Error('service worker never ready')), 10000)), + ])); + + // Reload so the SW controls the page and runtime-caches the hashed assets. + await page.reload(); + const controlled = await page.evaluate(() => !!navigator.serviceWorker.controller); + if (!controlled) throw new Error('page not controlled by service worker after reload'); + console.log('PASS installability: manifest ok, service worker controlling'); + + // --- 2. 9:16 project: portrait composition, all fit modes render --- + 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.__mockHandles = handles; + window.showDirectoryPicker = async () => new MockDirHandle(handles); + }, payload); + + await page.getByRole('button', { name: 'Open Folder' }).click(); + await page.getByText('photo2.jpg').first().waitFor(); + + async function assertPortraitPlayer(label) { + const ratio = await page.evaluate(() => { + const player = document.querySelector('.__remotion-player'); + if (!player) return null; + const rect = player.getBoundingClientRect(); + return rect.width / rect.height; + }); + if (ratio === null) throw new Error(label + ': remotion player element not found'); + if (Math.abs(ratio - 9 / 16) > 0.01) { + throw new Error(label + ': expected portrait 9:16 player, got width/height=' + ratio.toFixed(3)); + } + console.log('PASS ' + label + ': player aspect ' + ratio.toFixed(3)); + } + + const aspectToggle = page.getByRole('radio', { name: '9:16' }); + if (!(await aspectToggle.getAttribute('data-state')).includes('on')) { + throw new Error('aspect toggle did not restore 9:16 from slideshow.json'); + } + await assertPortraitPlayer('9:16 restored (cover)'); + + // Play briefly to prove playback works in portrait. + await page.keyboard.press(' '); + await page.waitForTimeout(700); + await page.keyboard.press(' '); + + for (const fit of ['Letterbox', 'Blur fill']) { + await page.getByLabel('Fit mode').click(); + await page.getByRole('option', { name: fit }).click(); + await page.waitForTimeout(400); + await assertPortraitPlayer('9:16 ' + fit); + } + await page.screenshot({ path: 'issue-19-pwa-aspect-ratio.png', fullPage: true }); + + // --- 3. Offline: app shell still loads --- + await page.context().setOffline(true); + await page.reload(); + await page.getByRole('button', { name: 'Open Folder' }).waitFor({ timeout: 10000 }); + console.log('PASS offline: app shell loaded while offline'); + await page.context().setOffline(false); +}` + +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/editor-shell/App.tsx b/src/editor-shell/App.tsx index e3c431f..1f78f43 100644 --- a/src/editor-shell/App.tsx +++ b/src/editor-shell/App.tsx @@ -11,7 +11,7 @@ import { createTitleSlide, } from '../timeline-core' import type { GlobalSettings, SlideOverrides, ThemeName } from '../timeline-core' -import { applyTheme } from '../timeline-core' +import { applyTheme, dimensionsForAspectRatio } from '../timeline-core' import { plan, slideIdAtFrame, startFrameForSlideId } from '../sequence-planner' import { AppHeader } from './AppHeader' import { DropImportLayer } from './DropImportLayer' @@ -32,6 +32,8 @@ export function App() { const clearSelection = useCallback(() => setSelectedSlideId(null), []) const project = useProject({ onFolderLoaded: clearSelection }) const { + aspectRatio, + setAspectRatio, audioTracks, globalSettings, setGlobalSettings, @@ -106,6 +108,7 @@ export function App() { [globalSettings, selectedSoundtrack, slides], ) const totalFrames = renderPlan.totalFrames > 0 ? renderPlan.totalFrames : FPS + const canvas = dimensionsForAspectRatio(aspectRatio) const handleFrameChange = useCallback((frame: number) => { setCurrentSlideId(slideIdAtFrame(renderPlan, frame)) @@ -168,6 +171,8 @@ export function App() { void settings: GlobalSettings themeName: ThemeName | null onSettingsChange: (updated: GlobalSettings) => void @@ -26,6 +28,8 @@ type Props = { } export function EditorSidebar({ + aspectRatio, + onAspectRatioChange, settings, themeName, onSettingsChange, @@ -51,6 +55,8 @@ export function EditorSidebar({ Settings = { classic: 'Classic', @@ -20,13 +22,15 @@ const THEME_LABELS: Record = { const THEME_NAMES: ThemeName[] = ['classic', 'energetic', 'plain'] type Props = { + aspectRatio: AspectRatio + onAspectRatioChange: (ratio: AspectRatio) => void settings: GlobalSettings onChange: (updated: GlobalSettings) => void onThemeChange: (name: ThemeName) => void themeName: ThemeName | null } -export function GlobalSettingsPanel({ settings, onChange, onThemeChange, themeName }: Props) { +export function GlobalSettingsPanel({ aspectRatio, onAspectRatioChange, settings, onChange, onThemeChange, themeName }: Props) { function set(key: K, value: GlobalSettings[K]) { onChange({ ...settings, [key]: value }) } @@ -53,6 +57,26 @@ export function GlobalSettingsPanel({ settings, onChange, onThemeChange, themeNa
+
+ + { + if (value) onAspectRatioChange(value as AspectRatio) + }} + > + {ASPECT_RATIOS.map(ratio => ( + + {ratio} + + ))} + +
+
diff --git a/src/editor-shell/PlayerPane.tsx b/src/editor-shell/PlayerPane.tsx index 040951a..780a1cd 100644 --- a/src/editor-shell/PlayerPane.tsx +++ b/src/editor-shell/PlayerPane.tsx @@ -8,17 +8,17 @@ import type { RenderPlan } from '../sequence-planner' import { SlideshowComposition } from '../composition' export const FPS = 30 -export const COMP_WIDTH = 1920 -export const COMP_HEIGHT = 1080 type Props = { + compositionHeight: number + compositionWidth: number onFrameChange: (frame: number) => void playerRef?: React.RefObject renderPlan: RenderPlan totalFrames: number } -export function PlayerPane({ onFrameChange, playerRef, renderPlan, totalFrames }: Props) { +export function PlayerPane({ compositionHeight, compositionWidth, onFrameChange, playerRef, renderPlan, totalFrames }: Props) { const embeddedHostRef = useRef(null) const presentationHostRef = useRef(null) const fallbackPlayerRef = useRef(null) @@ -93,8 +93,8 @@ export function PlayerPane({ onFrameChange, playerRef, renderPlan, totalFrames } component={SlideshowComposition} controls durationInFrames={totalFrames} - compositionWidth={COMP_WIDTH} - compositionHeight={COMP_HEIGHT} + compositionWidth={compositionWidth} + compositionHeight={compositionHeight} fps={FPS} initialFrame={initialFrame} inputProps={{ plan: renderPlan }} diff --git a/src/editor-shell/slidePersistence.test.ts b/src/editor-shell/slidePersistence.test.ts index 696c495..b0cc483 100644 --- a/src/editor-shell/slidePersistence.test.ts +++ b/src/editor-shell/slidePersistence.test.ts @@ -53,4 +53,14 @@ describe('slidesToJson', () => { const json = slidesToJson(DEFAULT_GLOBAL_SETTINGS, [makeSlide('a.jpg')], 'track.mp3', null, null) expect(json.soundtrackAttribution).toBeUndefined() }) + + it('includes aspectRatio when provided', () => { + const json = slidesToJson(DEFAULT_GLOBAL_SETTINGS, [makeSlide('a.jpg')], null, null, null, '9:16') + expect(json.aspectRatio).toBe('9:16') + }) + + it('omits aspectRatio when not provided', () => { + const json = slidesToJson(DEFAULT_GLOBAL_SETTINGS, [makeSlide('a.jpg')], null, null, null) + expect(json.aspectRatio).toBeUndefined() + }) }) diff --git a/src/editor-shell/slidePersistence.ts b/src/editor-shell/slidePersistence.ts index b2a3989..9ca5ae2 100644 --- a/src/editor-shell/slidePersistence.ts +++ b/src/editor-shell/slidePersistence.ts @@ -1,6 +1,6 @@ import type { MediaSlide, Slide } from '../timeline-core/types' import { isTitleSlide } from '../timeline-core/types' -import type { GlobalSettings, ThemeName } from '../timeline-core' +import type { AspectRatio, GlobalSettings, ThemeName } from '../timeline-core' import { SCHEMA_VERSION, type SlideshowJson } from '../project-store' import type { JamendoAttribution } from '../jamendo/types' @@ -34,10 +34,12 @@ export function slidesToJson( soundtrackFilename?: string | null, themeName?: ThemeName | null, soundtrackAttribution?: JamendoAttribution | null, + aspectRatio?: AspectRatio | null, ): SlideshowJson { return { globalSettings, schemaVersion: SCHEMA_VERSION, + ...(aspectRatio ? { aspectRatio } : {}), ...(soundtrackFilename ? { soundtrackFilename } : {}), ...(themeName ? { themeName } : {}), ...(soundtrackAttribution ? { soundtrackAttribution } : {}), diff --git a/src/editor-shell/useProject.ts b/src/editor-shell/useProject.ts index 133898b..7c24fa8 100644 --- a/src/editor-shell/useProject.ts +++ b/src/editor-shell/useProject.ts @@ -1,8 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { Slide } from '../timeline-core/types' import { isTitleSlide } from '../timeline-core/types' -import { DEFAULT_GLOBAL_SETTINGS } from '../timeline-core' -import type { GlobalSettings, ThemeName } from '../timeline-core' +import { DEFAULT_ASPECT_RATIO, DEFAULT_GLOBAL_SETTINGS, isAspectRatio } from '../timeline-core' +import type { AspectRatio, GlobalSettings, ThemeName } from '../timeline-core' import { addRecentProject, enumerateAudioTracks, @@ -37,6 +37,7 @@ type Options = { } export function useProject({ onFolderLoaded }: Options = {}) { + const [aspectRatio, setAspectRatio] = useState(DEFAULT_ASPECT_RATIO) const [audioTracks, setAudioTracks] = useState([]) const [globalSettings, setGlobalSettings] = useState(DEFAULT_GLOBAL_SETTINGS) const [slides, setSlides] = useState([]) @@ -87,9 +88,9 @@ export function useProject({ onFolderLoaded }: Options = {}) { if (!handle) return if (autosaveTimerRef.current) clearTimeout(autosaveTimerRef.current) autosaveTimerRef.current = setTimeout(() => { - saveProject(handle, slidesToJson(globalSettings, slides, soundtrackFilename, themeName, soundtrackAttribution)).catch(console.error) + saveProject(handle, slidesToJson(globalSettings, slides, soundtrackFilename, themeName, soundtrackAttribution, aspectRatio)).catch(console.error) }, AUTOSAVE_DELAY) - }, [globalSettings, slides, soundtrackFilename, soundtrackAttribution, themeName]) + }, [aspectRatio, globalSettings, slides, soundtrackFilename, soundtrackAttribution, themeName]) const loadFolder = useCallback( async (handle: FileSystemDirectoryHandle, savedData?: SlideshowJson) => { @@ -119,6 +120,7 @@ export function useProject({ onFolderLoaded }: Options = {}) { }) pendingAudioRevokeRef.current = audioTracks pendingRevokeRef.current = latestSlidesRef.current + setAspectRatio(isAspectRatio(savedData?.aspectRatio) ? savedData.aspectRatio : DEFAULT_ASPECT_RATIO) setAudioTracks(nextAudioTracks) setGlobalSettings(restoredSettings) setThemeName(restoredThemeName) @@ -249,6 +251,8 @@ export function useProject({ onFolderLoaded }: Options = {}) { }, [globalSettings]) return { + aspectRatio, + setAspectRatio, audioTracks, globalSettings, setGlobalSettings, diff --git a/src/main.tsx b/src/main.tsx index 6029a4d..7354f94 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,3 +8,10 @@ createRoot(document.getElementById('root')!).render( , ) + +// App-shell caching only (PROD builds; dev would cache Vite's transient module URLs). +if (import.meta.env.PROD && 'serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js').catch(console.error) + }) +} diff --git a/src/project-store/schema.ts b/src/project-store/schema.ts index 3129af6..0c0e03f 100644 --- a/src/project-store/schema.ts +++ b/src/project-store/schema.ts @@ -1,3 +1,4 @@ +import type { AspectRatio } from '../timeline-core/aspect' import type { GlobalSettings, SlideOverrides, ThemeName } from '../timeline-core/settings' import type { BeatGrid } from '../beat-grid/types' import type { JamendoAttribution } from '../jamendo/types' @@ -28,6 +29,7 @@ export type SerializedTitleSlide = { export type SerializedSlide = SerializedMediaSlide | SerializedTitleSlide export type SlideshowJson = { + aspectRatio?: AspectRatio globalSettings?: GlobalSettings schemaVersion: number slides: SerializedSlide[] diff --git a/src/timeline-core/aspect.test.ts b/src/timeline-core/aspect.test.ts new file mode 100644 index 0000000..d67bc9e --- /dev/null +++ b/src/timeline-core/aspect.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' +import { DEFAULT_ASPECT_RATIO, dimensionsForAspectRatio, isAspectRatio } from './aspect' + +describe('dimensionsForAspectRatio', () => { + it('16:9 is a 1920×1080 landscape canvas', () => { + expect(dimensionsForAspectRatio('16:9')).toEqual({ width: 1920, height: 1080 }) + }) + + it('9:16 is a 1080×1920 portrait canvas', () => { + expect(dimensionsForAspectRatio('9:16')).toEqual({ width: 1080, height: 1920 }) + }) + + it('1:1 is a 1080×1080 square canvas', () => { + expect(dimensionsForAspectRatio('1:1')).toEqual({ width: 1080, height: 1080 }) + }) +}) + +describe('isAspectRatio', () => { + it('accepts every supported ratio', () => { + expect(isAspectRatio('16:9')).toBe(true) + expect(isAspectRatio('9:16')).toBe(true) + expect(isAspectRatio('1:1')).toBe(true) + }) + + it('rejects unsupported values from untrusted slideshow.json', () => { + expect(isAspectRatio('4:3')).toBe(false) + expect(isAspectRatio(123)).toBe(false) + expect(isAspectRatio(undefined)).toBe(false) + expect(isAspectRatio(null)).toBe(false) + }) +}) + +describe('DEFAULT_ASPECT_RATIO', () => { + it('defaults to a 16:9 1080p canvas', () => { + expect(DEFAULT_ASPECT_RATIO).toBe('16:9') + expect(dimensionsForAspectRatio(DEFAULT_ASPECT_RATIO)).toEqual({ width: 1920, height: 1080 }) + }) +}) diff --git a/src/timeline-core/aspect.ts b/src/timeline-core/aspect.ts new file mode 100644 index 0000000..75dbd0f --- /dev/null +++ b/src/timeline-core/aspect.ts @@ -0,0 +1,26 @@ +export const ASPECT_RATIOS = ['16:9', '9:16', '1:1'] as const + +export type AspectRatio = (typeof ASPECT_RATIOS)[number] + +export const DEFAULT_ASPECT_RATIO: AspectRatio = '16:9' + +export type CanvasDimensions = { + width: number + height: number +} + +/** Boundary guard: slideshow.json is untrusted input, so validate before use. */ +export function isAspectRatio(value: unknown): value is AspectRatio { + return typeof value === 'string' && (ASPECT_RATIOS as readonly string[]).includes(value) +} + +export function dimensionsForAspectRatio(ratio: AspectRatio): CanvasDimensions { + switch (ratio) { + case '16:9': + return { width: 1920, height: 1080 } + case '9:16': + return { width: 1080, height: 1920 } + case '1:1': + return { width: 1080, height: 1080 } + } +} diff --git a/src/timeline-core/index.ts b/src/timeline-core/index.ts index e3642fd..0a14e88 100644 --- a/src/timeline-core/index.ts +++ b/src/timeline-core/index.ts @@ -1,3 +1,5 @@ +export { ASPECT_RATIOS, DEFAULT_ASPECT_RATIO, dimensionsForAspectRatio, isAspectRatio } from './aspect' +export type { AspectRatio, CanvasDimensions } from './aspect' export { getMediaType, isSupportedAudio, isSupportedMedia, sortByFilename } from './media' export { moveSlide, toggleExcluded, filterIncluded, createTitleSlide } from './timeline' export { resolve, applyImageDuration, DEFAULT_GLOBAL_SETTINGS, THEMES, applyTheme } from './settings'