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
28 changes: 28 additions & 0 deletions docs/qa/ux7144-pr3-reliability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Edit reliability implementation evidence

Tracks quantfive/codepress#7144. Source base: `b8995a0f3f4eca7658597b44b488ffe80929a46e`
(`codepress-main`), which already includes 0.3.13 focused-clip shortcuts and optional host history.
CodePress still pins patched 0.3.12. This source PR does not publish a package or change that pin.

## Identified defects and coverage

- Runtime snapshot installation reset playhead and horizontal scroll and retained removed selected IDs. It now preserves view state, clamps the playhead only at the new endpoint, and filters selection against authoritative items.
- Controller validation advanced a speculative adapter revision; an unknown transport result could be retried as a fresh delete. Validation is isolated, a pending request blocks different intent, and **Retry save** resends its immutable batch/key. A delete of B never retries an unknown delete of A.
- Late receipts replaced newer pushed revisions. Receipt adoption now checks timeline identity and revision, and observer failures cannot strand submission cleanup.
- A push during trim could overwrite the gesture. Optional `beginTrim` / `commitTrim` / `cancelTrim` capture the authoritative snapshot and original selection, consume a token once, and defer store installation until settlement. Changed authority rejects without rebasing. No-op, cancellation, duplicate commit, and pending admission are covered.
- Explicit trim intent produces existing `trim_item` and bounded `move_item` commands for normal/ripple/roll. Source frame deltas round once using speed; attachment breaks are retained. Exhausted handles fail local validation. Linked trims and synchronized cross-track ripple trims remain unsupported and reject before any mutation.

PR2 owns pointer-hook wiring for the optional trim port. This PR exposes the port and tests it directly; the combined pointer gesture and visibility cancellation need aggregate verification. It does not infer an operation from a compound render diff on that explicit path. Other existing store actions retain their current supported-diff path.

## Executed checks

- Host unit suite: 106 passing tests across seven files, including 10 new regression cases and the adjusted explicit-retry case.
- Core controller/runtime/context/trim/status type-aware check: passed.
- Feature boundaries, dependency contracts, edge budgets, unused exports and class members: passed.
- Provenance verification: passed.
- Chrome browser host fixture: seven passing tests. Real mounted FreeCut surface with a deterministic host fixture, **not an authenticated CodePress backend**. Includes focused Delete/Backspace linked-cohort deletion, Meta/Control undo/redo, context menu deletion, exact-key Retry, and host textarea Space/Backspace ownership followed by timeline focus.
- Browser screenshots: `artifacts/host-delete-ripple-retry.png`, `artifacts/host-delete-ripple-menu.png`, `artifacts/host-history-meta-undone.png`, `artifacts/host-history-control-after.png`. Videos: `artifacts/pr3-browser-results/`.

## Remaining integration evidence

The scoped check including `editor-surface.tsx` reports unresolved `@/index.css` side-effect declarations; core changed logic passes. Full build/package/installed consumer verification and complete repository gate suite remain for aggregate delivery. No authenticated backend, real media playback/source-frame inspection, inspector/search focus matrix, or combined PR2 pointer trim is claimed here. Parent owns release version/pin/vendor patch reconciliation and aggregate package verification. The original tester usability pass remains outstanding.
12 changes: 12 additions & 0 deletions packages/freecut-editor/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,18 @@ export interface EditorHostContextValue {
}

