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/scripts/fallow-unused-class-members.allowlist.json b/scripts/fallow-unused-class-members.allowlist.json index 9da914322..a7cf4d483 100644 --- a/scripts/fallow-unused-class-members.allowlist.json +++ b/scripts/fallow-unused-class-members.allowlist.json @@ -40,20 +40,10 @@ "memberName": "getLastFailureKind", "kind": "class_method" }, - { - "parentName": "VideoFrameExtractor", - "memberName": "init", - "kind": "class_method" - }, { "parentName": "VideoFrameExtractor", "memberName": "isBatchPrewarmAvailable", "kind": "class_method" - }, - { - "parentName": "VideoFrameExtractor", - "memberName": "prewarmBatch", - "kind": "class_method" } ] }, @@ -339,11 +329,6 @@ "memberName": "isFrameInRange", "kind": "class_method" }, - { - "parentName": "Clock", - "memberName": "isPlaying", - "kind": "class_method" - }, { "parentName": "Clock", "memberName": "loop", @@ -365,22 +350,11 @@ "memberName": "outFrame", "kind": "class_method" }, - { - "parentName": "Clock", - "memberName": "playbackRate", - "kind": "class_method", - "count": 2 - }, { "parentName": "Clock", "memberName": "seekToTime", "kind": "class_method" }, - { - "parentName": "Clock", - "memberName": "setAudioContext", - "kind": "class_method" - }, { "parentName": "Clock", "memberName": "setInPoint", diff --git a/scripts/fallow-unused-exports.allowlist.json b/scripts/fallow-unused-exports.allowlist.json index 6edcc961b..5bab86b97 100644 --- a/scripts/fallow-unused-exports.allowlist.json +++ b/scripts/fallow-unused-exports.allowlist.json @@ -245,16 +245,6 @@ "exportName": "createAudioBuffer", "reason": "Called through the canvas-audio namespace from export rendering." }, - { - "path": "src/features/export/utils/canvas-audio.ts", - "exportName": "hasAudioContent", - "reason": "Called through the canvas-audio namespace from export rendering." - }, - { - "path": "src/features/export/utils/canvas-render-orchestrator.ts", - "exportName": "renderSingleFrame", - "reason": "Dynamically imported by thumbnail and timeline persistence flows." - }, { "path": "src/features/keyframes/utils/easing-presets.ts", "exportName": "areBezierPointsEqual", @@ -305,21 +295,11 @@ "exportName": "PARAKEET_MODEL", "reason": "unreviewed: carried over by the 2026-07-09 re-baseline; trace individually before deleting." }, - { - "path": "src/features/media-library/transcription/transcription-engine.ts", - "exportName": "PARAKEET_SUPPORTED_LANGUAGES", - "reason": "unreviewed: carried over by the 2026-07-09 re-baseline; trace individually before deleting." - }, { "path": "src/features/preview/components/color-scopes-monitor.tsx", "exportName": "ColorScopesMonitor", "reason": "Lazy-loaded from the preview area." }, - { - "path": "src/features/preview/components/inline-source-preview.tsx", - "exportName": "InlineSourcePreview", - "reason": "Lazy-loaded from the preview area." - }, { "path": "src/features/preview/deps/media-library-contract.ts", "exportName": "cleanupBlobUrls", 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/adapter.test.ts b/src/features/editor/codepress/adapter.test.ts index 4747732cb..d38ae6d14 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) => { @@ -180,6 +198,71 @@ describe('deterministic microsecond/frame conversion', () => { expect(() => assertFrameAligned(33_334, 30)).toThrow(FrameTimingError) }) + it('keeps ripple-moved touching clips frame-aligned across rounded endpoints', () => { + const frame = (value: number) => framesToMicroseconds(value, 30) + const items = [ + clip({ + item_id: 'clip-one', + timeline_start_us: frame(10), + timeline_end_us: frame(50), + }), + clip({ + item_id: 'clip-two', + timeline_start_us: frame(50), + timeline_end_us: frame(90), + }), + clip({ + item_id: 'clip-three', + timeline_start_us: frame(90), + timeline_end_us: frame(130), + }), + ] + const adapter = new CodePressCommandAdapter({ + document: documentFor( + timeline({ + duration_us: frame(180), + tracks: [ + { + track_id: 'track-video', + kind: 'video', + name: 'Video', + locked: false, + muted: false, + items, + }, + ], + }), + ), + }) + + const result = applyRequest(adapter, { + contract_version: 1, + timeline_id: 'timeline-test', + operation_id: 'frame-aligned-ripple-move', + idempotency_key: 'frame-aligned-ripple-move:1', + base_revision: 0, + preconditions: [], + commands: [ + { + command_id: 'move-attached-chain', + type: 'move_item', + item_id: 'clip-one', + to_track_id: 'track-video', + timeline_start_us: frame(26), + index: 0, + ripple: true, + }, + ], + }) + + expect(result.timeline.tracks[0]?.items).toMatchObject([ + { item_id: 'clip-one', timeline_start_us: frame(26), timeline_end_us: frame(66) }, + { item_id: 'clip-two', timeline_start_us: frame(66), timeline_end_us: frame(106) }, + { item_id: 'clip-three', timeline_start_us: frame(106), timeline_end_us: frame(146) }, + ]) + expect(validateTimelineState(result.timeline).ok).toBe(true) + }) + it('translates every public timestamp in a batch before application', () => { const fixture = readFixture('valid/core-edit-batch.json') const translated = translateCommandBatchToFrames(fixture.request, 30) diff --git a/src/features/editor/codepress/attached-chain.ts b/src/features/editor/codepress/attached-chain.ts new file mode 100644 index 000000000..b4151ab82 --- /dev/null +++ b/src/features/editor/codepress/attached-chain.ts @@ -0,0 +1,67 @@ +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 +} + +function enqueueLinkedCohort( + items: readonly TimelineItem[], + current: TimelineItem, + seen: ReadonlySet, + queue: TimelineItem[], +): void { + if (current.item_type === 'caption_cue' || !current.linked_group_id) return + for (const cohort of items) { + if (cohort.linked_group_id === current.linked_group_id && !seen.has(itemId(cohort))) + queue.push(cohort) + } +} + +function findTouchingAttachedItem( + items: readonly TimelineItem[], + current: TimelineItem, +): TimelineItem | undefined { + const currentId = itemId(current) + return items.find( + (candidate) => + candidate.track_id === current.track_id && + itemId(candidate) !== currentId && + start(candidate) === end(current) && + attached(candidate), + ) +} + +/** 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) + enqueueLinkedCohort(items, current, seen, queue) + if (!attached(current)) continue + const next = findTouchingAttachedItem(items, current) + 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..f3f1a1271 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 } @@ -831,6 +846,20 @@ function checkArray(value: unknown, path: string, errors: VideoCommandError[]): return true } +function checkUniqueIdentifiers( + values: readonly unknown[], + path: string, + errors: VideoCommandError[], +): void { + const ids = new Set() + for (const [index, id] of values.entries()) { + const itemPath = `${path}[${index}]` + if (!checkIdentifier(id, itemPath, errors)) continue + if (ids.has(id)) errors.push(invalidRequest(itemPath, 'must be unique')) + ids.add(id) + } +} + function checkTrackKind(value: unknown): value is TrackKind { return typeof value === 'string' && (TRACK_KINDS as readonly string[]).includes(value) } @@ -1162,6 +1191,16 @@ 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 + if (value.item_ids.length === 0) + errors.push(invalidRequest(`${path}.item_ids`, 'must not be empty')) + checkUniqueIdentifiers(value.item_ids, `${path}.item_ids`, errors) + 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) @@ -1183,16 +1222,8 @@ function validateCommand(value: unknown, path: string, errors: VideoCommandError checkInterval(value.start_us, value.end_us, path, errors) if (value.track_ids !== null && !checkArray(value.track_ids, `${path}.track_ids`, errors)) return - if (Array.isArray(value.track_ids)) { - const ids = new Set() - for (const [index, id] of value.track_ids.entries()) { - if (checkIdentifier(id, `${path}.track_ids[${index}]`, errors)) { - if (ids.has(id)) - errors.push(invalidRequest(`${path}.track_ids[${index}]`, 'must be unique')) - ids.add(id) - } - } - } + if (Array.isArray(value.track_ids)) + checkUniqueIdentifiers(value.track_ids, `${path}.track_ids`, errors) break case 'add_track': checkIndex(value.index, `${path}.index`, errors) @@ -1291,16 +1322,8 @@ function validateCommand(value: unknown, path: string, errors: VideoCommandError break case 'remove_caption_cues': checkIdentifier(value.track_id, `${path}.track_id`, errors) - if (checkArray(value.cue_ids, `${path}.cue_ids`, errors)) { - const ids = new Set() - for (const [index, id] of value.cue_ids.entries()) { - if (checkIdentifier(id, `${path}.cue_ids[${index}]`, errors)) { - if (ids.has(id)) - errors.push(invalidRequest(`${path}.cue_ids[${index}]`, 'must be unique')) - ids.add(id) - } - } - } + if (checkArray(value.cue_ids, `${path}.cue_ids`, errors)) + checkUniqueIdentifiers(value.cue_ids, `${path}.cue_ids`, errors) break case 'set_item_properties': checkIdentifier(value.item_id, `${path}.item_id`, errors) 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..0c6a3b9d1 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: @@ -215,6 +216,11 @@ function setItemFramePosition( ) } +function moveItemToFrame(item: TimelineItem, startFrame: number, fps: FrameRateLike): TimelineItem { + const range = frameRangeFor(item, fps) + return setItemFramePosition(item, startFrame, startFrame + range.end - range.start, fps) +} + function shiftItemByFrames( item: TimelineItem, deltaFrames: number, @@ -378,6 +384,7 @@ function applyAddText(timeline: MutableTimeline, command: AddTextCommand): Comma function applyDuplicate( timeline: MutableTimeline, command: Extract, + fps: FrameRateLike, ): CommandEffect { const source = findItem(timeline, command.item_id, command.command_id).item const target = findTrack(timeline, command.to_track_id, command.command_id) @@ -385,11 +392,7 @@ function applyDuplicate( const duplicate = cloneItem(source) const sourceStart = itemStart(source) const targetStart = command.timeline_start_us ?? sourceStart - const shifted = setItemPosition( - duplicate, - targetStart, - targetStart + (itemEnd(source) - sourceStart), - ) + const shifted = moveItemToFrame(duplicate, assertFrameAligned(targetStart, fps), fps) const withId = shifted.item_type === 'caption_cue' ? { ...shifted, cue_id: command.new_item_id, track_id: target.track_id } @@ -413,15 +416,40 @@ function applyRemoveItem( return { ...emptyEffect(), deleted_item_ids: [command.item_id] } } -function applyMoveItem(timeline: MutableTimeline, command: MoveItemCommand): CommandEffect { +function applyMoveItem( + timeline: MutableTimeline, + command: MoveItemCommand, + fps: FrameRateLike, +): CommandEffect { 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) - const oldStart = itemStart(located.item) - const moved = setItemPosition( + if (command.ripple) { + const chainIds = resolveAttachedChainIds(timeline, command.item_id) + const anchorStartFrame = frameRangeFor(located.item, fps).start + const targetStartFrame = assertFrameAligned(command.timeline_start_us, fps) + const deltaFrames = targetStartFrame - anchorStartFrame + const moved = applyMoveItem(timeline, { ...command, ripple: undefined }, fps) + for (const id of chainIds) { + if (id === command.item_id) continue + const member = findItem(timeline, id, command.command_id) + const range = frameRangeFor(member.item, fps) + setItemAt( + timeline, + member, + setItemFramePosition(member.item, range.start + deltaFrames, range.end + deltaFrames, fps), + ) + } + return { + ...moved, + moved_item_ids: chainIds, + updated_item_ids: chainIds, + } + } + const moved = moveItemToFrame( setItemTrack(located.item, target.track_id), - command.timeline_start_us, - command.timeline_start_us + itemEnd(located.item) - oldStart, + assertFrameAligned(command.timeline_start_us, fps), + fps, ) const sourceTrack = timeline.tracks[located.trackIndex]! const sourceItems = sourceTrack.items.filter((_, index) => index !== located.itemIndex) @@ -436,6 +464,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, @@ -832,12 +885,14 @@ function applyCommand( case 'duplicate_item': if (command.timeline_start_us !== undefined) assertFrameAligned(command.timeline_start_us, fps) - return applyDuplicate(timeline, command) + return applyDuplicate(timeline, command, fps) case 'remove_item': return applyRemoveItem(timeline, command) case 'move_item': assertFrameAligned(command.timeline_start_us, fps) - return applyMoveItem(timeline, command) + return applyMoveItem(timeline, command, fps) + 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/components/properties-sidebar/clip-panel/text-section.tsx b/src/features/editor/components/properties-sidebar/clip-panel/text-section.tsx index a82449a76..ab8360eb6 100644 --- a/src/features/editor/components/properties-sidebar/clip-panel/text-section.tsx +++ b/src/features/editor/components/properties-sidebar/clip-panel/text-section.tsx @@ -287,11 +287,6 @@ export function TextStyleSection(props: TextSectionProps) { return } -/** Motion-text animation on its own — the Animation tab. */ -export function TextAnimationSection(props: TextSectionProps) { - return -} - /** * Style + animation together — used only for mixed (text + non-text) * selections, which keep the general Effects-tab layout. diff --git a/src/features/editor/deps/timeline-motion-contract.ts b/src/features/editor/deps/timeline-motion-contract.ts index c3ae993e1..2abd96382 100644 --- a/src/features/editor/deps/timeline-motion-contract.ts +++ b/src/features/editor/deps/timeline-motion-contract.ts @@ -13,7 +13,6 @@ export { KEYFRAME_DIAMOND_RENDERED_WIDTH_PX, ROW_HEIGHT, getAnimatablePropertiesForItem, - getEffectPropertyBaseValue, getProceduralBands, getPropertyAccordionGroups, getPropertyDisplayGroups, 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..06f9ba7d8 100644 --- a/src/features/editor/host/controller.test.ts +++ b/src/features/editor/host/controller.test.ts @@ -190,6 +190,23 @@ describe('embedded FreeCut host controller', () => { }) }) + it('floors sourceDuration at the clip source range when asset duration is unprobed', () => { + const initial = snapshot() + // Cloud/AI assets sit at durationSeconds 0 until the probe job lands. The + // native item must not get a 1-frame sourceDuration: the trim clamp would + // turn any end-extend drag into a collapse toward 1 frame. + const unprobed = { + ...initial, + assets: [{ ...initial.assets[0]!, durationSeconds: 0 }], + } + const native = hostSnapshotToNativeTimeline(unprobed) + expect(native.items[0]).toMatchObject({ + sourceStart: 0, + sourceEnd: 60, + sourceDuration: 60, + }) + }) + it('keeps caption styles and caption-role items on the host-backed native bridge', () => { const initial = snapshot({ tracks: [ @@ -330,6 +347,44 @@ describe('embedded FreeCut host controller', () => { expect(derived.batch?.commands[0]).toMatchObject({ type: 'move_item', item_id: 'text-1' }) }) + it('preserves detached regular text through native host reconciliation', () => { + const initial = snapshot({ + tracks: [ + { + id: 'text-track', + kind: 'overlay', + name: 'Text', + locked: false, + muted: false, + items: [ + { + type: 'text', + id: 'detached-text', + trackId: 'text-track', + from: 150, + durationInFrames: 30, + text: 'Detached tail', + rippleLinked: false, + }, + ], + }, + ], + }) + const native = hostSnapshotToNativeTimeline(initial) + const reconciled = nativeTimelineToFrameDocument( + { tracks: native.tracks, items: native.items, fps: native.fps }, + initial.timeline, + ) + + expect(reconciled).toMatchObject({ ok: true }) + if (!reconciled.ok) return + expect(reconciled.document.tracks[0]?.items[0]).toMatchObject({ rippleLinked: false }) + expect(deriveSupportedHostEdit(initial.timeline, reconciled.document)).toEqual({ + batch: null, + reason: NO_SUPPORTED_EDIT_REASON, + }) + }) + it.each([ ['null', null], ['absent', undefined], @@ -698,6 +753,7 @@ describe('embedded FreeCut host controller', () => { 'add_clip', 'add_text', 'move_item', + 'set_item_attachment', 'trim_item', 'split_item', 'remove_item', @@ -721,6 +777,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 +1352,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..91f8c171c 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( } } +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..6faea0b97 100644 --- a/src/features/editor/host/document.ts +++ b/src/features/editor/host/document.ts @@ -25,9 +25,17 @@ export type NativeTimelineConversionResult = | { ok: true; document: FreeCutFrameDocument } | { ok: false; failure: NativeTimelineConversionFailure } -function safeDurationFrames(asset: EmbeddedEditorAsset, fps: number): number { - if (!Number.isFinite(asset.durationSeconds) || asset.durationSeconds <= 0) return 1 - return Math.max(1, Math.round(asset.durationSeconds * fps)) +function safeDurationFrames(asset: EmbeddedEditorAsset, fps: number, sourceEnd?: number): number { + const probed = + Number.isFinite(asset.durationSeconds) && asset.durationSeconds > 0 + ? Math.max(1, Math.round(asset.durationSeconds * fps)) + : 1 + // Never let the bound undercut the source range the clip already occupies: + // an unprobed (or bogus-tiny) asset duration would otherwise surface as a + // 1-frame sourceDuration, and the trim clamp turns any end-extend drag on + // such a clip into a collapse to 1 frame. Flooring at the in-use range + // keeps the clip intact; the handle re-opens once the probe lands. + return Math.max(probed, Math.ceil(sourceEnd ?? 0), 1) } // fallow-ignore-next-line complexity @@ -157,6 +165,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 +193,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 } : {}), @@ -202,7 +212,9 @@ function nativeItemFromHostItem( } const asset = assets.get(item.mediaId) - const sourceDuration = asset ? safeDurationFrames(asset, asset.fps || 30) : undefined + const sourceDuration = asset + ? safeDurationFrames(asset, asset.fps || 30, item.sourceEnd) + : undefined const common = { id: item.id, trackId: item.trackId, @@ -212,6 +224,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 +302,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 +331,7 @@ function frameItemToNativeComparable( durationInFrames: item.durationInFrames, text: item.text, ...linkedGroupMetadata(item.linkedGroupId), + ...(item.rippleLinked !== undefined ? { rippleLinked: item.rippleLinked } : {}), ...(Object.keys(style).length > 0 ? { style } : {}), } } @@ -338,6 +353,7 @@ function frameItemToNativeComparable( durationInFrames: item.durationInFrames, text: item.text, ...linkedGroupMetadata(item.linkedGroupId), + ...(item.rippleLinked !== undefined ? { rippleLinked: item.rippleLinked } : {}), ...(Object.keys(style).length > 0 ? { style } : {}), ...(opacityOnly ? { opacity: transform.opacity } : {}), ...(transform !== undefined && !opacityOnly ? { transform } : {}), diff --git a/src/features/editor/host/editor-surface.tsx b/src/features/editor/host/editor-surface.tsx index 85785f493..be5aae1fb 100644 --- a/src/features/editor/host/editor-surface.tsx +++ b/src/features/editor/host/editor-surface.tsx @@ -126,7 +126,11 @@ export function FreeCutEditorSurface({ host }: { host: EditorHost }) { mode: 'host', capabilities, host, - timeline: { requestRippleDelete: state.runtime.requestRippleDelete }, + timeline: { + requestRippleDelete: state.runtime.requestRippleDelete, + requestSetItemAttachment: (itemIds, rippleLinked) => + state.runtime.requestSetItemAttachment(itemIds, rippleLinked), + }, }} > diff --git a/src/features/editor/host/runtime.ts b/src/features/editor/host/runtime.ts index 1128616ee..b59871659 100644 --- a/src/features/editor/host/runtime.ts +++ b/src/features/editor/host/runtime.ts @@ -107,6 +107,19 @@ export class EmbeddedEditorHostRuntime implements EmbeddedEditorHostRuntimeContr } } + // Passed through EditorHostProvider and invoked by timeline item actions. + // fallow-ignore-next-line unused-class-member + async requestSetItemAttachment(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/keyframes/deps/timeline-contract.ts b/src/features/keyframes/deps/timeline-contract.ts index 9b3d1a884..493196078 100644 --- a/src/features/keyframes/deps/timeline-contract.ts +++ b/src/features/keyframes/deps/timeline-contract.ts @@ -7,7 +7,3 @@ export { useTimelineStore } from '@/features/timeline/stores/timeline-store' export { useItemsStore } from '@/features/timeline/stores/items-store' export { useKeyframesStore } from '@/features/timeline/stores/keyframes-store' export { useTransitionsStore } from '@/features/timeline/stores/transitions-store' -export { - getEdgeScrollDelta, - getPlayheadEdgeScrollVelocity, -} from '@/features/timeline/utils/playhead-edge-scroll' 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