Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/freecut-editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```
2 changes: 1 addition & 1 deletion packages/freecut-editor/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
6 changes: 6 additions & 0 deletions packages/freecut-editor/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export type EditorCapability =
| 'media.relink'
| 'timeline.add'
| 'timeline.move'
| 'timeline.attachment'
| 'timeline.trim'
| 'timeline.split'
| 'timeline.remove'
Expand Down Expand Up @@ -160,6 +161,7 @@ export interface FreeCutFrameClip {
trackId: string
mediaId: string
linkedGroupId?: string | null
rippleLinked?: boolean
from: number
durationInFrames: number
sourceStart?: number
Expand All @@ -177,6 +179,7 @@ export interface FreeCutFrameText {
from: number
durationInFrames: number
linkedGroupId?: string | null
rippleLinked?: boolean
text: string
style?: Record<string, string | number>
opacity?: number
Expand All @@ -190,6 +193,7 @@ export interface FreeCutFrameCaptionCue {
from: number
durationInFrames: number
linkedGroupId?: string | null
rippleLinked?: boolean
text: string
speaker?: string | null
style?: CaptionStyle
Expand Down Expand Up @@ -483,6 +487,7 @@ export interface EditorHostContextValue {

export interface HostTimelineEditPort {
requestRippleDelete(itemIds: readonly string[]): Promise<void> | void
requestSetItemAttachment?(itemIds: readonly string[], rippleLinked: boolean): Promise<void> | void
}

export interface EditorHostProviderProps {
Expand All @@ -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',
Expand Down
26 changes: 0 additions & 26 deletions scripts/fallow-unused-class-members.allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
},
Expand Down Expand Up @@ -339,11 +329,6 @@
"memberName": "isFrameInRange",
"kind": "class_method"
},
{
"parentName": "Clock",
"memberName": "isPlaying",
"kind": "class_method"
},
{
"parentName": "Clock",
"memberName": "loop",
Expand All @@ -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",
Expand Down
20 changes: 0 additions & 20 deletions scripts/fallow-unused-exports.allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/features/editor/codepress/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
83 changes: 83 additions & 0 deletions src/features/editor/codepress/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,24 @@ describe('PR1 conformance fixtures', () => {
})
})

it('rejects a non-boolean ripple move intent', () => {
const fixture = readFixture<ValidFixture>('valid/core-edit-batch.json')
const request = structuredClone(fixture.request) as unknown as Record<string, unknown>
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) => {
Expand Down Expand Up @@ -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<ValidFixture>('valid/core-edit-batch.json')
const translated = translateCommandBatchToFrames(fixture.request, 30)
Expand Down
67 changes: 67 additions & 0 deletions src/features/editor/codepress/attached-chain.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
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<string>()
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
}
Loading
Loading