export interface HostTimelineEditPort {
beginTrim?: (itemId: string) => string | null
commitTrim?: (
token: string,
intent: {
handle: 'start' | 'end'
deltaFrames: number
mode: 'normal' | 'ripple' | 'rolling'
itemIds: readonly string[]
neighborId?: string | null
},
) => Promise<void>
cancelTrim?: (token: string) => void
requestRippleDelete(itemIds: readonly string[]): Promise<void> | void
requestSetItemAttachment?(itemIds: readonly string[], rippleLinked: boolean): Promise<void> | void
}
Expand Down
4 changes: 4 additions & 0 deletions src/features/editor/host/context.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { HostTrimIntent } from './trim-intent'
import { createContext, useContext } from 'react'
import {
isHostCapabilityEnabled,
Expand All @@ -8,6 +9,9 @@ import {

/** UI producer for destructive host timeline edits. */
export interface HostTimelineEditPort {
beginTrim?: (itemId: string) => string | null
commitTrim?: (token: string, intent: HostTrimIntent) => Promise<void>
cancelTrim?: (token: string) => void
/** Ask the host authority to ripple-delete the selected timeline anchors. */
requestRippleDelete(itemIds: readonly string[]): Promise<void> | void
requestSetItemAttachment?: (
Expand Down
287 changes: 286 additions & 1 deletion src/features/editor/host/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ import { hostSnapshotToNativeTimeline, nativeTimelineToFrameDocument } from './d
import { EmbeddedEditorHostRuntime } from './runtime'
import { useMediaLibraryStore } from '@/features/editor/deps/media-library'
import { useTimelineStore } from '@/features/editor/deps/timeline-store'
import { useSelectionStore } from '@/shared/state/selection'
import { useTimelineSettingsStore } from '@/features/editor/deps/timeline-store'
import { trimIntentBatch } from './trim-intent'
import { usePlaybackStore } from '@/shared/state/playback'

const mediaReference: MediaReference = {
Expand Down Expand Up @@ -298,7 +301,7 @@ describe('embedded FreeCut host controller', () => {
await expect(controller.requestRippleDelete(['clip-1'])).rejects.toThrow(
'host transport unavailable',
)
await expect(controller.requestRippleDelete(['clip-1'])).resolves.toMatchObject({
await expect(controller.retryPendingEdit()).resolves.toMatchObject({
status: 'applied',
})
expect(submitEdit).toHaveBeenCalledTimes(2)
Expand Down Expand Up @@ -1743,3 +1746,285 @@ describe('embedded FreeCut host controller', () => {
})
})
})

describe('gesture authority and recovery regressions', () => {
it('keeps a newer push when an older edit receipt arrives', async () => {
const initial = snapshot()
const harness = createFakeHost(initial)
let release!: (result: HostEditResult) => void
const controller = new HostEditorController(
{
...harness.host,
submitEdit: () =>
new Promise((resolve) => {
release = resolve
}),
},
initial,
)
const pending = controller.submitEdit(commandForMove(initial))
controller.replaceAuthoritativeSnapshot({
...initial,
timeline: { ...initial.timeline, revision: 7 },
})
release({
status: 'applied',
snapshot: movedSnapshot(),
result: { status: 'applied' } as HostAppliedEditResult['result'],
})
await pending
expect(controller.getSnapshot().timeline.revision).toBe(7)
})

it('retries the identical request after a lost applied receipt and newer push', async () => {
const initial = snapshot()
const harness = createFakeHost(initial)
const batches: EditCommandBatch[] = []
const controller = new HostEditorController(
{
...harness.host,
submitEdit: async (batch) => {
batches.push(structuredClone(batch))
const result = await harness.host.submitEdit(batch)
if (batches.length === 1) throw new Error('lost receipt')
return result
},
},
initial,
)
const batch = commandForMove(initial)
await expect(controller.submitEdit(batch)).rejects.toThrow('lost receipt')
controller.replaceAuthoritativeSnapshot(harness.getRemoteSnapshot())
expect(controller.getTransactionState()).toBe('retry')
await expect(controller.retryPendingEdit()).resolves.toMatchObject({ status: 'replayed' })
expect(batches[1]).toEqual(batches[0])
expect(controller.getSnapshot().timeline.revision).toBe(1)
expect(controller.getTransactionState()).toBe('saved')
})

it('preserves playhead/scroll and reconciles only removed selection IDs', () => {
const initial = snapshot()
const runtime = new EmbeddedEditorHostRuntime(createFakeHost(initial).host, initial)
runtime.mountStores()
try {
usePlaybackStore.getState().setCurrentFrame(42)
useTimelineSettingsStore.getState().setScrollPosition(100)
useSelectionStore.getState().selectItems(['clip-1', 'removed'])
runtime.controller.replaceAuthoritativeSnapshot({
...initial,
timeline: { ...initial.timeline, revision: 1 },
})
expect(usePlaybackStore.getState().currentFrame).toBe(42)
expect(useTimelineSettingsStore.getState().scrollPosition).toBe(100)
expect(useSelectionStore.getState().selectedItemIds).toEqual(['clip-1'])
runtime.controller.replaceAuthoritativeSnapshot({
...initial,
timeline: { ...initial.timeline, revision: 2, tracks: [], durationInFrames: 20 },
})
expect(usePlaybackStore.getState().currentFrame).toBe(19)
expect(useSelectionStore.getState().selectedItemIds).toEqual([])
} finally {
runtime.unmountStores()
}
})

it('consumes trim tokens once, cancels without mutation, and never rebases a changed gesture', async () => {
const initial = snapshot()
const harness = createFakeHost(initial)
const notices: HostNotice[] = []
const runtime = new EmbeddedEditorHostRuntime(
{ ...harness.host, notify: (notice) => notices.push(notice) },
initial,
)
runtime.mountStores()
const intent = {
handle: 'end' as const,
deltaFrames: -5,
mode: 'normal' as const,
itemIds: ['clip-1'],
}
try {
const canceled = runtime.beginTrim('clip-1')!
runtime.cancelTrim(canceled)
await runtime.commitTrim(canceled, intent)
const noop = runtime.beginTrim('clip-1')!
await runtime.commitTrim(noop, { ...intent, deltaFrames: 0 })
await runtime.commitTrim(noop, intent)
expect(harness.submitEdit).not.toHaveBeenCalled()
const stale = runtime.beginTrim('clip-1')!
runtime.controller.replaceAuthoritativeSnapshot({
...initial,
timeline: { ...initial.timeline, revision: 1 },
})
// The gesture retains its original view until it settles.
await runtime.commitTrim(stale, intent)
expect(harness.submitEdit).not.toHaveBeenCalled()
expect(notices.at(-1)?.message).toContain('changed during the trim')
} finally {
runtime.unmountStores()
}
})

it('submits a trim once and rejects rapid overlap before losing the first intent', async () => {
const initial = snapshot()
const harness = createFakeHost(initial)
let release!: () => void
const delayed = {
...harness.host,
submitEdit: async (batch: EditCommandBatch) => {
await new Promise<void>((resolve) => {
release = resolve
})
return harness.host.submitEdit(batch)
},
}
const runtime = new EmbeddedEditorHostRuntime(delayed, initial)
runtime.mountStores()
try {
const token = runtime.beginTrim('clip-1')!
const intent = {
handle: 'end' as const,
deltaFrames: -5,
mode: 'normal' as const,
itemIds: ['clip-1'],
}
const pending = runtime.commitTrim(token, intent)
expect(runtime.beginTrim('clip-1')).toBeNull()
await runtime.commitTrim(token, intent)
release()
await pending
expect(harness.submitEdit).toHaveBeenCalledTimes(1)
expect(runtime.controller.getSnapshot().timeline.tracks[0]!.items[0]!.durationInFrames).toBe(
55,
)
expect(runtime.beginTrim('clip-1')).not.toBeNull()
} finally {
runtime.unmountStores()
}
})

it('translates explicit start/end ripple and roll intent without diff guessing, preserving speed and attachment breaks', () => {
const initial = snapshot()
const anchor = initial.timeline.tracks[0]!.items[0]!
Object.assign(anchor, { from: 10, sourceStart: 20, sourceEnd: 140, speed: 2 })
Object.assign(initial.timeline.tracks[0]!, {
items: [
...initial.timeline.tracks[0]!.items,
{ ...anchor, id: 'next', from: 70, sourceStart: 0, sourceEnd: 120 },
{ ...anchor, id: 'detached', from: 130, rippleLinked: false },
],
})
const batch = trimIntentBatch(initial.timeline, 'clip-1', {
handle: 'start',
deltaFrames: 5,
mode: 'ripple',
itemIds: ['clip-1'],
})
expect(batch.commands).toMatchObject([
{ type: 'trim_item', edge: 'start', source_us: 1_000_000, timeline_us: 500_000 },
{ type: 'move_item', item_id: 'clip-1' },
{ type: 'move_item', item_id: 'next' },
])
const roll = trimIntentBatch(initial.timeline, 'clip-1', {
handle: 'end',
deltaFrames: 5,
mode: 'rolling',
itemIds: ['clip-1'],
neighborId: 'next',
})
expect(roll.commands.map((command) => command.type)).toEqual(['trim_item', 'trim_item'])
Object.assign(anchor, { linkedGroupId: 'av' })
expect(() =>
trimIntentBatch(initial.timeline, 'clip-1', {
handle: 'end',
deltaFrames: 5,
mode: 'normal',
itemIds: ['clip-1'],
}),
).toThrow('Linked clip trimming')
})
})

it('never interprets a new delete selection as a retry of an unknown earlier delete', async () => {
const initial = snapshot()
Object.assign(initial.timeline.tracks[0]!, {
items: [
...initial.timeline.tracks[0]!.items,
{ ...initial.timeline.tracks[0]!.items[0]!, id: 'clip-2', from: 60 },
],
})
const submitEdit = vi.fn(async () => {
throw new Error('unknown outcome')
})
const controller = new HostEditorController(
{ ...createFakeHost(initial).host, submitEdit },
initial,
)
await expect(controller.requestRippleDelete(['clip-1'])).rejects.toThrow('unknown outcome')
await expect(controller.requestRippleDelete(['clip-2'])).resolves.toMatchObject({
status: 'unsupported',
})
expect(submitEdit).toHaveBeenCalledTimes(1)
})

it('isolates transaction listeners from successful submissions and cleanup', async () => {
const initial = snapshot()
const controller = new HostEditorController(createFakeHost(initial).host, initial)
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const listener = vi.fn()
controller.subscribeTransaction(() => {
throw new Error('observer failed')
})
controller.subscribeTransaction(listener)
try {
await expect(controller.submitEdit(commandForMove(initial))).resolves.toMatchObject({
status: 'applied',
})
expect(controller.getTransactionState()).toBe('saved')
expect(listener).toHaveBeenCalledTimes(2)
} finally {
warning.mockRestore()
}
})

it('rejects exhausted source handles before any host mutation', async () => {
const initial = snapshot()
const harness = createFakeHost(initial)
const controller = new HostEditorController(harness.host, initial)
const batch = trimIntentBatch(initial.timeline, 'clip-1', {
handle: 'end',
deltaFrames: 300,
mode: 'normal',
itemIds: ['clip-1'],
})
await expect(controller.submitEdit(batch)).resolves.toMatchObject({ status: 'rejected' })
expect(harness.submitEdit).not.toHaveBeenCalled()
expect(controller.getSnapshot()).toEqual(initial)
})

it('does not restore a superseded timeline identity from a late receipt', async () => {
const initial = snapshot()
let release!: (result: HostEditResult) => void
const controller = new HostEditorController(
{
...createFakeHost(initial).host,
submitEdit: () =>
new Promise((resolve) => {
release = resolve
}),
},
initial,
)
const pending = controller.submitEdit(commandForMove(initial))
controller.replaceAuthoritativeSnapshot({
...initial,
timeline: { ...initial.timeline, timelineId: 'replacement', revision: 0 },
})
release({
status: 'applied',
snapshot: movedSnapshot(),
result: { status: 'applied' } as HostAppliedEditResult['result'],
})
await pending
expect(controller.getSnapshot().timeline.timelineId).toBe('replacement')
})
Loading
Loading