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
10 changes: 10 additions & 0 deletions src/features/editor/components/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { toast } from 'sonner'
import { useEditorHotkeys } from '@/features/editor/hooks/use-editor-hotkeys'
import { useAutoSave } from '../hooks/use-auto-save'
import {
useHostTimelineShortcuts,
useTimelineShortcuts,
useTransitionBreakageNotifications,
} from '@/features/editor/deps/timeline-hooks'
Expand Down Expand Up @@ -429,6 +430,14 @@ const TimelineShortcutsController = memo(function TimelineShortcutsController()
return null
})

// Host mode mounts only the host-safe shortcut slice (playback, tools,
// delete, zoom/snap) — undo/redo, ripple delete, clipboard, markers, and
// nudges would mutate local stores without crossing the host bridge.
const HostTimelineShortcutsController = memo(function HostTimelineShortcutsController() {
useHostTimelineShortcuts()
return null
})

// fallow-ignore-next-line complexity
export const LoadedEditor = memo(function LoadedEditor({
projectId,
Expand Down Expand Up @@ -746,6 +755,7 @@ export const LoadedEditor = memo(function LoadedEditor({
>
{!hostRuntime && <AutoSaveController onSave={handleSave} />}
{!hostRuntime && <TimelineShortcutsController />}
{hostRuntime && <HostTimelineShortcutsController />}
{!hostRuntime && (
<LocalRouterBridge projectId={projectId} onReady={setLocalRefreshMigration} />
)}
Expand Down
1 change: 1 addition & 0 deletions src/features/editor/deps/composition-runtime-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ export {
} from '@/runtime/composition-runtime/utils/corner-pin'
export { clearPreviewAudioCache } from '@/runtime/composition-runtime/utils/audio-decode-cache'
export { deletePreviewAudioConform } from '@/runtime/composition-runtime/utils/preview-audio-conform'
export { peekSharedPreviewAudioContext } from '@/runtime/composition-runtime/utils/preview-audio-graph'
1 change: 1 addition & 0 deletions src/features/editor/deps/timeline-hooks-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

export {
useHostTimelineShortcuts,
useTimelineShortcuts,
} from '@/features/timeline/hooks/use-timeline-shortcuts'
export {
Expand Down
238 changes: 238 additions & 0 deletions src/features/editor/host/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,4 +437,242 @@ describe('embedded FreeCut host controller', () => {
})
expect(adapter.capabilities).toEqual({})
})

describe('host round-trip stability', () => {
async function flushReconcile(): Promise<void> {
for (let i = 0; i < 10; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 0))
}
}

function minimalTwoClipSnapshot(): EmbeddedEditorSnapshot {
const initial = snapshot()
const track = initial.timeline.tracks[0]!
return {
...initial,
timeline: {
...initial.timeline,
tracks: [
{
...track,
items: [
// Minimal host clips: no volume/speed/opacity/transform keys,
// and clip-2 omits sourceStart/sourceEnd entirely.
{
type: 'video',
id: 'clip-1',
trackId: 'track-1',
mediaId: 'media-1',
from: 0,
durationInFrames: 60,
sourceStart: 0,
sourceEnd: 60,
},
{
type: 'video',
id: 'clip-2',
trackId: 'track-1',
mediaId: 'media-1',
from: 60,
durationInFrames: 60,
},
],
},
],
},
}
}

it('derives a move_item command for a store drag of a minimal host clip', async () => {
const initial = minimalTwoClipSnapshot()
const harness = createFakeHost(initial)
const runtime = new EmbeddedEditorHostRuntime(harness.host, initial)
runtime.mountStores()
try {
useTimelineStore.getState().moveItem('clip-1', 30)
await flushReconcile()

expect(harness.submitEdit).toHaveBeenCalledTimes(1)
const batch = harness.submitEdit.mock.calls[0]![0] as EditCommandBatch
expect(batch.commands).toEqual([
expect.objectContaining({ type: 'move_item', item_id: 'clip-1' }),
])
// The untouched clip must not leak into the derived change set.
expect(batch.commands.some((command) => command.command_id.includes('clip-2'))).toBe(
false,
)
} finally {
runtime.unmountStores()
}
})

it('derives a trim_item command for a store trim of a minimal host clip', async () => {
const initial = minimalTwoClipSnapshot()
const harness = createFakeHost(initial)
const runtime = new EmbeddedEditorHostRuntime(harness.host, initial)
runtime.mountStores()
try {
useTimelineStore.getState().updateItem('clip-1', { durationInFrames: 40, sourceEnd: 40 })
await flushReconcile()

expect(harness.submitEdit).toHaveBeenCalledTimes(1)
const batch = harness.submitEdit.mock.calls[0]![0] as EditCommandBatch
expect(batch.commands).toEqual([
expect.objectContaining({ type: 'trim_item', item_id: 'clip-1', edge: 'end' }),
])
} finally {
runtime.unmountStores()
}
})

it('derives a move_item command for a host text item with the default color', async () => {
const initial = snapshot()
const track = initial.timeline.tracks[0]!
const withText: EmbeddedEditorSnapshot = {
...initial,
timeline: {
...initial.timeline,
tracks: [
{
...track,
items: [
...track.items,
{
type: 'text',
id: 'text-1',
trackId: 'track-1',
from: 10,
durationInFrames: 20,
text: 'Hello host',
},
],
},
],
},
}
const harness = createFakeHost(withText)
const runtime = new EmbeddedEditorHostRuntime(harness.host, withText)
runtime.mountStores()
try {
// The native bridge defaults the text color to #ffffff; the round trip
// must not turn that default into a style mutation.
expect(
useTimelineStore.getState().items.find((item) => item.id === 'text-1'),
).toMatchObject({ type: 'text', color: '#ffffff' })

useTimelineStore.getState().moveItem('text-1', 40)
await flushReconcile()

expect(harness.submitEdit).toHaveBeenCalledTimes(1)
const batch = harness.submitEdit.mock.calls[0]![0] as EditCommandBatch
expect(batch.commands).toEqual([
expect.objectContaining({ type: 'move_item', item_id: 'text-1' }),
])
} finally {
runtime.unmountStores()
}
})

it('keeps a clip carrying top-level opacity movable through the round trip', async () => {
const initial = snapshot()
const track = initial.timeline.tracks[0]!
const clip = track.items[0]!
if (clip.type !== 'video') throw new Error('expected a video clip')
const withOpacity: EmbeddedEditorSnapshot = {
...initial,
timeline: {
...initial.timeline,
tracks: [
{
...track,
items: [{ ...clip, opacity: 0.5 }],
},
],
},
}
const harness = createFakeHost(withOpacity)
const runtime = new EmbeddedEditorHostRuntime(harness.host, withOpacity)
runtime.mountStores()
try {
useTimelineStore.getState().moveItem('clip-1', 30)
await flushReconcile()

expect(harness.submitEdit).toHaveBeenCalledTimes(1)
const batch = harness.submitEdit.mock.calls[0]![0] as EditCommandBatch
expect(batch.commands).toEqual([
expect.objectContaining({ type: 'move_item', item_id: 'clip-1' }),
])
} finally {
runtime.unmountStores()
}
})

it('batches one remove_item command per removed item', () => {
const initial = minimalTwoClipSnapshot()
const next: EmbeddedEditorSnapshot = {
...initial,
timeline: {
...initial.timeline,
tracks: [{ ...initial.timeline.tracks[0]!, items: [] }],
},
}

const derived = deriveSupportedHostEdit(initial.timeline, next.timeline, {
operationId: 'operation-remove-2',
idempotencyKey: 'idempotency-remove-2',
})

expect(derived.batch?.commands).toEqual([
expect.objectContaining({ type: 'remove_item', item_id: 'clip-1' }),
expect.objectContaining({ type: 'remove_item', item_id: 'clip-2' }),
])
expect(derived.batch?.preconditions).toHaveLength(2)
})

it('forwards a multi-select store removal to the host as one batched operation', async () => {
const initial = minimalTwoClipSnapshot()
const harness = createFakeHost(initial)
const runtime = new EmbeddedEditorHostRuntime(harness.host, initial)
runtime.mountStores()
try {
useTimelineStore.getState().removeItems(['clip-1', 'clip-2'])
await flushReconcile()

expect(harness.submitEdit).toHaveBeenCalledTimes(1)
const batch = harness.submitEdit.mock.calls[0]![0] as EditCommandBatch
expect(batch.commands).toEqual([
expect.objectContaining({ type: 'remove_item', item_id: 'clip-1' }),
expect.objectContaining({ type: 'remove_item', item_id: 'clip-2' }),
])
} finally {
runtime.unmountStores()
}
})

it('rejects removals beyond the host per-operation command limit', () => {
const initial = snapshot()
const track = initial.timeline.tracks[0]!
const items = Array.from({ length: 65 }, (_, index) => ({
type: 'video' as const,
id: `clip-${index}`,
trackId: 'track-1',
mediaId: 'media-1',
from: index * 60,
durationInFrames: 60,
}))
const previous = {
...initial.timeline,
tracks: [{ ...track, items }],
}
const next = {
...initial.timeline,
tracks: [{ ...track, items: [] }],
}

const derived = deriveSupportedHostEdit(previous, next)

expect(derived.batch).toBeNull()
expect(derived.reason).toMatch(/exceeds the 64-command host operation limit/)
})
})
})
43 changes: 36 additions & 7 deletions src/features/editor/host/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from './contract'
import {
createCodePressCommandAdapter,
MAX_COMMANDS_PER_OPERATION,
type EditCommand,
type EditCommandBatch,
type FreeCutFrameDocument,
Expand Down Expand Up @@ -39,6 +40,10 @@ function stableSerialize(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(',')}]`
if (value && typeof value === 'object') {
return `{${Object.entries(value as Record<string, unknown>)
// A present-but-undefined key is equivalent to a missing key: host
// snapshots omit unset optional fields while the native round trip may
// surface them explicitly.
.filter(([, entry]) => entry !== undefined)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => `${JSON.stringify(key)}:${stableSerialize(entry)}`)
.join(',')}}`
Expand All @@ -65,6 +70,18 @@ function sourceBounds(item: FrameClip): [number, number] {
return [start, item.sourceEnd ?? start + item.durationInFrames]
}

/**
* Host snapshots may omit source bounds; the native bridge synthesizes them
* (sourceStart ?? from, sourceEnd ?? sourceStart + duration). Fill the same
* defaults on both sides so an unchanged item compares equal regardless of
* which side carried the explicit keys.
*/
function withSynthesizedSourceBounds(item: FreeCutFrameItem): FreeCutFrameItem {
if (!isFrameClip(item)) return item
const [sourceStart, sourceEnd] = sourceBounds(item)
return { ...item, sourceStart, sourceEnd }
}

function trackIndex(document: FreeCutFrameDocument, trackId: string): number {
return Math.max(
0,
Expand Down Expand Up @@ -197,7 +214,8 @@ function commandIdsForChanges(
return (
before !== undefined &&
after !== undefined &&
stableSerialize(before) !== stableSerialize(after)
stableSerialize(withSynthesizedSourceBounds(before)) !==
stableSerialize(withSynthesizedSourceBounds(after))
)
})
return { added, removed, changed }
Expand Down Expand Up @@ -254,13 +272,24 @@ export function deriveSupportedHostEdit(
changed.length === 0
) {
// Track creation is already represented by the add_track commands above.
} else if (removed.length === 1 && added.length === 0 && changed.length === 0) {
const id = removed[0]!
const before = previousItems.get(id)!
if (before.type === 'caption_cue')
} else if (removed.length >= 1 && added.length === 0 && changed.length === 0) {
const removedItems = removed.map((id) => previousItems.get(id)!)
if (removedItems.some((item) => item.type === 'caption_cue'))
return { batch: null, reason: 'Caption removal is not supported' }
commands.push({ command_id: `remove-${id}`, type: 'remove_item', item_id: id })
preconditions.push(preconditionForItem(before, fps))
if (commands.length + removedItems.length > MAX_COMMANDS_PER_OPERATION) {
return {
batch: null,
reason: `Removing ${removedItems.length} items exceeds the ${MAX_COMMANDS_PER_OPERATION}-command host operation limit`,
}
}
for (const before of removedItems) {
commands.push({
command_id: `remove-${before.id}`,
type: 'remove_item',
item_id: before.id,
})
preconditions.push(preconditionForItem(before, fps))
}
} else if (added.length === 1 && removed.length === 0 && changed.length === 0) {
const id = added[0]!
const after = nextItems.get(id)!
Expand Down
Loading
Loading