<p align="center">
  <img src="./static/logo.svg" alt="svelte-video-editor" width="128" height="128" />
</p>

<h1 align="center">@ariefsn/svelte-video-editor</h1>

<p align="center">
  <a href="https://www.npmjs.com/package/@ariefsn/svelte-video-editor"><img alt="npm version" src="https://img.shields.io/npm/v/@ariefsn/svelte-video-editor?color=cb3837&logo=npm"></a>
  <a href="https://www.npmjs.com/package/@ariefsn/svelte-video-editor"><img alt="npm downloads" src="https://img.shields.io/npm/dm/@ariefsn/svelte-video-editor?color=cb3837&logo=npm"></a>
  <a href="https://bundlephobia.com/package/@ariefsn/svelte-video-editor"><img alt="minzipped size" src="https://img.shields.io/bundlephobia/minzip/@ariefsn/svelte-video-editor"></a>
  <img alt="svelte 5" src="https://img.shields.io/badge/svelte-5-ff3e00?logo=svelte&logoColor=white">
  <img alt="types included" src="https://img.shields.io/npm/types/@ariefsn/svelte-video-editor">
  <a href="./LICENSE"><img alt="license MIT" src="https://img.shields.io/npm/l/@ariefsn/svelte-video-editor?color=blue"></a>
</p>

A host-agnostic, **Svelte 5** video timeline editor — a CapCut/Premiere-style timeline with
clips, tracks, trimming, ripple/roll/slip edits, grouping, linked audio, markers, in/out range,
undo/redo, and a real-time DOM preview. No framework lock-in: all external concerns (asset URLs,
thumbnails, export, permissions, persistence, notifications, confirmation, i18n) are injected by
the host.

- **Built-in clip transitions** — per-clip enter/exit animations (fade, slide, scale, zoom,
  bounce, pop, spin, blur, wipe, flip) with duration + easing, scrubbed live by the playhead.
- **Responsive & mobile-ready** — the bin and clip inspector collapse into bottom sheets, the
  toolbar folds into an overflow menu, and clip options are reachable by touch.
- **The library owns no storage** — you persist the project (and pane height) however you like.
  Every clip's `animation` rides along in the project JSON, so the host can render it any way it
  likes on export.
- **Tailwind v4 theming** — ships a simple light/dark theme you can override via CSS variables.

