{isTitleSlide(slide) ? (
) : slide.type === 'video' ? (
diff --git a/src/project-store/media-loader.ts b/src/project-store/media-loader.ts
index c77872a..9fc23a2 100644
--- a/src/project-store/media-loader.ts
+++ b/src/project-store/media-loader.ts
@@ -25,6 +25,31 @@ async function getVideoDurationFrames(file: File): Promise {
}
}
+async function getImageDimensions(file: File): Promise<{ height: number; width: number } | undefined> {
+ try {
+ const bitmap = await createImageBitmap(file)
+ const dimensions = { height: bitmap.height, width: bitmap.width }
+ bitmap.close()
+ return dimensions
+ } catch {
+ return undefined
+ }
+}
+
+async function getVideoDimensions(file: File): Promise<{ height: number; width: number } | undefined> {
+ try {
+ const input = new Input({
+ formats: ALL_FORMATS,
+ source: new BlobSource(file),
+ })
+ const videoTrack = await input.getPrimaryVideoTrack()
+ if (!videoTrack) return undefined
+ return { height: videoTrack.displayHeight, width: videoTrack.displayWidth }
+ } catch {
+ return undefined
+ }
+}
+
export async function createMediaSlideFromFile(file: File): Promise {
const filename = file.name
const type = getMediaType(filename)
@@ -33,12 +58,16 @@ export async function createMediaSlideFromFile(file: File): Promise
type === 'video'
? await getVideoDurationFrames(file)
: IMAGE_DURATION_FRAMES
+ const dimensions = type === 'video'
+ ? await getVideoDimensions(file)
+ : await getImageDimensions(file)
return {
blobUrl,
durationInFrames,
excluded: false,
filename,
+ ...(dimensions ? { height: dimensions.height, width: dimensions.width } : {}),
id: `${filename}-${file.lastModified}`,
type,
}
diff --git a/src/sequence-planner/planner.test.ts b/src/sequence-planner/planner.test.ts
index 0193dcb..20d38a0 100644
--- a/src/sequence-planner/planner.test.ts
+++ b/src/sequence-planner/planner.test.ts
@@ -261,6 +261,13 @@ describe('plan — Ken Burns vectors', () => {
describe('plan — fit mode resolution', () => {
const IMG = makeSlide('i', 'image', 90)
const VID = makeSlide('v', 'video', 120)
+ const LANDSCAPE_IMG: MediaSlide = {
+ ...IMG,
+ filename: 'landscape.jpg',
+ height: 1080,
+ width: 1920,
+ }
+ const PORTRAIT_CANVAS = '9:16' as const
it('videos always get fitMode=contain regardless of global setting', () => {
const settings: GlobalSettings = { ...DEFAULT_GLOBAL_SETTINGS, fitMode: 'cover' }
@@ -269,7 +276,7 @@ describe('plan — fit mode resolution', () => {
expect(result.entries[1].fitMode).toBe('contain')
})
- it('images inherit global fitMode', () => {
+ it('images inherit global fitMode when not smart-fit', () => {
const cover: GlobalSettings = { ...DEFAULT_GLOBAL_SETTINGS, fitMode: 'cover' }
const contain: GlobalSettings = { ...DEFAULT_GLOBAL_SETTINGS, fitMode: 'contain' }
const blur: GlobalSettings = { ...DEFAULT_GLOBAL_SETTINGS, fitMode: 'blur-fill' }
@@ -278,6 +285,24 @@ describe('plan — fit mode resolution', () => {
expect(plan([IMG], contain).entries[0].fitMode).toBe('contain')
expect(plan([IMG], blur).entries[0].fitMode).toBe('blur-fill')
})
+
+ it('smart-fit uses blur-fill for landscape media in a portrait canvas', () => {
+ const settings: GlobalSettings = { ...DEFAULT_GLOBAL_SETTINGS, fitMode: 'smart-fit' }
+ const result = plan([LANDSCAPE_IMG], settings, undefined, undefined, undefined, undefined, PORTRAIT_CANVAS)
+ expect(result.entries[0].fitMode).toBe('blur-fill')
+ })
+
+ it('smart-fit uses cover for matching landscape media in a landscape canvas', () => {
+ const settings: GlobalSettings = { ...DEFAULT_GLOBAL_SETTINGS, fitMode: 'smart-fit' }
+ const result = plan([LANDSCAPE_IMG], settings)
+ expect(result.entries[0].fitMode).toBe('cover')
+ })
+
+ it('smart-fit falls back to contain when media dimensions are unknown', () => {
+ const settings: GlobalSettings = { ...DEFAULT_GLOBAL_SETTINGS, fitMode: 'smart-fit' }
+ const result = plan([IMG], settings)
+ expect(result.entries[0].fitMode).toBe('contain')
+ })
})
// --- Per-slide overrides ---
@@ -326,7 +351,7 @@ describe('plan — per-slide overrides', () => {
it('undefined override field falls back to global', () => {
const img = { ...makeSlide('a', 'image', 90), overrides: { fitMode: undefined } }
const result = plan([img], BASE)
- expect(result.entries[0].fitMode).toBe('cover') // global default
+ expect(result.entries[0].fitMode).toBe('cover') // global default in BASE fixture
})
})
@@ -456,7 +481,11 @@ describe('plan — audio clips', () => {
})
describe('plan — audio-driven loop golden snapshot', () => {
- const CUT_SETTINGS: GlobalSettings = { ...DEFAULT_GLOBAL_SETTINGS, transitionType: 'cut' }
+ const CUT_SETTINGS: GlobalSettings = {
+ ...DEFAULT_GLOBAL_SETTINGS,
+ fitMode: 'cover',
+ transitionType: 'cut',
+ }
const LONG_AUDIO = [{ blobUrl: 'blob:long', durationInFrames: 250 }]
it('matches golden snapshot for looped entries with a partial tail', () => {
diff --git a/src/sequence-planner/planner.ts b/src/sequence-planner/planner.ts
index fc7c6ba..3fff06d 100644
--- a/src/sequence-planner/planner.ts
+++ b/src/sequence-planner/planner.ts
@@ -1,5 +1,9 @@
import { resolve } from '../timeline-core/settings'
import type { FitMode, GlobalSettings, TransitionType } from '../timeline-core/settings'
+import type { AspectRatio } from '../timeline-core/aspect'
+import { dimensionsForAspectRatio } from '../timeline-core/aspect'
+import { resolveSmartFit } from '../timeline-core/smartFit'
+import type { ConcreteFitMode } from '../timeline-core/smartFit'
import { isTitleSlide } from '../timeline-core/types'
import type { Slide } from '../timeline-core/types'
import type { BeatGrid } from '../beat-grid/types'
@@ -104,6 +108,7 @@ export function plan(
audioClips?: AudioClipInput[],
beatGrid?: BeatGrid,
concatenatedBeatTimesSecs?: number[],
+ aspectRatio: AspectRatio = '16:9',
): RenderPlan {
if (slides.length === 0) {
const audioTotal = totalAudioFrames(audioClips)
@@ -146,9 +151,30 @@ export function plan(
return raw
}
- function getFitMode(slide: Slide): FitMode {
+ const canvas = dimensionsForAspectRatio(aspectRatio)
+
+ function getMediaDimensions(slide: Slide): { height?: number; width?: number } {
+ if (isTitleSlide(slide)) return {}
+ const meta = mediaMetadata?.get(slide.filename)
+ return {
+ height: slide.height ?? meta?.height,
+ width: slide.width ?? meta?.width,
+ }
+ }
+
+ function resolveConcreteFitMode(slide: Slide, fitMode: FitMode): ConcreteFitMode {
+ if (fitMode !== 'smart-fit') return fitMode
+
+ const { height, width } = getMediaDimensions(slide)
+ if (width === undefined || height === undefined) return 'contain'
+
+ return resolveSmartFit(width, height, canvas.width, canvas.height)
+ }
+
+ function getFitMode(slide: Slide): ConcreteFitMode {
if (isTitleSlide(slide)) return 'cover' // unused; TitleSlideView renders its own layout
- return slide.type === 'video' ? 'contain' : resolved(slide).fitMode
+ if (slide.type === 'video') return 'contain'
+ return resolveConcreteFitMode(slide, resolved(slide).fitMode)
}
function getTransitionDur(slide: Slide): number {
diff --git a/src/sequence-planner/types.ts b/src/sequence-planner/types.ts
index 0f82097..4144ec6 100644
--- a/src/sequence-planner/types.ts
+++ b/src/sequence-planner/types.ts
@@ -1,4 +1,5 @@
-import type { FitMode, TransitionType } from '../timeline-core/settings'
+import type { TransitionType } from '../timeline-core/settings'
+import type { ConcreteFitMode } from '../timeline-core/smartFit'
import type { Slide } from '../timeline-core/types'
export type TransitionSpec = {
@@ -37,7 +38,7 @@ export type RenderPlanEntry = {
startFrame: number
durationInFrames: number
transitionIn?: TransitionSpec
- fitMode: FitMode
+ fitMode: ConcreteFitMode
kenBurns: KenBurnsVector | null
videoVolume: number
}
@@ -65,4 +66,6 @@ export type RenderPlan = {
export type MediaMetadata = {
durationInFrames?: number
+ height?: number
+ width?: number
}
diff --git a/src/timeline-core/aspect.test.ts b/src/timeline-core/aspect.test.ts
index d67bc9e..3ac5734 100644
--- a/src/timeline-core/aspect.test.ts
+++ b/src/timeline-core/aspect.test.ts
@@ -1,5 +1,11 @@
import { describe, it, expect } from 'vitest'
-import { DEFAULT_ASPECT_RATIO, dimensionsForAspectRatio, isAspectRatio } from './aspect'
+import {
+ DEFAULT_ASPECT_RATIO,
+ DEFAULT_MEDIA_ASPECT_RATIO_CSS,
+ dimensionsForAspectRatio,
+ isAspectRatio,
+ mediaAspectRatioCss,
+} from './aspect'
describe('dimensionsForAspectRatio', () => {
it('16:9 is a 1920×1080 landscape canvas', () => {
@@ -36,3 +42,14 @@ describe('DEFAULT_ASPECT_RATIO', () => {
expect(dimensionsForAspectRatio(DEFAULT_ASPECT_RATIO)).toEqual({ width: 1920, height: 1080 })
})
})
+
+describe('mediaAspectRatioCss', () => {
+ it('returns width / height when dimensions are known', () => {
+ expect(mediaAspectRatioCss(1080, 1920)).toBe('1080 / 1920')
+ })
+
+ it('falls back to 16 / 9 when dimensions are missing or invalid', () => {
+ expect(mediaAspectRatioCss()).toBe(DEFAULT_MEDIA_ASPECT_RATIO_CSS)
+ expect(mediaAspectRatioCss(0, 1080)).toBe(DEFAULT_MEDIA_ASPECT_RATIO_CSS)
+ })
+})
diff --git a/src/timeline-core/aspect.ts b/src/timeline-core/aspect.ts
index 75dbd0f..bebb76e 100644
--- a/src/timeline-core/aspect.ts
+++ b/src/timeline-core/aspect.ts
@@ -14,6 +14,15 @@ export function isAspectRatio(value: unknown): value is AspectRatio {
return typeof value === 'string' && (ASPECT_RATIOS as readonly string[]).includes(value)
}
+export const DEFAULT_MEDIA_ASPECT_RATIO_CSS = '16 / 9'
+
+export function mediaAspectRatioCss(width?: number, height?: number): string {
+ if (width !== undefined && height !== undefined && width > 0 && height > 0) {
+ return `${width} / ${height}`
+ }
+ return DEFAULT_MEDIA_ASPECT_RATIO_CSS
+}
+
export function dimensionsForAspectRatio(ratio: AspectRatio): CanvasDimensions {
switch (ratio) {
case '16:9':
diff --git a/src/timeline-core/index.ts b/src/timeline-core/index.ts
index 641b8a9..fb228b7 100644
--- a/src/timeline-core/index.ts
+++ b/src/timeline-core/index.ts
@@ -1,4 +1,4 @@
-export { ASPECT_RATIOS, DEFAULT_ASPECT_RATIO, dimensionsForAspectRatio, isAspectRatio } from './aspect'
+export { ASPECT_RATIOS, DEFAULT_ASPECT_RATIO, DEFAULT_MEDIA_ASPECT_RATIO_CSS, dimensionsForAspectRatio, isAspectRatio, mediaAspectRatioCss } from './aspect'
export type { AspectRatio, CanvasDimensions } from './aspect'
export { getMediaType, isSupportedAudio, isSupportedMedia, sortByFilename } from './media'
export { addAudioClip, moveAudioClip, removeAudioClip, updateAudioClipGain } from './audioClips'
@@ -16,3 +16,5 @@ export type {
} from './settings'
export type { AudioClip, MediaSlide, TitleSlide, Slide, MediaType } from './types'
export { isTitleSlide } from './types'
+export { resolveSmartFit } from './smartFit'
+export type { ConcreteFitMode } from './smartFit'
diff --git a/src/timeline-core/settings.ts b/src/timeline-core/settings.ts
index b07f50b..39dd1c2 100644
--- a/src/timeline-core/settings.ts
+++ b/src/timeline-core/settings.ts
@@ -2,7 +2,7 @@ import { isTitleSlide } from './types'
import type { Slide } from './types'
export type TransitionType = 'crossfade' | 'dip-to-black' | 'cut'
-export type FitMode = 'cover' | 'contain' | 'blur-fill'
+export type FitMode = 'blur-fill' | 'contain' | 'cover' | 'smart-fit'
export type KenBurnsMode = 'alternate' | 'zoom-in-only'
export type ThemeName = 'classic' | 'energetic' | 'plain'
export type Energy = 'calm' | 'medium' | 'punchy'
@@ -32,7 +32,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
imageDurationSecs: 3,
transitionType: 'crossfade',
kenBurns: true,
- fitMode: 'cover',
+ fitMode: 'smart-fit',
}
const FPS = 30
@@ -54,20 +54,20 @@ export const THEMES: Record = {
imageDurationSecs: 4,
transitionType: 'crossfade',
kenBurns: true,
- fitMode: 'cover',
+ fitMode: 'smart-fit',
},
energetic: {
imageDurationSecs: 2,
transitionType: 'cut',
kenBurns: true,
- fitMode: 'cover',
+ fitMode: 'smart-fit',
kenBurnsMode: 'zoom-in-only',
},
plain: {
imageDurationSecs: 5,
transitionType: 'cut',
kenBurns: false,
- fitMode: 'cover',
+ fitMode: 'smart-fit',
},
}
diff --git a/src/timeline-core/smartFit.test.ts b/src/timeline-core/smartFit.test.ts
new file mode 100644
index 0000000..437c23d
--- /dev/null
+++ b/src/timeline-core/smartFit.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from 'vitest'
+import { resolveSmartFit } from './smartFit'
+
+const LANDSCAPE_16_9 = { height: 1080, width: 1920 }
+const PORTRAIT_9_16 = { height: 1920, width: 1080 }
+const SQUARE = { height: 1080, width: 1080 }
+
+describe('resolveSmartFit', () => {
+ it('uses cover when media and canvas share landscape orientation', () => {
+ expect(resolveSmartFit(
+ LANDSCAPE_16_9.width,
+ LANDSCAPE_16_9.height,
+ LANDSCAPE_16_9.width,
+ LANDSCAPE_16_9.height,
+ )).toBe('cover')
+ })
+
+ it('uses cover when media and canvas share portrait orientation', () => {
+ expect(resolveSmartFit(
+ PORTRAIT_9_16.width,
+ PORTRAIT_9_16.height,
+ PORTRAIT_9_16.width,
+ PORTRAIT_9_16.height,
+ )).toBe('cover')
+ })
+
+ it('uses blur-fill for landscape media in a portrait canvas', () => {
+ expect(resolveSmartFit(
+ LANDSCAPE_16_9.width,
+ LANDSCAPE_16_9.height,
+ PORTRAIT_9_16.width,
+ PORTRAIT_9_16.height,
+ )).toBe('blur-fill')
+ })
+
+ it('uses blur-fill for portrait media in a landscape canvas', () => {
+ expect(resolveSmartFit(
+ PORTRAIT_9_16.width,
+ PORTRAIT_9_16.height,
+ LANDSCAPE_16_9.width,
+ LANDSCAPE_16_9.height,
+ )).toBe('blur-fill')
+ })
+
+ it('uses blur-fill for square media in a landscape canvas', () => {
+ expect(resolveSmartFit(
+ SQUARE.width,
+ SQUARE.height,
+ LANDSCAPE_16_9.width,
+ LANDSCAPE_16_9.height,
+ )).toBe('blur-fill')
+ })
+
+ it('uses blur-fill for landscape media in a square canvas', () => {
+ expect(resolveSmartFit(
+ LANDSCAPE_16_9.width,
+ LANDSCAPE_16_9.height,
+ SQUARE.width,
+ SQUARE.height,
+ )).toBe('blur-fill')
+ })
+
+ it('uses cover for square media in a square canvas', () => {
+ expect(resolveSmartFit(
+ SQUARE.width,
+ SQUARE.height,
+ SQUARE.width,
+ SQUARE.height,
+ )).toBe('cover')
+ })
+
+ it('falls back to contain when dimensions are missing or zero', () => {
+ expect(resolveSmartFit(0, 1080, 1920, 1080)).toBe('contain')
+ expect(resolveSmartFit(1920, 0, 1920, 1080)).toBe('contain')
+ })
+})
diff --git a/src/timeline-core/smartFit.ts b/src/timeline-core/smartFit.ts
new file mode 100644
index 0000000..4af3cc7
--- /dev/null
+++ b/src/timeline-core/smartFit.ts
@@ -0,0 +1,46 @@
+import type { FitMode } from './settings'
+
+export type ConcreteFitMode = Exclude
+
+const ORIENTATION_THRESHOLD = 0.05
+
+function isLandscape(aspect: number): boolean {
+ return aspect > 1 + ORIENTATION_THRESHOLD
+}
+
+function isPortrait(aspect: number): boolean {
+ return aspect < 1 - ORIENTATION_THRESHOLD
+}
+
+function isSquare(aspect: number): boolean {
+ return !isLandscape(aspect) && !isPortrait(aspect)
+}
+
+export function resolveSmartFit(
+ mediaWidth: number,
+ mediaHeight: number,
+ canvasWidth: number,
+ canvasHeight: number,
+): ConcreteFitMode {
+ if (mediaWidth <= 0 || mediaHeight <= 0 || canvasWidth <= 0 || canvasHeight <= 0) {
+ return 'contain'
+ }
+
+ const mediaAspect = mediaWidth / mediaHeight
+ const canvasAspect = canvasWidth / canvasHeight
+
+ const orientationMismatch =
+ (isLandscape(mediaAspect) && isPortrait(canvasAspect))
+ || (isPortrait(mediaAspect) && isLandscape(canvasAspect))
+
+ if (orientationMismatch) {
+ return 'blur-fill'
+ }
+
+ const shapeMismatch = isSquare(mediaAspect) !== isSquare(canvasAspect)
+ if (shapeMismatch) {
+ return 'blur-fill'
+ }
+
+ return 'cover'
+}
diff --git a/src/timeline-core/types.ts b/src/timeline-core/types.ts
index c7d47ed..d374cc8 100644
--- a/src/timeline-core/types.ts
+++ b/src/timeline-core/types.ts
@@ -13,8 +13,12 @@ export type MediaSlide = {
durationInFrames: number
/** excluded from timeline but still in folder */
excluded: boolean
+ /** intrinsic pixel height when known */
+ height?: number
/** per-slide setting overrides; absent means "use global defaults" */
overrides?: SlideOverrides
+ /** intrinsic pixel width when known */
+ width?: number
}
export type TitleSlide = {