diff --git a/specs/smart-fit.md b/specs/smart-fit.md new file mode 100644 index 0000000..9f9a575 --- /dev/null +++ b/specs/smart-fit.md @@ -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. diff --git a/src/editor-shell/App.tsx b/src/editor-shell/App.tsx index c1272be..193d254 100644 --- a/src/editor-shell/App.tsx +++ b/src/editor-shell/App.tsx @@ -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) diff --git a/src/editor-shell/GlobalSettingsPanel.tsx b/src/editor-shell/GlobalSettingsPanel.tsx index 7de8407..267fa28 100644 --- a/src/editor-shell/GlobalSettingsPanel.tsx +++ b/src/editor-shell/GlobalSettingsPanel.tsx @@ -170,6 +170,7 @@ export function GlobalSettingsPanel({ aspectRatio, onAspectRatioChange, settings + Smart fit Cover (crop) Letterbox Blur fill diff --git a/src/editor-shell/SlideSettingsDialog.tsx b/src/editor-shell/SlideSettingsDialog.tsx index 5f813cc..cec501a 100644 --- a/src/editor-shell/SlideSettingsDialog.tsx +++ b/src/editor-shell/SlideSettingsDialog.tsx @@ -137,6 +137,7 @@ export function SlideSettingsDialog({ globalSettings, onClose, onOverride, slide + Smart fit Cover (crop) Letterbox Blur fill diff --git a/src/editor-shell/StoryboardFilmstrip.tsx b/src/editor-shell/StoryboardFilmstrip.tsx index 2f96012..410ac3d 100644 --- a/src/editor-shell/StoryboardFilmstrip.tsx +++ b/src/editor-shell/StoryboardFilmstrip.tsx @@ -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' @@ -64,7 +65,14 @@ export function StoryboardFilmstrip({ onDragEnd={() => { dragIndexRef.current = null }} onClick={() => onSlideClick(slide.id)} > -
+
{isTitleSlide(slide) ? (
) : slide.type === 'video' ? (