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
37 changes: 37 additions & 0 deletions specs/smart-fit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Smart Fit — Technical Spec

## Problem

Default `cover` fit crops media when its aspect ratio differs from the project canvas (e.g. 16:9 photo in a 9:16 project). Users expect the full image to remain visible unless they explicitly choose crop.

## Behavior

- **`smart-fit`** becomes the default global `fitMode` (themes included).
- At plan time, `smart-fit` resolves to a concrete fit per slide:
- **Orientation mismatch** (landscape ↔ portrait) or **square ↔ non-square** → `blur-fill` (full media visible, blurred background).
- **Compatible orientation** (both landscape, both portrait, or both square) → `cover` (Ken Burns-friendly crop-fill).
- **Missing media dimensions** → `contain` (safe fallback, no crop).
- **Videos** remain always `contain` (unchanged).
- **Per-slide overrides** (`cover`, `contain`, `blur-fill`, `smart-fit`) still win via the settings cascade.
- **Persisted `fitMode: 'cover'`** in existing projects is unchanged until the user switches.

## Architecture

| Layer | Responsibility |
| --- | --- |
| `timeline-core/smartFit.ts` | Pure `resolveSmartFit(mediaW, mediaH, canvasW, canvasH)` |
| `timeline-core/settings.ts` | Add `'smart-fit'` to `FitMode`, default + themes |
| `project-store/media-loader.ts` | Extract `width`/`height` at import (image bitmap, video track) |
| `sequence-planner/planner.ts` | Accept `aspectRatio`, resolve `smart-fit` using slide dims + canvas |
| `composition/` | No changes — receives resolved fit modes only |
| Editor thumbnails | `object-contain` + intrinsic `aspect-ratio` from slide dimensions (fallback 16:9) |

## Thumbnails

- Timeline blocks: media centered with `object-contain` inside the proportional-width block (full image visible, letterboxed in block).
- Filmstrip cards: container `aspect-ratio` derived from slide `width`/`height`; falls back to 16:9 when unknown.

## Tests

- `smartFit.test.ts`: orientation pairs, square cases, missing/zero dimensions.
- `planner.test.ts`: smart-fit resolution with metadata + aspect ratio; overrides still win.
3 changes: 2 additions & 1 deletion src/editor-shell/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,9 @@ export function App() {
planAudioClips.length > 0 ? planAudioClips : undefined,
beatGrid.effectiveBeatGrid,
planBeatTimes,
aspectRatio,
),
[beatGrid.effectiveBeatGrid, deferredGlobalSettings, deferredSlides, planAudioClips, planBeatTimes],
[aspectRatio, beatGrid.effectiveBeatGrid, deferredGlobalSettings, deferredSlides, planAudioClips, planBeatTimes],
)
const totalFrames = renderPlan.totalFrames > 0 ? renderPlan.totalFrames : FPS
const canvas = dimensionsForAspectRatio(aspectRatio)
Expand Down
1 change: 1 addition & 0 deletions src/editor-shell/GlobalSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export function GlobalSettingsPanel({ aspectRatio, onAspectRatioChange, settings
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="smart-fit">Smart fit</SelectItem>
<SelectItem value="cover">Cover (crop)</SelectItem>
<SelectItem value="contain">Letterbox</SelectItem>
<SelectItem value="blur-fill">Blur fill</SelectItem>
Expand Down
1 change: 1 addition & 0 deletions src/editor-shell/SlideSettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export function SlideSettingsDialog({ globalSettings, onClose, onOverride, slide
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="smart-fit">Smart fit</SelectItem>
<SelectItem value="cover">Cover (crop)</SelectItem>
<SelectItem value="contain">Letterbox</SelectItem>
<SelectItem value="blur-fill">Blur fill</SelectItem>
Expand Down
20 changes: 14 additions & 6 deletions src/editor-shell/StoryboardFilmstrip.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useRef } from 'react'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
import { mediaAspectRatioCss } from '../timeline-core/aspect'
import type { Slide } from '../timeline-core/types'
import { isTitleSlide } from '../timeline-core/types'

Expand Down Expand Up @@ -64,7 +65,14 @@ export function StoryboardFilmstrip({
onDragEnd={() => { dragIndexRef.current = null }}
onClick={() => onSlideClick(slide.id)}
>
<div className="relative aspect-video w-full overflow-hidden rounded-sm">
<div
className="relative flex w-full items-center justify-center overflow-hidden rounded-sm bg-black"
style={{
aspectRatio: isTitleSlide(slide)
? '16 / 9'
: mediaAspectRatioCss(slide.width, slide.height),
}}
>
{isTitleSlide(slide) ? (
<div
className="flex h-full w-full items-center justify-center px-1 text-xs font-medium"
Expand All @@ -77,17 +85,17 @@ export function StoryboardFilmstrip({
</div>
) : slide.type === 'video' ? (
<video
src={slide.blobUrl}
className="h-full w-full object-cover"
muted
className="max-h-full max-w-full object-contain"
draggable={false}
muted
src={slide.blobUrl}
/>
) : (
<img
src={slide.blobUrl}
alt={slide.filename}
className="h-full w-full object-cover"
className="max-h-full max-w-full object-contain"
draggable={false}
src={slide.blobUrl}
/>
)}
{!isTitleSlide(slide) && slide.type === 'video' && (
Expand Down
6 changes: 3 additions & 3 deletions src/editor-shell/TimelineMediaBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function TimelineMediaBlock({
}}
style={{ left: leftPx, width: widthPx }}
>
<div className="relative min-h-0 flex-1 overflow-hidden rounded-sm">
<div className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded-sm bg-black">
{isTitleSlide(slide) ? (
<div
className="flex h-full w-full items-center justify-center px-1 text-xs font-medium"
Expand All @@ -73,15 +73,15 @@ export function TimelineMediaBlock({
</div>
) : slide.type === 'video' ? (
<video
className="h-full w-full object-cover"
className="max-h-full max-w-full object-contain"
draggable={false}
muted
src={slide.blobUrl}
/>
) : (
<img
alt={slide.filename}
className="h-full w-full object-cover"
className="max-h-full max-w-full object-contain"
draggable={false}
src={slide.blobUrl}
/>
Expand Down
29 changes: 29 additions & 0 deletions src/project-store/media-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,31 @@ async function getVideoDurationFrames(file: File): Promise<number> {
}
}

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<MediaSlide> {
const filename = file.name
const type = getMediaType(filename)
Expand All @@ -33,12 +58,16 @@ export async function createMediaSlideFromFile(file: File): Promise<MediaSlide>
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,
}
Expand Down
35 changes: 32 additions & 3 deletions src/sequence-planner/planner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand All @@ -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' }
Expand All @@ -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 ---
Expand Down Expand Up @@ -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
})
})

Expand Down Expand Up @@ -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', () => {
Expand Down
30 changes: 28 additions & 2 deletions src/sequence-planner/planner.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 5 additions & 2 deletions src/sequence-planner/types.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -37,7 +38,7 @@ export type RenderPlanEntry = {
startFrame: number
durationInFrames: number
transitionIn?: TransitionSpec
fitMode: FitMode
fitMode: ConcreteFitMode
kenBurns: KenBurnsVector | null
videoVolume: number
}
Expand Down Expand Up @@ -65,4 +66,6 @@ export type RenderPlan = {

export type MediaMetadata = {
durationInFrames?: number
height?: number
width?: number
}
19 changes: 18 additions & 1 deletion src/timeline-core/aspect.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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)
})
})
9 changes: 9 additions & 0 deletions src/timeline-core/aspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
4 changes: 3 additions & 1 deletion src/timeline-core/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Loading
Loading