> ⚠️ **Browser-only.** This component renders in the browser (DOM, `<video>`/`<audio>`, canvas,
> `requestAnimationFrame`). It is safe to _import_ on the server, but render it client-side only
> (see [SvelteKit (SSR)](#sveltekit-ssr)).

- **Demo:** [https://svelte-video-editor.ariefsn.dev](https://svelte-video-editor.ariefsn.dev)
- **Repo:** [https://github.com/ariefsn/svelte-video-editor](https://github.com/ariefsn/svelte-video-editor)

## Contents

- [Install](#install)
- [Theme](#theme)
- [Simple mode](#simple-mode) — props & callbacks only
- [Advanced mode](#advanced-mode) — snippets, i18n, host-owned state
- [Opening media](#opening-media) — file picker, drag-and-drop, `binImport`
- [SvelteKit (SSR)](#sveltekit-ssr)
- [Host contract](#host-contract)
- [Clip transitions](#clip-transitions) — built-in enter/exit animations
- [Section overrides](#section-overrides-snippets)
- [Custom preview renderer](#custom-preview-renderer) — Remotion, canvas, anything
- [Localization](#localization)
- [License](#license)

## Install

```bash
bun add @ariefsn/svelte-video-editor
# or: npm i @ariefsn/svelte-video-editor
# or: pnpm add @ariefsn/svelte-video-editor
```

Requires `svelte@^5` and Tailwind v4 in your app.

## Theme

Import the shipped theme once (it defines the `--ts-*` CSS variables + light/dark palette):

```ts
// app.css or your root layout
import '@ariefsn/svelte-video-editor/app.css';
```

Dark mode follows a `.dark` class on an ancestor (e.g. `<html class="dark">`). Override any token
to restyle:

```css
:root {
	--ts-primary: hsl(220 90% 56%); /* your brand */
}
```

---

## Simple mode

The minimal "drop it in and go" setup — **props and callbacks only, no snippets**. You supply a
project, persist it in `onChange`, and resolve media. That's it.

```svelte
<script lang="ts">
	import { browser } from '$app/environment';
	import {
		TimelineEditor,
		createEmptyProject,
		migrateProject,
		type NotifyKind,
		type ResolvedAsset,
		type TimelineProject
	} from '@ariefsn/svelte-video-editor';
	import '@ariefsn/svelte-video-editor/app.css';

	// The host owns the project and its persistence. The library stores nothing.
	let project = $state<TimelineProject | null>(null);

	$effect(() => {
		if (!browser || project) return;
		const saved = localStorage.getItem('my-project');
		project = saved ? migrateProject(JSON.parse(saved)) : createEmptyProject('My first video');
	});

	function handleChange(p: TimelineProject) {
		project = p;
		localStorage.setItem('my-project', JSON.stringify(p)); // host-owned persistence
	}

	// Resolve a clip's media URL (+ metadata). Here URLs resolve to themselves.
	async function resolveAsset(assetId: string): Promise<ResolvedAsset> {
		return { url: assetId, hasAudio: true };
	}

	// Return a thumbnail image URL for `frame` (at a fixed 30fps reference).
	async function generateThumbnail(assetId: string, frame: number): Promise<string> {
		return assetId; // e.g. an <img> poster; real apps seek a <video> + canvas
	}

	function handleExport(p: TimelineProject) {
		console.log('export', p); // you own rendering
	}

	function handleNotify(message: string, kind: NotifyKind) {
		console.log(kind, message); // wire to your toast system if you like
	}
</script>

{#if browser && project}
	{#key project.id}
		<TimelineEditor
			{project}
			onChange={handleChange}
			{resolveAsset}
			{generateThumbnail}
			onExport={handleExport}
			can={() => true}
			onNotify={handleNotify}
		/>
	{/key}
{/if}
```

`onBack` is omitted here, so no back button renders.

---

## Advanced mode

Use snippets to customize sections, supply your own confirm dialog, switch languages at runtime,
gate features, and own the pane height — everything injected by the host.

```svelte
<script lang="ts">
	import { browser } from '$app/environment';
	import {
		TimelineEditor,
		InspectorPanel,
		createEmptyProject,
		uid,
		type BinItem,
		type ConfirmOptions,
		type EditorAction,
		type MessagesOverride,
		type ResolvedAsset,
		type SectionCtx,
		type TimelineProject
	} from '@ariefsn/svelte-video-editor';
	import '@ariefsn/svelte-video-editor/app.css';

	let project = $state<TimelineProject | null>(null);
	let timelineHeight = $state<number | undefined>(undefined);
	let lang = $state<'en' | 'id'>('en');
	let proTier = $state(true);

	$effect(() => {
		if (!browser || project) return;
		project = createEmptyProject('Advanced demo');
		timelineHeight = Number(localStorage.getItem('paneH')) || undefined;
	});

	async function resolveAsset(assetId: string): Promise<ResolvedAsset> {
		return { url: assetId, hasAudio: true };
	}
	async function generateThumbnail(assetId: string): Promise<string> {
		return assetId;
	}

	function handleChange(p: TimelineProject) {
		project = p;
	}

	function handleExport(p: TimelineProject) {
		console.log('export', p);
	}

	function handleHeight(h: number) {
		timelineHeight = h;
		localStorage.setItem('paneH', String(h)); // host-owned persistence
	}

	function canDo(action: EditorAction): boolean {
		return action === 'export' ? proTier : true;
	}

	// Localization is host-owned: swap the whole messages map to switch language.
	const messagesId: MessagesOverride = { export: 'Ekspor', split: 'Pisah', undo: 'Urungkan' };
	const messages = $derived<MessagesOverride | undefined>(lang === 'id' ? messagesId : undefined);

	// Use your OWN confirm dialog instead of the built-in one (optional).
	async function confirm(opts: ConfirmOptions): Promise<boolean> {
		return window.confirm(`${opts.title}\n\n${opts.message ?? ''}`);
	}

	let pasteUrl = $state('');
	function addByUrl(addItems: (items: BinItem[]) => void) {
		if (!pasteUrl.trim()) return;
		addItems([{ id: uid(), url: pasteUrl, name: 'media', mediaType: 'video', duration: null }]);
		pasteUrl = '';
	}
</script>

{#if browser && project}
	{#key project.id}
		<TimelineEditor
			{project}
			onChange={handleChange}
			{resolveAsset}
			{generateThumbnail}
			onExport={handleExport}
			can={canDo}
			{messages}
			{confirm}
			{timelineHeight}
			onTimelineHeightChange={handleHeight}
			onBack={() => history.back()}
		>
			<!-- Host-owned import UI inside the asset bin -->
			{#snippet binImport({ addItems })}
				<form
					onsubmit={(e) => {
						e.preventDefault();
						addByUrl(addItems);
					}}
				>
					<input bind:value={pasteUrl} placeholder="Paste media URL…" />
					<button>Add</button>
				</form>
			{/snippet}

			<!-- WRAP a default section: render the built-in + add your own UI -->
			{#snippet inspector(ctx: SectionCtx)}
				{#if ctx.editor.activeClip}
					<InspectorPanel onRequestDelete={ctx.onRequestDelete} />
				{/if}
				<div class="p-3 text-xs">Active clip: {ctx.editor.activeClip?.name ?? '—'}</div>
			{/snippet}

			<!-- FULLY REPLACE a section -->
			{#snippet shortcutsFooter()}
				<div class="border-t px-3 py-1 text-xs">My custom footer</div>
			{/snippet}
		</TimelineEditor>
	{/key}
{/if}
```

Switch `lang` between `'en'` and `'id'` at runtime and the whole UI re-labels reactively — no
`locale` prop, the `messages` map is the single source of translation.

> A runnable version of both modes lives in this repo under `src/routes/` (`/` = simple,
> `/advanced` = advanced). Run `bun run dev`.

---

## Opening media

**The editor ships no import UI on purpose.** It never touches files, never fetches and never
builds storage URLs, so _you_ decide where media comes from — an upload endpoint, an existing
asset library, a stock picker, or the local disk. That UI goes in the `binImport` snippet, which
receives `addItems(items: BinItem[])` and renders inside the asset bin.

Until you supply it, **the bin stays empty and there is no way for a user to add media** — that is
the contract, not a bug.

### A local-file picker (click to browse + drop files)

```svelte
<script lang="ts">
	import { uid, type BinItem } from '@ariefsn/svelte-video-editor';

	let fileInput: HTMLInputElement | null = $state(null);
	let dragging = $state(false);

	// Keep blob URLs OUT of the saved project: register each file under a
	// stable assetId, and let resolveAsset hand back the live URL.
	const localAssets = new Map<string, string>();

	async function handleFiles(files: FileList | null, addItems: (i: BinItem[]) => void) {
		if (!files?.length) return;
		const items: BinItem[] = [];
		for (const file of files) {
			const mediaType = file.type.startsWith('video/')
				? 'video'
				: file.type.startsWith('audio/')
					? 'audio'
					: 'image';
			const url = URL.createObjectURL(file);
			const assetId = `local:${uid()}`;
			localAssets.set(assetId, url);
			items.push({ id: uid(), assetId, url, name: file.name, mediaType, duration: null });
		}
		addItems(items);
	}
</script>

{#snippet binImport({ addItems })}
	<!-- preventDefault on dragover, or the browser navigates to the dropped file -->
	<div
		ondragover={(e) => {
			e.preventDefault();
			dragging = true;
		}}
		ondragleave={() => (dragging = false)}
		ondrop={(e) => {
			e.preventDefault();
			dragging = false;
			handleFiles(e.dataTransfer?.files ?? null, addItems);
		}}
		class={dragging ? 'ring-2' : ''}
	>
		<input
			bind:this={fileInput}
			type="file"
			multiple
			accept="video/*,audio/*,image/*"
			hidden
			onchange={(e) => {
				handleFiles(e.currentTarget.files, addItems);
				e.currentTarget.value = ''; // so re-picking the same file re-fires
			}}
		/>
		<button onclick={() => fileInput?.click()}>Open media</button>
		<p>or drop files here</p>
	</div>
{/snippet}
```

Pair it with a `resolveAsset` that knows about those ids:

```ts
async function resolveAsset(assetId: string) {
	return { url: localAssets.get(assetId) ?? assetId, hasAudio: true };
}
```

> ⚠️ **`blob:` URLs must never be persisted.** They die when the tab closes, so a project saved
> with one comes back pointing at media that can never load. Store the `assetId` and resolve a
> fresh URL each session — strip `blob:` values in your `onChange` handler before writing them
> anywhere. For media that must survive a reload, upload the file and use the uploaded URL.

Set `duration` (source length in **seconds**) when you know it, and clips get the right length on
the timeline; leave it `null` and the editor falls back to a default. You can probe it from a
detached `<video>`/`<audio>` on `loadedmetadata`.

See [`src/routes/advanced/OpenMediaControl.svelte`](./src/routes/advanced/OpenMediaControl.svelte)
for the complete version used by the `/advanced` demo, including duration probing and
session-expiry handling.

---

## SvelteKit (SSR)

SvelteKit is SSR by default and the editor needs the DOM — render it under a browser guard:

```svelte
<script lang="ts">
	import { browser } from '$app/environment';
	import { TimelineEditor } from '@ariefsn/svelte-video-editor';
</script>

{#if browser}
	<TimelineEditor … />
{/if}
```

(Or set `export const ssr = false` on the route.)

---

## Host contract

| Prop                                        | Type                                  | Required | Notes                                                                               |
| ------------------------------------------- | ------------------------------------- | -------- | ----------------------------------------------------------------------------------- |
| `project`                                   | `TimelineProject`                     | ✅       | Auto-migrated from older shapes. Remount via `{#key project.id}` to switch.         |
| `onChange`                                  | `(project) => void`                   | ✅       | Debounced (`changeDebounceMs`, default 800ms; `0` = immediate). **You persist it.** |
| `resolveAsset`                              | `(assetId) => Promise<ResolvedAsset>` | ✅       | Resolve media URL + metadata. See [Opening media](#opening-media).                  |
| `generateThumbnail`                         | `(assetId, frame) => Promise<string>` | ✅       | `frame` at a fixed 30fps reference.                                                 |
| `onExport`                                  | `(project, range?) => void`           | ✅       | You own rendering/export.                                                           |
| `can`                                       | `(action) => boolean`                 | ✅       | Permission gate (`'export'`, `'magnetic-main-track'`).                              |
| `onBack`                                    | `() => void`                          | ❌       | Back button renders only when provided.                                             |
| `onNotify`                                  | `(message, kind) => void`             | ❌       | Transient feedback sink; no-ops if absent.                                          |
| `messages`                                  | `Partial<Messages>`                   | ❌       | Override/translate any label (the single i18n source).                              |
| `confirm`                                   | `(opts) => Promise<boolean>`          | ❌       | Use your own confirm dialog instead of the built-in.                                |
| `timelineHeight` / `onTimelineHeightChange` | `number` / `(h) => void`              | ❌       | Host-owned timeline pane height.                                                    |
| `binWidth` / `onBinWidthChange`             | `number` / `(w) => void`              | ❌       | Host-owned bin column width (desktop; draggable separator).                         |
| `inspectorWidth` / `onInspectorWidthChange` | `number` / `(w) => void`              | ❌       | Host-owned inspector column width (desktop; draggable separator).                   |
| `magneticMainTrack`                         | `boolean`                             | ❌       | CapCut-style packing on the first track.                                            |
| `changeDebounceMs`                          | `number`                              | ❌       | Debounce for `onChange`.                                                            |

`ProjectListView` is also exported for a "pick/create a project" screen (`projects`, `onOpen`,
`onCreate`, `onRename`, `onDelete`, plus optional `messages` / `confirm`).

## Clip transitions

Every clip (video, image, text) can carry an optional **enter** and **exit** transition, edited from
the inspector's **Animation** section and previewed live (the playhead scrubs them). The data is a
plain field on the clip, so it round-trips through `onChange` / `onExport` for the host to render.

```ts
import {
	type ClipAnimation,
	type ClipTransition,
	type AnimPreset, // 'fade' | 'slide-left'|'slide-right'|'slide-up'|'slide-down'
	// | 'scale' | 'zoom' | 'bounce' | 'pop' | 'spin' | 'blur' | 'wipe' | 'flip'
	type Easing, // 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out'
	ANIM_PRESETS,
	EASINGS,
	defaultTransition,
	clipAnimStyle // pure: (clip, playheadSec, fps) => { opacity, transform, filter, clipPath }
} from '@ariefsn/svelte-video-editor';

// On each clip:  clip.animation?: { in?: ClipTransition; out?: ClipTransition }
// ClipTransition = { preset: AnimPreset; durationF: number; easing: Easing }
```

To render transitions yourself (e.g. a custom/Remotion compositor), call `clipAnimStyle(clip,
playheadSec, fps)` for the exact per-frame visual the editor preview uses — preview and export then
match frame-for-frame. See [docs/clip-animations-research.md](./docs/clip-animations-research.md).

## Section overrides (snippets)

Replace any section via a snippet receiving `SectionCtx` (`{ editor, host, onBack?, onRequestDelete }`):
`toolbar`, `assetBin`, `preview`, `transport`, `inspector`, `tracks`, `shortcutsFooter`, plus
`binImport` (host import UI, gets `{ addItems }`). You can **wrap** a default by rendering the
exported section component (e.g. `<TimelineToolbar {...ctx} />`) and adding your own UI around it.

`binImport` is the one you almost certainly need: the library ships **no media-import UI at all**,
so without it the asset bin has no way to fill. See [Opening media](#opening-media) for a
ready-made file picker and drop zone.

## Custom preview renderer

**Yes — you can replace the preview entirely** (Remotion, a canvas player, a WebGL compositor,
anything). The built-in `PreviewStage` is just the default; pass a `preview` snippet and the
editor renders yours instead. There are **two separate surfaces** to consider:

| Surface                                | Override                    | What it is                                      |
| -------------------------------------- | --------------------------- | ----------------------------------------------- |
| **Live preview** (in-editor scrubbing) | `preview` snippet           | Real-time playback while editing.               |
| **Final render** (export)              | `onExport(project, range?)` | You already own this — render however you like. |

### What the `preview` snippet gives you

The snippet receives the same `SectionCtx` as every section override:

```svelte
<script lang="ts">
	import { TimelineEditor, type SectionCtx } from '@ariefsn/svelte-video-editor';
</script>

<TimelineEditor {project} {onChange} {resolveAsset} {generateThumbnail} {onExport} {can}>
	{#snippet preview(ctx)}
		<!-- ctx.editor.project   — full reactive timeline (tracks, clips, fps, aspectRatio) -->
		<!-- ctx.editor.playhead  — current position, in seconds -->
		<!-- ctx.editor.playing   — boolean play/pause state -->
		<!-- ctx.host.resolveAsset — (assetId) => Promise<ResolvedAsset> for media URLs -->
		<MyRenderer
			project={ctx.editor.project}
			playhead={ctx.editor.playhead}
			playing={ctx.editor.playing}
			resolveAsset={ctx.host.resolveAsset}
		/>
	{/snippet}
</TimelineEditor>
```

All the data types your renderer needs (`TimelineProject`, `MediaClip`, `TextClip`,
`TimelineTrack`, `frameToSec`, `secToFrame`, `isMediaClip`, `isTextClip`, …) are exported from
the package.

**Non-Svelte renderers** (a Remotion/React composition, a canvas/WebGL player, a server-side
renderer, plain Node) must import from the **`/pure`** subpath instead. The main entry only
resolves under the `svelte` condition and pulls in `.svelte` components; `/pure` is
framework-free and resolves in any bundler.

`/pure` exposes the **same composition rules the built-in preview uses**, so any player can
reproduce it exactly instead of re-implementing them. Times are float seconds on the project
timeline (`t = frame / fps`):

| Question                       | Helper                                                                    |
| ------------------------------ | ------------------------------------------------------------------------- |
| How long is the project?       | `projectDurationF(project)`                                               |
| What is on screen at `t`?      | `visibleClipsAt(project, t)` → `{ clip, zIndex }[]`, bottom → top         |
| All layers (for `<Sequence>`s) | `layeredClips(project)` — z-ordered, hidden tracks skipped                |
| Is this clip active at `t`?    | `isClipActive(clip, t, fps)` — `[start, end)`                             |
| Where in the source media?     | `clipSourceSec(clip, t, fps)` — honours `trimInF`                         |
| Output / stage pixel size      | `compositionSize(aspectRatio, shortSide?)`, `fitStage(aspectRatio, w, h)` |
| Background                     | `backgroundCss(project.background)`                                       |
| Enter/exit animation           | `clipAnimStyle(clip, t, fps)`                                             |
| Media layer style              | `mediaClipCss(anim)` — full-bleed `object-fit: cover` + animation         |
| Text layer style               | `textClipCss(clip.style, stageHeight, anim)` — % sizes → px               |
| Heard at all? (mute/solo/…)    | `clipAudible(project, clip)`, `trackAudible(project, track)`              |
| Volume at `t` (with fades)     | `clipGain(clip, t, fps)`                                                  |
| Stored/older project           | `migrateProject(input)`                                                   |

The style helpers return camelCase CSS objects — pass them straight to a React `style` prop,
`Object.assign(el.style, css)`, or serialize with `toCssText(css)`. A minimal DOM player:

```ts
import {
	backgroundCss,
	clipAnimStyle,
	isMediaClip,
	mediaClipCss,
	textClipCss,
	toCssText,
	visibleClipsAt,
	type TimelineProject
} from '@ariefsn/svelte-video-editor/pure';

function renderFrame(stage: HTMLElement, project: TimelineProject, t: number) {
	stage.style.background = backgroundCss(project.background);
	const html = visibleClipsAt(project, t).map(({ clip, zIndex }) => {
		const anim = clipAnimStyle(clip, t, project.fps);
		return isMediaClip(clip)
			? `<img src="${clip.url}" style="z-index:${zIndex}; ${toCssText(mediaClipCss(anim))}">`
			: `<div style="z-index:${zIndex}; ${toCssText(textClipCss(clip.style, stage.clientHeight, anim))}">${clip.text}</div>`;
	});
	stage.innerHTML = html.join('');
}
```

### Using Remotion specifically

Remotion works, but it's **host-owned glue** — the library gives you the seam and the data, not
a drop-in Remotion player. There are two things to bridge:

1. **React ↔ Svelte.** Remotion's `<Player>` is a React component and this is Svelte 5, so you
   mount a small React island (`createRoot`) inside a Svelte wrapper.
2. **Playback.** The built-in `PlaybackEngine` only drives the native `<video>`/`<audio>`
   elements in `PreviewStage` — it does **not** drive Remotion. You sync the editor store and the
   Player's `playerRef` to each other yourself, in **both** directions.

The store API you wire against (all reactive, exported types):

| Read from editor  | Meaning                         | Call on editor (when user scrubs _inside_ the Player)      |
| ----------------- | ------------------------------- | ---------------------------------------------------------- |
| `editor.playhead` | current time, **float seconds** | `editor.seek(seconds)` / `editor.seekFrame(frame)`         |
| `editor.playing`  | boolean                         | `editor.play()` / `editor.pause()` / `editor.togglePlay()` |
| `editor.fps`      | project fps (int)               | —                                                          |
| `editor.project`  | full `TimelineProject`          | —                                                          |

#### Bridge component (`RemotionPreview.svelte`)

```svelte
<script lang="ts">
	import { onMount, onDestroy } from 'svelte';
	import { createRoot, type Root } from 'react-dom/client';
	import { createElement } from 'react';
	import { Player, type PlayerRef } from '@remotion/player';
	import { compositionSize, secToFrame, type SectionCtx } from '@ariefsn/svelte-video-editor';
	import { MyComposition } from './MyComposition'; // your Remotion composition (React)

	// Passed straight from the preview snippet: <RemotionPreview {ctx} />
	let { ctx }: { ctx: SectionCtx } = $props();
	const editor = ctx.editor;

	let mountEl: HTMLDivElement;
	let root: Root;
	let player: PlayerRef | null = null;
	// Suppress the echo when an editor→player seek triggers the player's own event.
	let applying = false;

	// aspectRatio has no width/height — derive output pixels (16:9 → 1920×1080).
	const dims = $derived(compositionSize(editor.project.aspectRatio));

	onMount(() => {
		root = createRoot(mountEl);
		renderPlayer();
	});
	onDestroy(() => root?.unmount());

	function renderPlayer() {
		root.render(
			createElement(Player, {
				ref: (r: PlayerRef | null) => (player = r),
				component: MyComposition,
				// Map clips/tracks + resolveAsset into your composition's props:
				inputProps: { project: editor.project, resolveAsset: ctx.host.resolveAsset },
				durationInFrames: Math.max(1, secToFrame(editor.duration, editor.fps)),
				compositionWidth: dims.width,
				compositionHeight: dims.height,
				fps: editor.fps,
				controls: false, // the editor's TransportBar already drives playback
				style: { width: '100%', height: '100%' }
			})
		);
	}

	// Re-render the Player when the timeline/aspect changes.
	$effect(() => {
		void editor.project; // re-run on project edits
		if (root) renderPlayer();
	});

	// editor → player: keep the Player's frame + play state in sync with the store.
	$effect(() => {
		if (!player) return;
		const frame = secToFrame(editor.playhead, editor.fps);
		if (player.getCurrentFrame() !== frame) {
			applying = true;
			player.seekTo(frame);
			applying = false;
		}
		if (editor.playing && player.isPlaying() === false) player.play();
		if (!editor.playing && player.isPlaying()) player.pause();
	});

	// player → editor: when the user scrubs/plays inside the Player, push it back.
	$effect(() => {
		if (!player) return;
		const onSeek = (e: { detail: { frame: number } }) => {
			if (applying) return; // ignore our own seekTo
			editor.seekFrame(e.detail.frame);
		};
		const onPlay = () => editor.play();
		const onPause = () => editor.pause();
		player.addEventListener('seeked', onSeek);
		player.addEventListener('play', onPlay);
		player.addEventListener('pause', onPause);
		return () => {
			player?.removeEventListener('seeked', onSeek);
			player?.removeEventListener('play', onPlay);
			player?.removeEventListener('pause', onPause);
		};
	});
</script>

<div bind:this={mountEl} class="h-full w-full"></div>
```

Then drop it into the `preview` snippet:

```svelte
<TimelineEditor {project} {onChange} {resolveAsset} {generateThumbnail} {onExport} {can}>
	{#snippet preview(ctx)}
		<RemotionPreview {ctx} />
	{/snippet}
</TimelineEditor>
```

> **The `applying` guard matters.** Without it, an editor→player `seekTo` fires the Player's
> `seeked` event, which calls `editor.seekFrame`, which re-runs the editor→player effect — an
> infinite ping-pong. Gate one direction (here, the player→editor push ignores seeks we
> initiated).

#### Mapping the timeline into a composition

Inside `MyComposition` (your React code), import from `@ariefsn/svelte-video-editor/pure` and let
the helpers decide layering, timing, styles and audio — the result matches the editor preview:

```tsx
import { AbsoluteFill, Audio, Img, OffthreadVideo, Sequence, useCurrentFrame } from 'remotion';
import {
	backgroundCss,
	clipAnimStyle,
	clipAudible,
	clipGain,
	compositionSize,
	isMediaClip,
	layeredClips,
	mediaClipCss,
	textClipCss,
	type TimelineProject
} from '@ariefsn/svelte-video-editor/pure';

export function MyComposition({
	project,
	urls
}: {
	project: TimelineProject;
	urls: Record<string, string>;
}) {
	const frame = useCurrentFrame();
	const { fps } = project;
	const t = frame / fps;
	const { height } = compositionSize(project.aspectRatio);

	return (
		<AbsoluteFill style={{ background: backgroundCss(project.background) }}>
			{layeredClips(project).map(({ clip, zIndex }) => {
				const anim = clipAnimStyle(clip, t, fps);
				return (
					<Sequence key={clip.id} from={clip.startF} durationInFrames={clip.durationF}>
						{!isMediaClip(clip) ? (
							<div style={{ zIndex, ...textClipCss(clip.style, height, anim) }}>{clip.text}</div>
						) : clip.kind === 'image' ? (
							<Img src={urls[clip.id]} style={{ zIndex, ...mediaClipCss(anim) }} />
						) : (
							(() => {
								const Media = clip.kind === 'video' ? OffthreadVideo : Audio;
								const audible = clipAudible(project, clip);
								return (
									<Media
										src={urls[clip.id]}
										startFrom={clip.trimInF} // `trimBefore` on newer Remotion
										// f is relative to the Sequence start
										volume={(f) => (audible ? clipGain(clip, (clip.startF + f) / fps, fps) : 0)}
										style={{ zIndex, ...mediaClipCss(anim) }}
									/>
								);
							})()
						)}
					</Sequence>
				);
			})}
		</AbsoluteFill>
	);
}
```

Resolve media URLs (`resolveAsset(clip.assetId)`, falling back to `clip.url`) before rendering
and pass them in via `inputProps`.

> **Simpler path:** if you only want Remotion for high-fidelity _output_ (not live scrubbing),
> skip the `preview` snippet entirely and keep the built-in `PreviewStage`. Just feed `project`
> into a Remotion render inside `onExport(project, range?)` — no React island, no sync glue.

## Localization

There is no `locale` prop — pass a fully-translated `messages` map. **`messages` is fully typed**, so
you get autocomplete for every key and a compile error on typos:

```ts
import type { Messages } from '@ariefsn/svelte-video-editor';
import { defaultMessages } from '@ariefsn/svelte-video-editor';

// Partial override — every key autocompletes:
const messages: Partial<Messages> = {
	export: 'Render',
	split: 'Cut',
	// most keys are strings; a few interpolate and are functions:
	delete_clips_confirm: ({ count }) => `Delete ${count} clip(s)?`
};
```

```svelte
<!-- `messages` is typed as Partial<Messages> -> IntelliSense lists all keys -->
<TimelineEditor messages={{ export: 'Render', split: 'Cut' }} … />
```

Exports for typing/translating:

- **`Messages`** — the full message map type (all keys; strings + the 4 interpolating functions).
- **`MessagesOverride`** = `Partial<Messages>` — the type of the `messages` prop.
- **`MessageKey`** — a union of every key name.
- **`defaultMessages`** — the English defaults (clone it as a base for a full translation).

Swap the whole `messages` object at runtime to switch languages reactively.

---

## License

MIT © [Arief Setiyo Nugroho](https://github.com/ariefsn) — [me@ariefsn.dev](mailto:me@ariefsn.dev)

See [LICENSE](./LICENSE).
