From 6180419056c67ff7d0cda0cd5b05bb55b8d5172f Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Mon, 31 Aug 2026 15:27:31 -0700 Subject: [PATCH 01/10] feat(timeline): add durable attached clip chains --- packages/freecut-editor/README.md | 6 +- packages/freecut-editor/package.json | 2 +- packages/freecut-editor/src/index.d.ts | 6 + src/features/editor/codepress/README.md | 6 + .../editor/codepress/attached-chain.ts | 52 +++ src/features/editor/codepress/contract.ts | 32 ++ src/features/editor/codepress/document.ts | 9 + src/features/editor/codepress/edit-engine.ts | 53 +++ src/features/editor/codepress/translation.ts | 6 + src/features/editor/host/context.ts | 4 + src/features/editor/host/contract.ts | 5 + src/features/editor/host/controller.test.ts | 29 +- src/features/editor/host/controller.ts | 356 +++++++++++++----- src/features/editor/host/document.ts | 5 + src/features/editor/host/editor-surface.tsx | 5 +- src/features/editor/host/runtime.ts | 14 + .../project-bundle/schemas/project-schema.ts | 1 + .../components/timeline-item/index.tsx | 5 + .../timeline-item/item-context-menu.test.tsx | 18 +- .../timeline-item/item-context-menu.tsx | 17 + .../use-timeline-item-actions.ts | 15 + .../timeline/hooks/use-timeline-drag.ts | 4 +- .../timeline/hooks/use-timeline-trim.ts | 5 +- .../stores/actions/edit/trim-actions.ts | 19 +- .../timeline/stores/actions/item-actions.ts | 81 +++- .../timeline/stores/timeline-store-facade.ts | 1 + src/features/timeline/types.ts | 1 + .../timeline/utils/attached-chain.test.ts | 50 +++ src/features/timeline/utils/attached-chain.ts | 115 ++++++ src/types/project.ts | 1 + src/types/timeline.ts | 2 + 31 files changed, 802 insertions(+), 123 deletions(-) create mode 100644 src/features/editor/codepress/attached-chain.ts create mode 100644 src/features/timeline/utils/attached-chain.test.ts create mode 100644 src/features/timeline/utils/attached-chain.ts diff --git a/packages/freecut-editor/README.md b/packages/freecut-editor/README.md index 68f0dc3a1..4480ec512 100644 --- a/packages/freecut-editor/README.md +++ b/packages/freecut-editor/README.md @@ -69,7 +69,9 @@ shortcut editor, including J/K/L transport. UI changes call `setSettings`, and host or agent changes can flow back through `subscribe`, so embedded shortcut configuration never becomes a UI-only setting. -As of 0.3.11, the host-mode Delete action and Delete/Backspace shortcuts submit +As of 0.3.12, host-mode timeline clips use durable forward attachment chains by +default. A detached clip is an explicit ripple break and can be reattached from +its context menu. The host-mode Delete action and Delete/Backspace shortcuts submit one authoritative ripple-delete request for the selected linked cohort. The controlled timeline remains unchanged until the host receipt arrives; rejected requests surface actionable host feedback. Lift / leave gap remains the named @@ -135,5 +137,5 @@ Consumers install the exact published version and keep it pinned in their lockfile: ```bash -npm install @quantfive/freecut-editor-surface@0.3.11 +npm install @quantfive/freecut-editor-surface@0.3.12 ``` diff --git a/packages/freecut-editor/package.json b/packages/freecut-editor/package.json index bf57e0a81..19e9df070 100644 --- a/packages/freecut-editor/package.json +++ b/packages/freecut-editor/package.json @@ -1,6 +1,6 @@ { "name": "@quantfive/freecut-editor-surface", - "version": "0.3.11", + "version": "0.3.12", "description": "The host-backed FreeCut browser editor surface.", "license": "MIT", "repository": { diff --git a/packages/freecut-editor/src/index.d.ts b/packages/freecut-editor/src/index.d.ts index ac970bdad..ca1afedcd 100644 --- a/packages/freecut-editor/src/index.d.ts +++ b/packages/freecut-editor/src/index.d.ts @@ -88,6 +88,7 @@ export type EditorCapability = | 'media.relink' | 'timeline.add' | 'timeline.move' + | 'timeline.attachment' | 'timeline.trim' | 'timeline.split' | 'timeline.remove' @@ -160,6 +161,7 @@ export interface FreeCutFrameClip { trackId: string mediaId: string linkedGroupId?: string | null + rippleLinked?: boolean from: number durationInFrames: number sourceStart?: number @@ -177,6 +179,7 @@ export interface FreeCutFrameText { from: number durationInFrames: number linkedGroupId?: string | null + rippleLinked?: boolean text: string style?: Record opacity?: number @@ -190,6 +193,7 @@ export interface FreeCutFrameCaptionCue { from: number durationInFrames: number linkedGroupId?: string | null + rippleLinked?: boolean text: string speaker?: string | null style?: CaptionStyle @@ -483,6 +487,7 @@ export interface EditorHostContextValue { export interface HostTimelineEditPort { requestRippleDelete(itemIds: readonly string[]): Promise | void + requestSetItemAttachment?(itemIds: readonly string[], rippleLinked: boolean): Promise | void } export interface EditorHostProviderProps { @@ -501,6 +506,7 @@ export declare const SUPPORTED_HOST_COMMANDS: readonly [ 'add_clip', 'add_text', 'move_item', + 'set_item_attachment', 'trim_item', 'split_item', 'remove_item', diff --git a/src/features/editor/codepress/README.md b/src/features/editor/codepress/README.md index 492ffabcb..0e3b32124 100644 --- a/src/features/editor/codepress/README.md +++ b/src/features/editor/codepress/README.md @@ -64,6 +64,12 @@ frame timestamp. ## Ripple and captions +Timeline items carry optional `ripple_linked` attachment metadata; missing means +attached for compatibility, while `false` is a durable break. `move_item` may +carry `ripple: true` to ask the authority to resolve the forward touching chain +from one anchor. `set_item_attachment` changes the break explicitly and keeps +the A/V `linked_group_id` cohort concept separate. + `ripple_delete` operates on `[start_us, end_us)` in the selected tracks (or all tracks for `track_ids: null`). Downstream items shift left by the exact frame delta, with each shifted endpoint re-encoded from its resulting frame index. diff --git a/src/features/editor/codepress/attached-chain.ts b/src/features/editor/codepress/attached-chain.ts new file mode 100644 index 000000000..af1a20035 --- /dev/null +++ b/src/features/editor/codepress/attached-chain.ts @@ -0,0 +1,52 @@ +import type { TimelineItem, TimelineState } from './contract' + +function itemId(item: TimelineItem): string { + return item.item_type === 'caption_cue' ? item.cue_id : item.item_id +} + +function start(item: TimelineItem): number { + return item.item_type === 'caption_cue' ? item.start_us : item.timeline_start_us +} + +function end(item: TimelineItem): number { + return item.item_type === 'caption_cue' ? item.end_us : item.timeline_end_us +} + +function attached(item: TimelineItem): boolean { + return item.ripple_linked !== false +} + +/** Neutral-wire counterpart of the frame-native attachment resolver. */ +export function resolveAttachedChainIds(timeline: TimelineState, anchorId: string): string[] { + const items = timeline.tracks.flatMap((track) => track.items) + const byId = new Map(items.map((item) => [itemId(item), item])) + const anchor = byId.get(anchorId) + if (!anchor) return [] + const result: string[] = [] + const seen = new Set() + const queue: TimelineItem[] = [anchor] + while (queue.length) { + const current = queue.shift()! + const currentId = itemId(current) + if (seen.has(currentId)) continue + seen.add(currentId) + result.push(currentId) + if (current.item_type !== 'caption_cue' && current.linked_group_id) { + for (const cohort of items) { + if (cohort.linked_group_id === current.linked_group_id && !seen.has(itemId(cohort))) + queue.push(cohort) + } + } + if (!attached(current)) continue + const currentTrack = current.track_id + const next = items.find( + (candidate) => + candidate.track_id === currentTrack && + itemId(candidate) !== currentId && + start(candidate) === end(current) && + attached(candidate), + ) + if (next && !seen.has(itemId(next))) queue.push(next) + } + return result +} diff --git a/src/features/editor/codepress/contract.ts b/src/features/editor/codepress/contract.ts index de7c97fb3..f475d3a18 100644 --- a/src/features/editor/codepress/contract.ts +++ b/src/features/editor/codepress/contract.ts @@ -128,6 +128,8 @@ export interface ClipItem { source_end_us: Microseconds /** Stable identity for an atomically linked media/caption cohort. */ linked_group_id?: string | null + /** Sequence attachment; omitted is attached, false is a detached break. */ + ripple_linked?: boolean transform?: Transform opacity?: number volume?: number @@ -149,6 +151,7 @@ export interface TextItem { text: string /** Reserved for host-authored synchronized text cohorts. */ linked_group_id?: string | null + ripple_linked?: boolean style?: TextStyle transform?: Transform opacity?: number @@ -164,6 +167,7 @@ export interface CaptionCue { text: string /** Stable identity when a caption is part of a linked edit cohort. */ linked_group_id?: string | null + ripple_linked?: boolean speaker?: string | null style?: CaptionStyle } @@ -236,6 +240,13 @@ export interface MoveItemCommand { to_track_id: TrackId timeline_start_us: Microseconds index: number + ripple?: boolean +} +export interface SetItemAttachmentCommand { + command_id: CommandId + type: 'set_item_attachment' + item_ids: readonly TimelineItemId[] + ripple_linked: boolean } export interface TrimItemCommand { command_id: CommandId @@ -381,6 +392,7 @@ export type EditCommand = | DuplicateItemCommand | RemoveItemCommand | MoveItemCommand + | SetItemAttachmentCommand | TrimItemCommand | SplitItemCommand | RippleDeleteCommand @@ -433,6 +445,8 @@ export function itemIdFromCommand(command: EditCommand): TimelineItemId | null { case 'split_item': case 'set_item_properties': return command.item_id + case 'set_item_attachment': + return command.item_ids[0] ?? null case 'upsert_caption_cues': return command.cues[0]?.cue_id ?? null case 'remove_caption_cues': @@ -460,6 +474,7 @@ export function isTimelineItemCommand( | SetCaptionStyleCommand | RequestJobCommand | RippleDeleteCommand + | SetItemAttachmentCommand > { return itemIdFromCommand(command) !== null } @@ -1163,6 +1178,23 @@ function validateCommand(value: unknown, path: string, errors: VideoCommandError checkMicroseconds(value.timeline_start_us, `${path}.timeline_start_us`, errors) checkIndex(value.index, `${path}.index`, errors) break + case 'set_item_attachment': + if (!checkArray(value.item_ids, `${path}.item_ids`, errors)) return + if (value.item_ids.length === 0) + errors.push(invalidRequest(`${path}.item_ids`, 'must not be empty')) + { + const ids = new Set() + for (const [index, id] of value.item_ids.entries()) { + if (checkIdentifier(id, `${path}.item_ids[${index}]`, errors)) { + if (ids.has(id)) + errors.push(invalidRequest(`${path}.item_ids[${index}]`, 'must be unique')) + ids.add(id) + } + } + } + if (typeof value.ripple_linked !== 'boolean') + errors.push(invalidRequest(`${path}.ripple_linked`, 'must be a boolean')) + break case 'trim_item': checkIdentifier(value.item_id, `${path}.item_id`, errors) if (value.edge !== 'start' && value.edge !== 'end') diff --git a/src/features/editor/codepress/document.ts b/src/features/editor/codepress/document.ts index e538e9d19..815873e45 100644 --- a/src/features/editor/codepress/document.ts +++ b/src/features/editor/codepress/document.ts @@ -22,6 +22,7 @@ export interface FreeCutFrameClip { trackId: string mediaId: string linkedGroupId?: string | null + rippleLinked?: boolean from: number durationInFrames: number sourceStart?: number @@ -39,6 +40,7 @@ export interface FreeCutFrameText { from: number durationInFrames: number linkedGroupId?: string | null + rippleLinked?: boolean text: string style?: Record opacity?: number @@ -52,6 +54,7 @@ export interface FreeCutFrameCaptionCue { from: number durationInFrames: number linkedGroupId?: string | null + rippleLinked?: boolean text: string speaker?: string | null style?: CaptionStyle @@ -124,6 +127,7 @@ function toContractItem(item: FreeCutFrameItem, fps: FrameRateLike): TimelineIte end_us: range.end_us, text: item.text, ...(item.linkedGroupId !== undefined ? { linked_group_id: item.linkedGroupId } : {}), + ...(item.rippleLinked !== undefined ? { ripple_linked: item.rippleLinked } : {}), ...(item.speaker !== undefined ? { speaker: item.speaker } : {}), ...(item.style !== undefined ? { style: { ...item.style } } : {}), } @@ -137,6 +141,7 @@ function toContractItem(item: FreeCutFrameItem, fps: FrameRateLike): TimelineIte timeline_end_us: range.end_us, text: item.text, ...(item.linkedGroupId !== undefined ? { linked_group_id: item.linkedGroupId } : {}), + ...(item.rippleLinked !== undefined ? { ripple_linked: item.rippleLinked } : {}), ...(item.style ? { style: { @@ -184,6 +189,7 @@ function toContractItem(item: FreeCutFrameItem, fps: FrameRateLike): TimelineIte source_start_us: framesToMicroseconds(sourceStart, fps), source_end_us: framesToMicroseconds(sourceEnd, fps), ...(item.linkedGroupId !== undefined ? { linked_group_id: item.linkedGroupId } : {}), + ...(item.rippleLinked !== undefined ? { ripple_linked: item.rippleLinked } : {}), ...(item.volume !== undefined ? { volume: item.volume } : {}), ...(item.speed !== undefined ? { speed: item.speed } : {}), ...(item.opacity !== undefined ? { opacity: item.opacity } : {}), @@ -292,6 +298,7 @@ function fromContractItem(item: TimelineItem, fps: FrameRateLike): FreeCutFrameI durationInFrames: range.end - range.start, text: item.text, ...(item.linked_group_id !== undefined ? { linkedGroupId: item.linked_group_id } : {}), + ...(item.ripple_linked !== undefined ? { rippleLinked: item.ripple_linked } : {}), ...(item.speaker !== undefined ? { speaker: item.speaker } : {}), ...(item.style !== undefined ? { style: { ...item.style } } : {}), } @@ -311,6 +318,7 @@ function fromContractItem(item: TimelineItem, fps: FrameRateLike): FreeCutFrameI durationInFrames: range.end - range.start, text: item.text, ...(item.linked_group_id !== undefined ? { linkedGroupId: item.linked_group_id } : {}), + ...(item.ripple_linked !== undefined ? { rippleLinked: item.ripple_linked } : {}), ...(item.style ? { style: { ...item.style } } : {}), ...(item.opacity !== undefined ? { opacity: item.opacity } : {}), ...(item.transform ? { transform: fromTransform(item.transform) } : {}), @@ -338,6 +346,7 @@ function fromContractItem(item: TimelineItem, fps: FrameRateLike): FreeCutFrameI sourceStart: sourceRange.start, sourceEnd: sourceRange.end, ...(item.linked_group_id !== undefined ? { linkedGroupId: item.linked_group_id } : {}), + ...(item.ripple_linked !== undefined ? { rippleLinked: item.ripple_linked } : {}), ...(item.volume !== undefined ? { volume: item.volume } : {}), ...(item.speed !== undefined ? { speed: item.speed } : {}), ...(item.opacity !== undefined ? { opacity: item.opacity } : {}), diff --git a/src/features/editor/codepress/edit-engine.ts b/src/features/editor/codepress/edit-engine.ts index dc49a5dcd..836d5862e 100644 --- a/src/features/editor/codepress/edit-engine.ts +++ b/src/features/editor/codepress/edit-engine.ts @@ -25,6 +25,7 @@ import type { } from './contract' import { MAX_ID_LENGTH, validateTimelineState } from './contract' import type { ControlledEditEngine, EditEngineContext, EditEngineResult } from './interfaces' +import { resolveAttachedChainIds } from './attached-chain' export class EditEngineError extends Error { readonly code: @@ -417,6 +418,31 @@ function applyMoveItem(timeline: MutableTimeline, command: MoveItemCommand): Com const located = findItem(timeline, command.item_id, command.command_id) const target = findTrack(timeline, command.to_track_id, command.command_id) ensureTrackCompatibility(target, located.item, command.command_id) + if (command.ripple) { + const chainIds = resolveAttachedChainIds(timeline, command.item_id) + const oldStarts = new Map( + chainIds.map((id) => { + const item = findItem(timeline, id, command.command_id).item + return [id, itemStart(item)] as const + }), + ) + const delta = command.timeline_start_us - itemStart(located.item) + const moved = applyMoveItem(timeline, { ...command, ripple: undefined }) + for (const id of chainIds) { + if (id === command.item_id) continue + const member = findItem(timeline, id, command.command_id) + setItemAt( + timeline, + member, + setItemPosition(member.item, oldStarts.get(id)! + delta, itemEnd(member.item) + delta), + ) + } + return { + ...moved, + moved_item_ids: chainIds, + updated_item_ids: chainIds, + } + } const oldStart = itemStart(located.item) const moved = setItemPosition( setItemTrack(located.item, target.track_id), @@ -436,6 +462,31 @@ function applyMoveItem(timeline: MutableTimeline, command: MoveItemCommand): Com } } +function applySetItemAttachment( + timeline: MutableTimeline, + command: Extract, +): CommandEffect { + const requested = new Set(command.item_ids) + const updated: string[] = [] + for (const track of timeline.tracks) { + const items = track.items.map((item) => { + const id = itemId(item) + if (!requested.has(id)) return item + if (item.ripple_linked === command.ripple_linked) return item + updated.push(id) + return { ...item, ripple_linked: command.ripple_linked } + }) + replaceTrackItems(timeline, timeline.tracks.indexOf(track), items) + } + if (updated.length !== requested.size) + throw new EditEngineError( + 'unknown_item', + 'One or more attachment items do not exist', + command.command_id, + ) + return { ...emptyEffect(), updated_item_ids: updated } +} + function applyTrim( timeline: MutableTimeline, command: Extract, @@ -838,6 +889,8 @@ function applyCommand( case 'move_item': assertFrameAligned(command.timeline_start_us, fps) return applyMoveItem(timeline, command) + case 'set_item_attachment': + return applySetItemAttachment(timeline, command) case 'trim_item': assertFrameAligned(command.timeline_us, fps) assertFrameAligned(command.source_us, fps) diff --git a/src/features/editor/codepress/translation.ts b/src/features/editor/codepress/translation.ts index 005182921..02ab3ecd3 100644 --- a/src/features/editor/codepress/translation.ts +++ b/src/features/editor/codepress/translation.ts @@ -79,6 +79,7 @@ export type FrameEditCommand = timeline_start_frame?: Frame }) | Extract + | Extract | (Omit, 'timeline_start_us'> & { timeline_start_frame: Frame }) @@ -177,6 +178,7 @@ function itemToFrames(item: TimelineItem, fps: FrameRateLike): FrameItem { cue_id: item.cue_id, track_id: item.track_id, text: item.text, + ...(item.ripple_linked !== undefined ? { ripple_linked: item.ripple_linked } : {}), ...(item.speaker !== undefined ? { speaker: item.speaker } : {}), ...(item.style !== undefined ? { style: item.style } : {}), start_frame: assertFrameAligned(item.start_us, fps), @@ -189,6 +191,7 @@ function itemToFrames(item: TimelineItem, fps: FrameRateLike): FrameItem { item_id: item.item_id, track_id: item.track_id, text: item.text, + ...(item.ripple_linked !== undefined ? { ripple_linked: item.ripple_linked } : {}), ...(item.style !== undefined ? { style: item.style } : {}), ...(item.transform !== undefined ? { transform: item.transform } : {}), ...(item.opacity !== undefined ? { opacity: item.opacity } : {}), @@ -203,6 +206,7 @@ function itemToFrames(item: TimelineItem, fps: FrameRateLike): FrameItem { track_id: item.track_id, media_id: item.media_id, media_kind: item.media_kind, + ...(item.ripple_linked !== undefined ? { ripple_linked: item.ripple_linked } : {}), ...(item.transform !== undefined ? { transform: item.transform } : {}), ...(item.opacity !== undefined ? { opacity: item.opacity } : {}), ...(item.volume !== undefined ? { volume: item.volume } : {}), @@ -270,6 +274,8 @@ export function translateCommandToFrames( } case 'remove_item': return command + case 'set_item_attachment': + return command case 'move_item': return { ...command, diff --git a/src/features/editor/host/context.ts b/src/features/editor/host/context.ts index 45e3d7ff8..389cc080e 100644 --- a/src/features/editor/host/context.ts +++ b/src/features/editor/host/context.ts @@ -10,6 +10,10 @@ import { export interface HostTimelineEditPort { /** Ask the host authority to ripple-delete the selected timeline anchors. */ requestRippleDelete(itemIds: readonly string[]): Promise | void + requestSetItemAttachment?: ( + itemIds: readonly string[], + rippleLinked: boolean, + ) => Promise | void } export interface EditorHostContextValue { diff --git a/src/features/editor/host/contract.ts b/src/features/editor/host/contract.ts index 294dcc9aa..747e4ad85 100644 --- a/src/features/editor/host/contract.ts +++ b/src/features/editor/host/contract.ts @@ -25,6 +25,7 @@ export type EditorCapability = | 'media.relink' | 'timeline.add' | 'timeline.move' + | 'timeline.attachment' | 'timeline.trim' | 'timeline.split' | 'timeline.remove' @@ -50,6 +51,7 @@ export const DEFAULT_HOST_CAPABILITIES: EditorCapabilityMap = { 'media.relink': false, 'timeline.add': true, 'timeline.move': true, + 'timeline.attachment': true, 'timeline.trim': true, 'timeline.split': true, 'timeline.remove': true, @@ -383,6 +385,7 @@ export const SUPPORTED_HOST_COMMANDS = [ 'add_clip', 'add_text', 'move_item', + 'set_item_attachment', 'trim_item', 'split_item', 'remove_item', @@ -404,6 +407,8 @@ export function capabilityForCommand(command: EditCommand['type']): EditorCapabi return 'timeline.add' case 'move_item': return 'timeline.move' + case 'set_item_attachment': + return 'timeline.attachment' case 'trim_item': return 'timeline.trim' case 'split_item': diff --git a/src/features/editor/host/controller.test.ts b/src/features/editor/host/controller.test.ts index 0e20ede10..5307135aa 100644 --- a/src/features/editor/host/controller.test.ts +++ b/src/features/editor/host/controller.test.ts @@ -698,6 +698,7 @@ describe('embedded FreeCut host controller', () => { 'add_clip', 'add_text', 'move_item', + 'set_item_attachment', 'trim_item', 'split_item', 'remove_item', @@ -721,6 +722,24 @@ describe('embedded FreeCut host controller', () => { expect(adapter.capabilities).toEqual({}) }) + it('derives attachment toggles as one command with item preconditions', () => { + const initial = snapshot() + const track = initial.timeline.tracks[0]! + const next = { + ...initial.timeline, + tracks: [{ ...track, items: [{ ...track.items[0]!, rippleLinked: false }] }], + } + const derived = deriveSupportedHostEdit(initial.timeline, next) + expect(derived.batch?.commands).toEqual([ + expect.objectContaining({ + type: 'set_item_attachment', + item_ids: ['clip-1'], + ripple_linked: false, + }), + ]) + expect(derived.batch?.preconditions).toHaveLength(1) + }) + describe('host round-trip stability', () => { async function flushReconcile(): Promise { for (let i = 0; i < 10; i += 1) { @@ -1278,13 +1297,9 @@ describe('embedded FreeCut host controller', () => { const derived = deriveSupportedHostEdit(initial.timeline, next) - expect(derived.batch).toBeNull() - expect(derived.reason).toMatch(/^Multiple or ambiguous timeline changes are unsupported\b/) - expect(derived.reason).toContain('added 0, removed 0, changed 2') - expect(derived.detail).toEqual({ - code: 'ambiguous_change', - changeCounts: { added: 0, removed: 0, changed: 2 }, - }) + expect(derived.batch?.commands).toEqual([ + expect.objectContaining({ type: 'move_item', item_id: 'clip-1', ripple: true }), + ]) }) it('derives a move_item command for a store drag of a clip carrying an identity transform', async () => { diff --git a/src/features/editor/host/controller.ts b/src/features/editor/host/controller.ts index a32ec7a9d..dc8436291 100644 --- a/src/features/editor/host/controller.ts +++ b/src/features/editor/host/controller.ts @@ -67,6 +67,48 @@ function isFrameClip(item: FreeCutFrameItem): item is FrameClip { return item.type === 'video' || item.type === 'audio' || item.type === 'image' } +function frameItemAttached(item: FreeCutFrameItem): boolean { + return item.rippleLinked !== false +} + +function frameAttachedChain(document: FreeCutFrameDocument, anchorId: string): string[] { + const items = document.tracks.flatMap((track) => track.items) + const byId = new Map(items.map((item) => [item.id, item])) + const anchor = byId.get(anchorId) + if (!anchor) return [] + const result: string[] = [] + const seen = new Set() + const queue: FreeCutFrameItem[] = [anchor] + while (queue.length) { + const current = queue.shift()! + if (seen.has(current.id)) continue + seen.add(current.id) + result.push(current.id) + for (const cohort of items.filter( + (candidate) => candidate.linkedGroupId && candidate.linkedGroupId === current.linkedGroupId, + )) { + if (!seen.has(cohort.id)) queue.push(cohort) + } + if (!frameItemAttached(current)) continue + const end = current.from + current.durationInFrames + const next = items.find( + (candidate) => + candidate.trackId === current.trackId && + candidate.id !== current.id && + candidate.from === end && + frameItemAttached(candidate), + ) + if (next && !seen.has(next.id)) queue.push(next) + } + return result +} + +function withoutAttachment(item: FreeCutFrameItem): unknown { + const copy = { ...item } as Record + delete copy.rippleLinked + return copy +} + /** * The concrete source window of a clip that states none: a clip with no * source range plays from the start of its media for its timeline duration. @@ -480,6 +522,53 @@ export function deriveRippleDelete( } } +export function deriveSetItemAttachment( + previous: FreeCutFrameDocument, + itemIds: readonly string[], + rippleLinked: boolean, + options: { operationId?: string; idempotencyKey?: string } = {}, +): DerivedHostEdit { + const ids = [...new Set(itemIds)] + const items = itemMap(previous) + if (ids.length === 0) return { batch: null, reason: 'No timeline item is selected' } + const selected = ids.map((id) => items.get(id)) + if (selected.some((item) => item === undefined)) { + return { batch: null, reason: 'The selected timeline item is no longer authoritative' } + } + const trackById = new Map(previous.tracks.map((track) => [track.id, track])) + const isTrackLocked = (trackId: string, visited = new Set()): boolean => { + if (visited.has(trackId)) return true + visited.add(trackId) + const track = trackById.get(trackId) + return ( + !!track?.locked || (!!track?.parentTrackId && isTrackLocked(track.parentTrackId, visited)) + ) + } + if (selected.some((item) => isTrackLocked(item!.trackId))) { + return { batch: null, reason: 'Cannot change attachment on a locked track' } + } + const operationId = options.operationId ?? `op-${crypto.randomUUID()}` + const idempotencyKey = options.idempotencyKey ?? `idem-${crypto.randomUUID()}` + return { + batch: { + contract_version: 1, + timeline_id: previous.timelineId, + operation_id: operationId, + idempotency_key: idempotencyKey, + base_revision: previous.revision, + preconditions: selected.map((item) => preconditionForItem(item!, previous.fps)), + commands: [ + { + command_id: `attachment-${operationId}`, + type: 'set_item_attachment', + item_ids: ids, + ripple_linked: rippleLinked, + }, + ], + }, + } +} + /** * Derive one bounded command batch from the real editor's frame-native store * change. Ambiguous or unsupported changes fail closed instead of being @@ -519,7 +608,30 @@ export function deriveSupportedHostEdit( preconditions.push({ type: 'track_absent', track_id: track.id }) } - if (removed.length === 0 && added.length === 0 && changed.length === 0) { + const attachmentOnlyChange = + removed.length === 0 && + added.length === 0 && + changed.length > 0 && + changed.every((id) => { + const before = previousItems.get(id) + const after = nextItems.get(id) + return ( + before !== undefined && + after !== undefined && + before.rippleLinked !== after.rippleLinked && + stableSerialize(withoutAttachment(before)) === stableSerialize(withoutAttachment(after)) + ) + }) + + if (attachmentOnlyChange) { + commands.push({ + command_id: `attachment-${operationId}`, + type: 'set_item_attachment', + item_ids: changed, + ripple_linked: nextItems.get(changed[0]!)!.rippleLinked !== false, + }) + for (const id of changed) preconditions.push(preconditionForItem(previousItems.get(id)!, fps)) + } else if (removed.length === 0 && added.length === 0 && changed.length === 0) { // Any track creation is already represented by the add_track commands // above. With no tracks added either, nothing changed at all: `commands` // stays empty and the caller gets the silent "No supported edit was @@ -603,124 +715,163 @@ export function deriveSupportedHostEdit( }) preconditions.push(preconditionForItem(before, fps)) } else if (removed.length === 0 && added.length === 0 && changed.length > 1) { - // Host mode defaults contiguous trims to ripple edits. The native store - // applies that as one trimmed clip plus uniformly shifted downstream - // clips, so recognize the full gesture and serialize it as one command - // batch instead of restoring the authoritative snapshot as ambiguous. - const trimIds = changed.filter((id) => { + const movedOnly = changed.every((id) => { const before = previousItems.get(id)! const after = nextItems.get(id)! - if (before.type === 'caption_cue' || after.type === 'caption_cue') return false const facts = itemChangeFacts(before, after) return ( facts.metadataUnchanged && facts.transformUnchanged && - facts.sameTrack && - (!facts.sourceUnchanged || !facts.durationUnchanged) + facts.sourceUnchanged && + facts.durationUnchanged && + !facts.timelineUnchanged ) }) - const trimId = trimIds.length === 1 ? trimIds[0] : null - const beforeTrim = trimId ? previousItems.get(trimId) : null - const afterTrim = trimId ? nextItems.get(trimId) : null - const shift = - beforeTrim && afterTrim ? afterTrim.durationInFrames - beforeTrim.durationInFrames : 0 - const oldTrimEnd = beforeTrim ? beforeTrim.from + beforeTrim.durationInFrames : 0 - const movedIds = trimId ? changed.filter((id) => id !== trimId) : [] - const isUniformContiguousRipple = - !!trimId && - !!beforeTrim && - !!afterTrim && - isFrameClip(beforeTrim) && - isFrameClip(afterTrim) && - shift !== 0 && - movedIds.length > 0 && - movedIds.every((id) => { + const movedAnchor = movedOnly + ? changed + .map((id) => previousItems.get(id)!) + .sort((left, right) => left.from - right.from || left.id.localeCompare(right.id))[0] + : undefined + const chainIds = movedAnchor ? frameAttachedChain(previous, movedAnchor.id) : [] + if ( + movedOnly && + movedAnchor && + chainIds.length === changed.length && + new Set(chainIds).size === changed.length + ) { + const afterAnchor = nextItems.get(movedAnchor.id)! + const location = itemLocation(next, movedAnchor.id) + if (!location) return { batch: null, reason: 'A rippled item no longer has a track' } + commands.push({ + command_id: `move-${movedAnchor.id}`, + type: 'move_item', + item_id: movedAnchor.id, + to_track_id: afterAnchor.trackId, + timeline_start_us: framesToMicroseconds(afterAnchor.from, fps), + index: location.index, + ripple: true, + }) + for (const id of changed) preconditions.push(preconditionForItem(previousItems.get(id)!, fps)) + } else { + // Host mode defaults contiguous trims to ripple edits. The native store + // applies that as one trimmed clip plus uniformly shifted downstream + // clips, so recognize the full gesture and serialize it as one command + // batch instead of restoring the authoritative snapshot as ambiguous. + const trimIds = changed.filter((id) => { const before = previousItems.get(id)! const after = nextItems.get(id)! + if (before.type === 'caption_cue' || after.type === 'caption_cue') return false const facts = itemChangeFacts(before, after) return ( facts.metadataUnchanged && facts.transformUnchanged && - facts.sourceUnchanged && - facts.durationUnchanged && - !facts.timelineUnchanged && facts.sameTrack && - before.trackId === beforeTrim.trackId && - before.from >= oldTrimEnd && - after.from - before.from === shift + (!facts.sourceUnchanged || !facts.durationUnchanged) ) }) + const trimId = trimIds.length === 1 ? trimIds[0] : null + const beforeTrim = trimId ? previousItems.get(trimId) : null + const afterTrim = trimId ? nextItems.get(trimId) : null + const shift = + beforeTrim && afterTrim ? afterTrim.durationInFrames - beforeTrim.durationInFrames : 0 + const oldTrimEnd = beforeTrim ? beforeTrim.from + beforeTrim.durationInFrames : 0 + const movedIds = trimId ? changed.filter((id) => id !== trimId) : [] + const isUniformContiguousRipple = + !!trimId && + !!beforeTrim && + !!afterTrim && + isFrameClip(beforeTrim) && + isFrameClip(afterTrim) && + shift !== 0 && + movedIds.length > 0 && + movedIds.every((id) => { + const before = previousItems.get(id)! + const after = nextItems.get(id)! + const facts = itemChangeFacts(before, after) + return ( + facts.metadataUnchanged && + facts.transformUnchanged && + facts.sourceUnchanged && + facts.durationUnchanged && + !facts.timelineUnchanged && + facts.sameTrack && + before.trackId === beforeTrim.trackId && + before.from >= oldTrimEnd && + after.from - before.from === shift + ) + }) - if (!isUniformContiguousRipple || !trimId || !beforeTrim || !afterTrim) { - const detail: HostEditRejectionDetail = { - code: 'ambiguous_change', - changeCounts: { added: added.length, removed: removed.length, changed: changed.length }, + if (!isUniformContiguousRipple || !trimId || !beforeTrim || !afterTrim) { + const detail: HostEditRejectionDetail = { + code: 'ambiguous_change', + changeCounts: { added: added.length, removed: removed.length, changed: changed.length }, + } + return { + batch: null, + reason: `Multiple or ambiguous timeline changes are unsupported${describeRejection(detail)}`, + detail, + } } - return { - batch: null, - reason: `Multiple or ambiguous timeline changes are unsupported${describeRejection(detail)}`, - detail, - } - } - const [beforeSourceStart, beforeSourceEnd] = sourceBounds(beforeTrim) - const [afterSourceStart, afterSourceEnd] = sourceBounds(afterTrim) - const edge = - afterTrim.from !== beforeTrim.from || - (afterSourceStart !== beforeSourceStart && afterSourceEnd === beforeSourceEnd) - ? 'start' - : 'end' - const trimTimelineFrame = - edge === 'start' ? beforeTrim.from - shift : afterTrim.from + afterTrim.durationInFrames - const trimSourceFrame = edge === 'start' ? afterSourceStart : afterSourceEnd - const requiredCommands = changed.length + (edge === 'start' ? 1 : 0) - if (commands.length + requiredCommands > MAX_COMMANDS_PER_OPERATION) { - return { - batch: null, - reason: `Ripple trim exceeds the ${MAX_COMMANDS_PER_OPERATION}-command host operation limit`, + const [beforeSourceStart, beforeSourceEnd] = sourceBounds(beforeTrim) + const [afterSourceStart, afterSourceEnd] = sourceBounds(afterTrim) + const edge = + afterTrim.from !== beforeTrim.from || + (afterSourceStart !== beforeSourceStart && afterSourceEnd === beforeSourceEnd) + ? 'start' + : 'end' + const trimTimelineFrame = + edge === 'start' ? beforeTrim.from - shift : afterTrim.from + afterTrim.durationInFrames + const trimSourceFrame = edge === 'start' ? afterSourceStart : afterSourceEnd + const requiredCommands = changed.length + (edge === 'start' ? 1 : 0) + if (commands.length + requiredCommands > MAX_COMMANDS_PER_OPERATION) { + return { + batch: null, + reason: `Ripple trim exceeds the ${MAX_COMMANDS_PER_OPERATION}-command host operation limit`, + } } - } - commands.push({ - command_id: `trim-${trimId}`, - type: 'trim_item', - item_id: trimId, - edge, - timeline_us: framesToMicroseconds(trimTimelineFrame, fps), - source_us: framesToMicroseconds(trimSourceFrame, fps), - }) - preconditions.push(preconditionForItem(beforeTrim, fps)) - - // Ripple-start anchors the trimmed item at its original timeline position. - // The wire trim moves its leading edge first, then this move restores the - // anchor while preserving the newly shortened source/timeline span. - if (edge === 'start') { - const location = itemLocation(next, trimId) - if (!location) return { batch: null, reason: 'The trimmed item no longer has a track' } commands.push({ - command_id: `anchor-${trimId}`, - type: 'move_item', + command_id: `trim-${trimId}`, + type: 'trim_item', item_id: trimId, - to_track_id: afterTrim.trackId, - timeline_start_us: framesToMicroseconds(afterTrim.from, fps), - index: location.index, + edge, + timeline_us: framesToMicroseconds(trimTimelineFrame, fps), + source_us: framesToMicroseconds(trimSourceFrame, fps), }) - } + preconditions.push(preconditionForItem(beforeTrim, fps)) - for (const id of movedIds) { - const before = previousItems.get(id)! - const after = nextItems.get(id)! - const location = itemLocation(next, id) - if (!location) return { batch: null, reason: 'A rippled item no longer has a track' } - commands.push({ - command_id: `move-${id}`, - type: 'move_item', - item_id: id, - to_track_id: after.trackId, - timeline_start_us: framesToMicroseconds(after.from, fps), - index: location.index, - }) - preconditions.push(preconditionForItem(before, fps)) + // Ripple-start anchors the trimmed item at its original timeline position. + // The wire trim moves its leading edge first, then this move restores the + // anchor while preserving the newly shortened source/timeline span. + if (edge === 'start') { + const location = itemLocation(next, trimId) + if (!location) return { batch: null, reason: 'The trimmed item no longer has a track' } + commands.push({ + command_id: `anchor-${trimId}`, + type: 'move_item', + item_id: trimId, + to_track_id: afterTrim.trackId, + timeline_start_us: framesToMicroseconds(afterTrim.from, fps), + index: location.index, + }) + } + + for (const id of movedIds) { + const before = previousItems.get(id)! + const after = nextItems.get(id)! + const location = itemLocation(next, id) + if (!location) return { batch: null, reason: 'A rippled item no longer has a track' } + commands.push({ + command_id: `move-${id}`, + type: 'move_item', + item_id: id, + to_track_id: after.trackId, + timeline_start_us: framesToMicroseconds(after.from, fps), + index: location.index, + }) + preconditions.push(preconditionForItem(before, fps)) + } } } else if (removed.length === 0 && added.length === 0 && changed.length === 1) { const id = changed[0]! @@ -879,6 +1030,25 @@ export class HostEditorController { return this.submitEdit(derived.batch) } + async requestSetItemAttachment( + itemIds: readonly string[], + rippleLinked: boolean, + ): Promise { + const derived = deriveSetItemAttachment(this.snapshot.timeline, itemIds, rippleLinked) + if (!derived.batch) { + this.notify({ + kind: 'unsupported', + message: derived.reason ?? 'Attachment change is unavailable', + }) + return { + status: 'unsupported', + snapshot: this.getSnapshot(), + reason: derived.reason ?? 'Attachment change is unavailable', + } + } + return this.submitEdit(derived.batch) + } + async submitEdit(batch: EditCommandBatch): Promise { const unsupported = batch.commands.find((command) => { const capability = capabilityForCommand(command.type) diff --git a/src/features/editor/host/document.ts b/src/features/editor/host/document.ts index 2564777cc..5fea5147d 100644 --- a/src/features/editor/host/document.ts +++ b/src/features/editor/host/document.ts @@ -157,6 +157,7 @@ function nativeItemFromHostItem( label: item.text || 'Text', text: item.text, ...linkedGroupMetadata(item.linkedGroupId), + ...(item.rippleLinked !== undefined ? { rippleLinked: item.rippleLinked } : {}), color: textColor, ...(typeof item.style?.font_family === 'string' ? { fontFamily: item.style.font_family } @@ -184,6 +185,7 @@ function nativeItemFromHostItem( text: item.text, textRole: 'caption', ...linkedGroupMetadata(item.linkedGroupId), + ...(item.rippleLinked !== undefined ? { rippleLinked: item.rippleLinked } : {}), color: style?.color ?? '#ffffff', ...(typeof style?.font_family === 'string' ? { fontFamily: style.font_family } : {}), ...(typeof style?.font_size === 'number' ? { fontSize: style.font_size } : {}), @@ -212,6 +214,7 @@ function nativeItemFromHostItem( mediaId: item.mediaId, src: '', ...linkedGroupMetadata(item.linkedGroupId), + ...(item.rippleLinked !== undefined ? { rippleLinked: item.rippleLinked } : {}), ...(sourceDuration !== undefined ? { sourceDuration } : {}), // Carry the host's source range through exactly as stated, absence // included. A native item with no `sourceStart` plays from source frame @@ -289,6 +292,7 @@ function frameItemToNativeComparable( sourceStart: item.sourceStart, sourceEnd: item.sourceEnd, ...linkedGroupMetadata(item.linkedGroupId), + ...(item.rippleLinked !== undefined ? { rippleLinked: item.rippleLinked } : {}), // Optional fields are emitted only when set so the comparable shape // matches host snapshots that omit them entirely. ...(item.volume !== undefined ? { volume: item.volume } : {}), @@ -317,6 +321,7 @@ function frameItemToNativeComparable( durationInFrames: item.durationInFrames, text: item.text, ...linkedGroupMetadata(item.linkedGroupId), + ...(item.rippleLinked !== undefined ? { rippleLinked: item.rippleLinked } : {}), ...(Object.keys(style).length > 0 ? { style } : {}), } } diff --git a/src/features/editor/host/editor-surface.tsx b/src/features/editor/host/editor-surface.tsx index 85785f493..3b16d4e19 100644 --- a/src/features/editor/host/editor-surface.tsx +++ b/src/features/editor/host/editor-surface.tsx @@ -126,7 +126,10 @@ export function FreeCutEditorSurface({ host }: { host: EditorHost }) { mode: 'host', capabilities, host, - timeline: { requestRippleDelete: state.runtime.requestRippleDelete }, + timeline: { + requestRippleDelete: state.runtime.requestRippleDelete, + requestSetItemAttachment: state.runtime.requestSetItemAttachment, + }, }} > diff --git a/src/features/editor/host/runtime.ts b/src/features/editor/host/runtime.ts index 1128616ee..02a4839e8 100644 --- a/src/features/editor/host/runtime.ts +++ b/src/features/editor/host/runtime.ts @@ -107,6 +107,20 @@ export class EmbeddedEditorHostRuntime implements EmbeddedEditorHostRuntimeContr } } + readonly requestSetItemAttachment = async ( + itemIds: readonly string[], + rippleLinked: boolean, + ): Promise => { + try { + await this.controller.requestSetItemAttachment(itemIds, rippleLinked) + } catch (error) { + this.host.notify?.({ + kind: 'error', + message: error instanceof Error ? error.message : 'Attachment change submission failed', + }) + } + } + /** * The host serves cross-origin media URLs, which rules out the Web Audio * clip graph (non-CORS cross-origin resources are silenced through diff --git a/src/features/project-bundle/schemas/project-schema.ts b/src/features/project-bundle/schemas/project-schema.ts index 64f877cd0..383acabf3 100644 --- a/src/features/project-bundle/schemas/project-schema.ts +++ b/src/features/project-bundle/schemas/project-schema.ts @@ -461,6 +461,7 @@ const timelineItemSchema = z mediaId: z.string().optional(), originId: z.string().optional(), linkedGroupId: z.string().optional(), + rippleLinked: z.boolean().optional(), type: itemTypeSchema, // Source fields src: z.string().optional(), diff --git a/src/features/timeline/components/timeline-item/index.tsx b/src/features/timeline/components/timeline-item/index.tsx index 2a8dfe158..a047ab1d4 100644 --- a/src/features/timeline/components/timeline-item/index.tsx +++ b/src/features/timeline/components/timeline-item/index.tsx @@ -726,6 +726,7 @@ export const TimelineItem = memo(function TimelineItem({ handleJoinRight, handleDelete, handleRippleDelete, + handleToggleAttachment, handleLinkSelected, handleUnlinkSelected, handleReverseSelected, @@ -993,6 +994,10 @@ export const TimelineItem = memo(function TimelineItem({ onRippleDelete: handleRippleDelete, onDelete: handleDelete, }} + attachmentActions={{ + isRippleLinked: item.rippleLinked !== false, + onToggle: handleToggleAttachment, + }} >
{ }) }) +describe('ItemContextMenu sequence attachment', () => { + it('offers detach and reattach toggles', () => { + const onToggle = vi.fn() + renderContextMenu({ attachmentActions: { isRippleLinked: true, onToggle } }) + fireEvent.click(screen.getByRole('button', { name: 'Detach from sequence' })) + expect(onToggle).toHaveBeenCalledTimes(1) + cleanup() + + // The menu stays mounted after activation; rerendering is represented by + // the same component contract with the detached state. + renderContextMenu({ attachmentActions: { isRippleLinked: false, onToggle } }) + fireEvent.click(screen.getByRole('button', { name: 'Reattach to sequence' })) + expect(onToggle).toHaveBeenCalledTimes(2) + }) +}) + describe('ItemContextMenu captions', () => { it('shows a single "Generate Captions" item when no transcript exists', () => { const onOpenCaptionDialog = vi.fn() diff --git a/src/features/timeline/components/timeline-item/item-context-menu.tsx b/src/features/timeline/components/timeline-item/item-context-menu.tsx index 9c82b05ee..295cb0966 100644 --- a/src/features/timeline/components/timeline-item/item-context-menu.tsx +++ b/src/features/timeline/components/timeline-item/item-context-menu.tsx @@ -117,12 +117,18 @@ type DestructiveActionsProps = ItemContextMenuSectionProps & { onDelete: () => void } +type AttachmentActionsProps = ItemContextMenuSectionProps & { + isRippleLinked: boolean + onToggle: () => void +} + type JoinActionsConfig = Omit type LinkActionsConfig = Omit type MediaActionsConfig = Omit type CaptionActionsConfig = Omit type CompositionActionsConfig = Omit type DestructiveActionsConfig = Omit +type AttachmentActionsConfig = Omit type KeyframeActionsConfig = Omit< KeyframeActionsProps, @@ -145,6 +151,7 @@ interface ItemContextMenuProps { trackLocked: boolean joinActions: JoinActionsConfig destructiveActions: DestructiveActionsConfig + attachmentActions?: AttachmentActionsConfig linkActions?: LinkActionsConfig keyframeActions?: KeyframeActionsConfig layoutActions?: LayoutActionsConfig @@ -168,6 +175,7 @@ export const ItemContextMenu = memo(function ItemContextMenu({ trackLocked, joinActions, destructiveActions, + attachmentActions, linkActions, keyframeActions, layoutActions, @@ -202,6 +210,7 @@ export const ItemContextMenu = memo(function ItemContextMenu({ trackLocked={trackLocked} joinActions={joinActions} destructiveActions={destructiveActions} + attachmentActions={attachmentActions} linkActions={linkActions} keyframeActions={keyframeActions} layoutActions={layoutActions} @@ -254,6 +263,7 @@ const ItemContextMenuFull = memo(function ItemContextMenuFull({ trackLocked, joinActions, destructiveActions, + attachmentActions, linkActions, keyframeActions, layoutActions, @@ -343,6 +353,13 @@ const ItemContextMenuFull = memo(function ItemContextMenuFull({ canRippleDelete={!hostMode} canDelete={canDeleteItem} /> + {attachmentActions && ( + + {attachmentActions.isRippleLinked + ? t('timeline.contextMenu.detachFromSequence', 'Detach from sequence') + : t('timeline.contextMenu.reattachToSequence', 'Reattach to sequence')} + + )} ) diff --git a/src/features/timeline/components/timeline-item/use-timeline-item-actions.ts b/src/features/timeline/components/timeline-item/use-timeline-item-actions.ts index 54a042a38..1fa39df0e 100644 --- a/src/features/timeline/components/timeline-item/use-timeline-item-actions.ts +++ b/src/features/timeline/components/timeline-item/use-timeline-item-actions.ts @@ -59,6 +59,7 @@ import { DEFAULT_FILLER_REMOVAL_SETTINGS, } from '../../utils/filler-word-removal-preview' import { mapSceneCutTimesToTimelineFrames } from '../../utils/scene-cut-frames' +import { setItemAttachment } from '../../stores/actions/item-actions' const logger = createLogger('UseTimelineItemActions') @@ -175,6 +176,19 @@ export function useTimelineItemActions({ } }, []) + const handleToggleAttachment = useCallback(() => { + const selectedItemIds = useSelectionStore.getState().selectedItemIds + const ids = selectedItemIds.length > 0 ? selectedItemIds : [item.id] + const nextValue = item.rippleLinked === false + if (editorMode === 'host') { + if (hostTimeline?.requestSetItemAttachment) + void hostTimeline.requestSetItemAttachment(ids, nextValue) + else host?.notify?.({ kind: 'unsupported', message: 'Attachment changes are unavailable' }) + return + } + setItemAttachment(ids, nextValue) + }, [editorMode, host, hostTimeline, item.id, item.rippleLinked]) + const handleLinkSelected = useCallback(() => { const selectedItemIds = useSelectionStore.getState().selectedItemIds void linkItems(selectedItemIds) @@ -607,6 +621,7 @@ export function useTimelineItemActions({ handleJoinRight, handleDelete, handleRippleDelete, + handleToggleAttachment, handleLinkSelected, handleUnlinkSelected, handleReverseSelected, diff --git a/src/features/timeline/hooks/use-timeline-drag.ts b/src/features/timeline/hooks/use-timeline-drag.ts index d450d8595..fe89e27de 100644 --- a/src/features/timeline/hooks/use-timeline-drag.ts +++ b/src/features/timeline/hooks/use-timeline-drag.ts @@ -34,6 +34,7 @@ import { createLogger } from '@/shared/logging/logger' import { createRafCoalescedCallback } from '../utils/raf-coalesced-callback' import { resolveEffectiveTrackStates } from '../utils/group-utils' import { suppressPostTimelineGestureClick } from '../components/timeline-item/post-drag-click-guard' +import { resolveAttachedChain } from '../utils/attached-chain' const logger = createLogger('TimelineDrag') @@ -542,7 +543,8 @@ function resolveDraggedItemStates( ? expandSelectionWithLinkedItems(allItems, currentSelectedIds) : currentSelectedIds : linkedIds - const itemsToDrag = expandItemIdsWithAttachedCaptions(allItems, baseItemsToDrag) + const attachedBaseIds = baseItemsToDrag.flatMap((id) => resolveAttachedChain(allItems, id)) + const itemsToDrag = expandItemIdsWithAttachedCaptions(allItems, [...new Set(attachedBaseIds)]) const unlockedItemIds = filterUnlockedItemIds( allItems, resolveEffectiveTrackStates(currentTracks), diff --git a/src/features/timeline/hooks/use-timeline-trim.ts b/src/features/timeline/hooks/use-timeline-trim.ts index 2ca8a3016..f08282a3b 100644 --- a/src/features/timeline/hooks/use-timeline-trim.ts +++ b/src/features/timeline/hooks/use-timeline-trim.ts @@ -55,6 +55,7 @@ import { } from '../utils/trim-edit-constraints' import { getTransitionBridgeAtHandle } from '../utils/transition-edit-guards' import { createRafCoalescedCallback } from '../utils/raf-coalesced-callback' +import { resolveAttachedRippleTail } from '../utils/attached-chain' interface TrimState { isTrimming: boolean @@ -105,12 +106,14 @@ function getRippleDownstreamItemIds( ): Set { const currentEnd = currentItem.from + currentItem.durationInFrames const downstreamItemIds = new Set() + const attachedIds = new Set(resolveAttachedRippleTail(allItems, currentItem.id)) for (const other of allItems) { if ( other.id !== currentItem.id && other.trackId === currentItem.trackId && - other.from >= currentEnd + other.from >= currentEnd && + attachedIds.has(other.id) ) { downstreamItemIds.add(other.id) } diff --git a/src/features/timeline/stores/actions/edit/trim-actions.ts b/src/features/timeline/stores/actions/edit/trim-actions.ts index 2951217d6..4b21a251e 100644 --- a/src/features/timeline/stores/actions/edit/trim-actions.ts +++ b/src/features/timeline/stores/actions/edit/trim-actions.ts @@ -38,6 +38,7 @@ import { requestPostEditWarmForItems, } from './shared' import type { TimelineItem, TimelineTrack } from '@/types/timeline' +import { resolveAttachedRippleTail } from '../../../utils/attached-chain' function keepTightestDelta(requested: number, candidate: number): number { return requested < 0 ? Math.max(requested, candidate) : Math.min(requested, candidate) @@ -230,6 +231,9 @@ function addRippleTrimDownstreamMutationIds(params: { mutationIds: Set }): void { const transitions = useTransitionsStore.getState().transitions + const attachedIds = new Set( + params.synced.flatMap((syncedItem) => resolveAttachedRippleTail(params.items, syncedItem.id)), + ) for (const syncedItem of params.synced) { const oldSyncedEnd = syncedItem.from + syncedItem.durationInFrames const transitionNeighbors = new Set( @@ -239,7 +243,10 @@ function addRippleTrimDownstreamMutationIds(params: { ) for (const candidate of params.items) { if (params.syncedIds.has(candidate.id) || candidate.trackId !== syncedItem.trackId) continue - if (candidate.from >= oldSyncedEnd || transitionNeighbors.has(candidate.id)) { + if ( + attachedIds.has(candidate.id) && + (candidate.from >= oldSyncedEnd || transitionNeighbors.has(candidate.id)) + ) { params.mutationIds.add(candidate.id) } } @@ -702,6 +709,9 @@ export function rippleTrimItem(id: string, handle: 'start' | 'end', trimDelta: n if (clampedTrimDelta === 0) return const oldFrom = item.from const oldEnd = item.from + item.durationInFrames + const attachedIds = new Set( + synced.flatMap((syncedItem) => resolveAttachedRippleTail(store.items, syncedItem.id)), + ) if (handle === 'start') store._trimItemStart(id, clampedTrimDelta, { skipAdjacentClamp: true }) else store._trimItemEnd(id, clampedTrimDelta, { skipAdjacentClamp: true }) @@ -749,7 +759,12 @@ export function rippleTrimItem(id: string, handle: 'start' | 'end', trimDelta: n if (transition.leftClipId === syncedItem.id) neighbors.add(transition.rightClipId) } for (const candidate of fresh) { - if (syncedIds.has(candidate.id) || candidate.trackId !== before.trackId) continue + if ( + syncedIds.has(candidate.id) || + candidate.trackId !== before.trackId || + !attachedIds.has(candidate.id) + ) + continue if (candidate.from >= oldSyncedEnd || neighbors.has(candidate.id)) deltas.set(candidate.id, shift) } diff --git a/src/features/timeline/stores/actions/item-actions.ts b/src/features/timeline/stores/actions/item-actions.ts index 245cea6b4..3332c3f9d 100644 --- a/src/features/timeline/stores/actions/item-actions.ts +++ b/src/features/timeline/stores/actions/item-actions.ts @@ -56,6 +56,7 @@ import { isTimelineTrackLocked, partitionItemMutationIdsByLock, } from '../../utils/track-lock-invariants' +import { resolveAttachedChain, resolveAttachedRippleTail } from '../../utils/attached-chain' const LOCK_PROTECTED_ITEM_FIELDS = new Set([ 'from', @@ -118,6 +119,34 @@ function areMoveUpdatesUnlocked( ) } +function expandMoveUpdatesWithAttachedChain( + updates: Array<{ id: string; from: number; trackId?: string }>, +): Array<{ id: string; from: number; trackId?: string }> { + const items = useItemsStore.getState().items + const byId = new Map(items.map((item) => [item.id, item])) + const result = new Map() + for (const update of updates) { + const anchor = byId.get(update.id) + if (!anchor) continue + const delta = update.from - anchor.from + for (const id of resolveAttachedChain(items, update.id)) { + const attached = byId.get(id) + if (!attached || result.has(id)) continue + result.set(id, { + id, + from: attached.from + delta, + ...(id === update.id && update.trackId ? { trackId: update.trackId } : {}), + }) + } + } + for (const update of updates) { + // Explicit cohort/selection assignments (including destination tracks) + // always win over an implicit attachment expansion. + result.set(update.id, { ...update, from: update.from }) + } + return [...result.values()] +} + function pruneLayerGroupsAfterItemRemoval(): void { const store = useItemsStore.getState() const nextTracks = pruneEmptyLayerGroupHierarchy(store.tracks, store.items) @@ -1041,7 +1070,12 @@ function buildBaseRippleShifts( for (const item of remainingItems) { let shiftAmount = 0 for (const deletedItem of deletedItems) { + const chainIds = resolveAttachedRippleTail( + [...remainingItems, ...deletedItems], + deletedItem.id, + ) if ( + chainIds.includes(item.id) && deletedItem.trackId === item.trackId && deletedItem.from + deletedItem.durationInFrames <= item.from ) { @@ -1371,32 +1405,36 @@ export function trackPushItems(anchorId: string, delta: number): void { export function moveItem(id: string, newFrom: number, newTrackId?: string): void { const item = useItemsStore.getState().itemById[id] if (!item) return - if (!areMoveUpdatesUnlocked([{ id, from: newFrom, trackId: newTrackId }])) return + const expandedUpdates = expandMoveUpdatesWithAttachedChain([ + { id, from: newFrom, trackId: newTrackId }, + ]) + if (!areMoveUpdatesUnlocked(expandedUpdates)) return execute( 'MOVE_ITEM', () => { - useItemsStore.getState()._moveItem(id, newFrom, newTrackId) + useItemsStore.getState()._moveItems(expandedUpdates) // Repair transitions - applyTransitionRepairs([id]) + applyTransitionRepairs(expandedUpdates.map((update) => update.id)) useTimelineSettingsStore.getState().markDirty() warnIfOverlapping('MOVE_ITEM') }, - { id, newFrom, newTrackId }, + { id, newFrom, newTrackId, attachedCount: expandedUpdates.length }, ) } export function moveItems(updates: Array<{ id: string; from: number; trackId?: string }>): void { - if (!areMoveUpdatesUnlocked(updates)) return + const expandedUpdates = expandMoveUpdatesWithAttachedChain(updates) + if (!areMoveUpdatesUnlocked(expandedUpdates)) return execute( 'MOVE_ITEMS', () => { - useItemsStore.getState()._moveItems(updates) + useItemsStore.getState()._moveItems(expandedUpdates) - const movedItemIds = new Set(updates.map((u) => u.id)) + const movedItemIds = new Set(expandedUpdates.map((u) => u.id)) const items = useItemsStore.getState().items const transitions = useTransitionsStore.getState().transitions @@ -1419,12 +1457,12 @@ export function moveItems(updates: Array<{ id: string; from: number; trackId?: s // Apply updated transitions (with trackId fixes) then repair useTransitionsStore.getState().setTransitions(updatedTransitions) - applyTransitionRepairs(updates.map((u) => u.id)) + applyTransitionRepairs(expandedUpdates.map((u) => u.id)) useTimelineSettingsStore.getState().markDirty() warnIfOverlapping('MOVE_ITEMS') }, - { count: updates.length }, + { count: expandedUpdates.length }, ) } @@ -1432,15 +1470,16 @@ export function moveItemsWithTrackChanges( tracks: TimelineTrack[], updates: Array<{ id: string; from: number; trackId?: string }>, ): void { - if (!areMoveUpdatesUnlocked(updates, tracks)) return + const expandedUpdates = expandMoveUpdatesWithAttachedChain(updates) + if (!areMoveUpdatesUnlocked(expandedUpdates, tracks)) return execute( 'MOVE_ITEMS_WITH_TRACKS', () => { useItemsStore.getState().setTracks(tracks) - useItemsStore.getState()._moveItems(updates) + useItemsStore.getState()._moveItems(expandedUpdates) - const movedItemIds = new Set(updates.map((u) => u.id)) + const movedItemIds = new Set(expandedUpdates.map((u) => u.id)) const items = useItemsStore.getState().items const transitions = useTransitionsStore.getState().transitions @@ -1460,11 +1499,25 @@ export function moveItemsWithTrackChanges( }) useTransitionsStore.getState().setTransitions(updatedTransitions) - applyTransitionRepairs(updates.map((u) => u.id)) + applyTransitionRepairs(expandedUpdates.map((u) => u.id)) useTimelineSettingsStore.getState().markDirty() warnIfOverlapping('MOVE_ITEMS_WITH_TRACKS') }, - { count: updates.length, trackCount: tracks.length }, + { count: expandedUpdates.length, trackCount: tracks.length }, + ) +} + +export function setItemAttachment(itemIds: readonly string[], rippleLinked: boolean): void { + const ids = [...new Set(itemIds)] + if (ids.length === 0 || !areItemMutationsUnlocked(ids)) return + execute( + 'SET_ITEM_ATTACHMENT', + () => { + const store = useItemsStore.getState() + for (const id of ids) store._updateItem(id, { rippleLinked }) + useTimelineSettingsStore.getState().markDirty() + }, + { itemIds: ids, rippleLinked }, ) } diff --git a/src/features/timeline/stores/timeline-store-facade.ts b/src/features/timeline/stores/timeline-store-facade.ts index 4379c0459..e1e2f2429 100644 --- a/src/features/timeline/stores/timeline-store-facade.ts +++ b/src/features/timeline/stores/timeline-store-facade.ts @@ -126,6 +126,7 @@ function getSnapshot(): TimelineState & TimelineActions { moveItem: timelineActions.moveItem, moveItems: timelineActions.moveItems, moveItemsWithTrackChanges: timelineActions.moveItemsWithTrackChanges, + setItemAttachment: timelineActions.setItemAttachment, duplicateItems: timelineActions.duplicateItems, duplicateItemsWithTrackChanges: timelineActions.duplicateItemsWithTrackChanges, trimItemStart: timelineActions.trimItemStart, diff --git a/src/features/timeline/types.ts b/src/features/timeline/types.ts index e4cc36f90..2a4ffeacc 100644 --- a/src/features/timeline/types.ts +++ b/src/features/timeline/types.ts @@ -84,6 +84,7 @@ export interface TimelineActions { tracks: TimelineTrack[], updates: Array<{ id: string; from: number; trackId?: string }>, ) => void + setItemAttachment: (itemIds: readonly string[], rippleLinked: boolean) => void duplicateItems: (itemIds: string[], positions: Array<{ from: number; trackId: string }>) => void duplicateItemsWithTrackChanges: ( tracks: TimelineTrack[], diff --git a/src/features/timeline/utils/attached-chain.test.ts b/src/features/timeline/utils/attached-chain.test.ts new file mode 100644 index 000000000..2ef05db10 --- /dev/null +++ b/src/features/timeline/utils/attached-chain.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vite-plus/test' +import type { VideoItem } from '@/types/timeline' +import { buildAttachedMoveUpdates, resolveAttachedChain } from './attached-chain' + +function clip( + id: string, + from: number, + durationInFrames: number, + options: Partial = {}, +): VideoItem { + return { + type: 'video', + id, + trackId: options.trackId ?? 'v1', + from, + durationInFrames, + label: id, + mediaId: id, + src: '', + ...options, + } +} + +describe('attached sequence chains', () => { + it('resolves only touching forward items and preserves gaps', () => { + const items = [clip('a', 0, 10), clip('b', 10, 5), clip('gap', 20, 5), clip('tail', 25, 5)] + expect(resolveAttachedChain(items, 'a')).toEqual(['a', 'b']) + expect(buildAttachedMoveUpdates(items, 'a', 4)).toEqual([ + { id: 'a', from: 4 }, + { id: 'b', from: 14 }, + ]) + }) + + it('treats false as a hard break while missing metadata stays attached', () => { + const items = [clip('a', 0, 10), clip('b', 10, 5, { rippleLinked: false }), clip('c', 15, 5)] + expect(resolveAttachedChain(items, 'a')).toEqual(['a']) + expect(resolveAttachedChain(items, 'b')).toEqual(['b']) + }) + + it('moves linked A/V cohorts with the sequence anchor', () => { + const items = [ + clip('video-a', 0, 10, { linkedGroupId: 'g' }), + clip('audio-a', 0, 10, { trackId: 'a1', linkedGroupId: 'g' }), + clip('video-b', 10, 5), + ] + expect(new Set(resolveAttachedChain(items, 'video-a'))).toEqual( + new Set(['video-a', 'audio-a', 'video-b']), + ) + }) +}) diff --git a/src/features/timeline/utils/attached-chain.ts b/src/features/timeline/utils/attached-chain.ts new file mode 100644 index 000000000..c75e0b0d2 --- /dev/null +++ b/src/features/timeline/utils/attached-chain.ts @@ -0,0 +1,115 @@ +import type { TimelineItem } from '@/types/timeline' +import { getLinkedItems } from './linked-items' + +/** Missing attachment metadata is intentionally attached for compatibility. */ +export function isRippleLinked(item: Pick): boolean { + return item.rippleLinked !== false +} + +/** + * Resolve the forward attachment chain for an anchor. A chain advances only + * across an exact touching boundary on the same lane. Linked A/V cohorts are + * added as a unit, but linkedGroupId never creates sequence attachments by + * itself. A false rippleLinked value is a hard break. + */ +export function resolveAttachedChain(items: readonly TimelineItem[], anchorId: string): string[] { + const byId = new Map(items.map((item) => [item.id, item])) + const anchor = byId.get(anchorId) + if (!anchor) return [] + + const result: string[] = [] + const included = new Set() + const queue: TimelineItem[] = [anchor] + + while (queue.length > 0) { + const current = queue.shift()! + if (included.has(current.id)) continue + included.add(current.id) + result.push(current.id) + + for (const cohortItem of getLinkedItems([...items], current.id)) { + if (!included.has(cohortItem.id)) queue.push(cohortItem) + } + // An explicit break stops the sequence tail but never breaks linked A/V + // cohort synchronization itself. + if (!isRippleLinked(current)) continue + + const end = current.from + current.durationInFrames + const next = items + .filter( + (candidate) => + candidate.trackId === current.trackId && + candidate.id !== current.id && + candidate.from === end && + isRippleLinked(candidate), + ) + .sort((left, right) => left.id.localeCompare(right.id))[0] + if (next && !included.has(next.id)) queue.push(next) + } + + return result +} + +export function resolveAttachedChainItems( + items: readonly TimelineItem[], + anchorId: string, +): TimelineItem[] { + const byId = new Map(items.map((item) => [item.id, item])) + return resolveAttachedChain(items, anchorId) + .map((id) => byId.get(id)) + .filter((item): item is TimelineItem => item !== undefined) +} + +/** Ripple edits retain their historical tail behavior, but stop at a break. */ +export function resolveAttachedRippleTail( + items: readonly TimelineItem[], + anchorId: string, +): string[] { + const byId = new Map(items.map((item) => [item.id, item])) + const anchor = byId.get(anchorId) + if (!anchor) return [] + const result = new Set([anchorId]) + const queue: TimelineItem[] = [anchor] + while (queue.length) { + const current = queue.shift()! + if (!isRippleLinked(current)) continue + for (const cohort of getLinkedItems([...items], current.id)) { + if (!result.has(cohort.id)) { + result.add(cohort.id) + queue.push(cohort) + } + } + const end = current.from + current.durationInFrames + const downstream = items + .filter((candidate) => candidate.trackId === current.trackId && candidate.from >= end) + .sort((left, right) => left.from - right.from || left.id.localeCompare(right.id)) + for (const candidate of downstream) { + if (!isRippleLinked(candidate)) break + if (!result.has(candidate.id)) { + result.add(candidate.id) + queue.push(candidate) + } + } + } + return items.filter((item) => result.has(item.id)).map((item) => item.id) +} + +export function buildAttachedMoveUpdates( + items: readonly TimelineItem[], + anchorId: string, + deltaFrames: number, + trackId?: string, +): Array<{ id: string; from: number; trackId?: string }> { + const byId = new Map(items.map((item) => [item.id, item])) + return resolveAttachedChain(items, anchorId).flatMap((id) => { + const item = byId.get(id) + if (!item) return [] + return [ + { + id, + from: item.from + deltaFrames, + ...(id === anchorId && trackId ? { trackId } : {}), + }, + ] + }) +} diff --git a/src/types/project.ts b/src/types/project.ts index 6c4e44a0f..a93d8cb0d 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -85,6 +85,7 @@ export interface ProjectTimeline { mediaId?: string originId?: string // Tracks lineage for stable React keys linkedGroupId?: string + rippleLinked?: boolean type: | 'video' | 'audio' diff --git a/src/types/timeline.ts b/src/types/timeline.ts index 4fa73e6ba..ff099fa06 100644 --- a/src/types/timeline.ts +++ b/src/types/timeline.ts @@ -65,6 +65,8 @@ type BaseTimelineItem = { compositionId?: string // Reference to a sub-composition for compound wrappers originId?: string // Tracks lineage - items from same split share this for stable React keys linkedGroupId?: string // Links paired timeline items like synced video/audio companions + /** Sequence attachment; omitted means attached, false is an explicit break. */ + rippleLinked?: boolean // Trim properties for media items trimStart?: number // Frames trimmed from start of source media trimEnd?: number // Frames trimmed from end of source media From 76062a4f998d7cce22c35813550e5d2d7ba7ba11 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Mon, 31 Aug 2026 15:31:28 -0700 Subject: [PATCH 02/10] fix(editor): validate ripple move intent --- src/features/editor/codepress/adapter.test.ts | 18 ++++++++++++++++++ src/features/editor/codepress/contract.ts | 2 ++ 2 files changed, 20 insertions(+) diff --git a/src/features/editor/codepress/adapter.test.ts b/src/features/editor/codepress/adapter.test.ts index 4747732cb..a5d283ee3 100644 --- a/src/features/editor/codepress/adapter.test.ts +++ b/src/features/editor/codepress/adapter.test.ts @@ -152,6 +152,24 @@ describe('PR1 conformance fixtures', () => { }) }) + it('rejects a non-boolean ripple move intent', () => { + const fixture = readFixture('valid/core-edit-batch.json') + const request = structuredClone(fixture.request) as unknown as Record + request.commands = [ + { + command_id: 'invalid-ripple-move', + type: 'move_item', + item_id: 'clip-a', + to_track_id: 'track-video', + timeline_start_us: 1_000_000, + index: 0, + ripple: 'true', + }, + ] + + expect(validateCommandBatch(request).ok).toBe(false) + }) + it.each(['errors/revision-conflict.json', 'errors/idempotency-conflict.json'])( 'keeps the canonical structured error shape for %s', (path) => { diff --git a/src/features/editor/codepress/contract.ts b/src/features/editor/codepress/contract.ts index f475d3a18..e75fbca36 100644 --- a/src/features/editor/codepress/contract.ts +++ b/src/features/editor/codepress/contract.ts @@ -1177,6 +1177,8 @@ function validateCommand(value: unknown, path: string, errors: VideoCommandError checkIdentifier(value.to_track_id, `${path}.to_track_id`, errors) checkMicroseconds(value.timeline_start_us, `${path}.timeline_start_us`, errors) checkIndex(value.index, `${path}.index`, errors) + if (value.ripple !== undefined && typeof value.ripple !== 'boolean') + errors.push(invalidRequest(`${path}.ripple`, 'must be a boolean')) break case 'set_item_attachment': if (!checkArray(value.item_ids, `${path}.item_ids`, errors)) return From 827082b4ba9473360411bf0f6bcfaad95f42e536 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Mon, 31 Aug 2026 16:44:15 -0700 Subject: [PATCH 03/10] fix(media): restore selected-delete accessible name --- src/features/media-library/components/media-library.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/media-library/components/media-library.tsx b/src/features/media-library/components/media-library.tsx index 4bc935d72..5056c59f9 100644 --- a/src/features/media-library/components/media-library.tsx +++ b/src/features/media-library/components/media-library.tsx @@ -820,6 +820,7 @@ export const MediaLibrary = memo(function MediaLibrary({ onMediaSelect }: MediaL