Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ac72901
test(host): construct readonly timeline fixtures without push
lightninglu10 Sep 8, 2026
dfad1cf
fix(host): hide local media transcription actions
lightninglu10 Sep 8, 2026
f308ced
fix(editor): preserve host focus and cancel hidden move gestures
lightninglu10 Sep 8, 2026
d12d5dd
feat(transcript): define provider word occurrence contract
lightninglu10 Sep 8, 2026
5291f83
fix(transcript): retain disjoint word selection boundaries
lightninglu10 Sep 8, 2026
2ba6461
fix(transcript): reject incomplete provider word coverage
lightninglu10 Sep 8, 2026
d242bf3
feat(transcript): cut selected word occurrences immediately
lightninglu10 Sep 8, 2026
f38cd37
feat(captions): expose edited-sequence captions in Library
lightninglu10 Sep 8, 2026
f0bf8d3
fix(media): preserve anonymous CORS in host package sources
lightninglu10 Sep 8, 2026
4edf7a2
test(editor): align shell expectations and package provenance
lightninglu10 Sep 8, 2026
61fc6cc
fix(package): emit host worker URLs without bundler wrappers
lightninglu10 Sep 8, 2026
04e857e
fix(host): preserve property and duration patch compatibility
lightninglu10 Sep 8, 2026
182d7ce
fix(preview): preserve host geometry and transport compatibility
lightninglu10 Sep 8, 2026
3f2aed0
test(package): include worker URL regressions in regular suite
lightninglu10 Sep 8, 2026
323fa67
refactor(host): simplify trim intent and commit flow
lightninglu10 Sep 8, 2026
99d2edc
fix(transcript): invalidate selection when word projection changes
lightninglu10 Sep 8, 2026
54d40d8
fix(timeline): enable split for one selected linked cohort
lightninglu10 Sep 8, 2026
1142167
chore(editor): prepare UX surface 0.3.14 release
lightninglu10 Sep 8, 2026
ee9c3a4
fix(editor): pause source monitor before hiding workspace
lightninglu10 Sep 8, 2026
c5ab08b
fix(captions): bind previews to selected occurrences
lightninglu10 Sep 8, 2026
802dbd7
fix(transcript): fence occurrence cuts and stale page responses
lightninglu10 Sep 8, 2026
e964e33
fix(timeline): preserve voiceover when advanced tools close
lightninglu10 Sep 8, 2026
8c2cb44
chore(release): reconcile merged reliability and timeline changes
lightninglu10 Sep 8, 2026
e1f3884
Merge remote-tracking branch 'origin/codepress-main' into ux7144-inte…
lightninglu10 Sep 8, 2026
9f379c5
Merge remote-tracking branch 'origin/codepress-main' into ux7144-inte…
lightninglu10 Sep 8, 2026
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
4 changes: 1 addition & 3 deletions packages/freecut-editor/consumer-smoke.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,7 @@ describe('published FreeCut browser entry', () => {
expect(screen.getAllByRole('toolbar').length).toBeGreaterThanOrEqual(2)
expect(screen.getByRole('region', { name: 'Preview area' })).toBeInTheDocument()
const timelineToolbar = within(view.container).getByRole('toolbar', { name: 'Controls' })
expect(
within(timelineToolbar).getByRole('button', { name: 'Split' }),
).toBeInTheDocument()
expect(within(timelineToolbar).getByRole('button', { name: 'Split' })).toBeInTheDocument()
},
{ timeout: 10_000 },
)
Expand Down
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.13",
"version": "0.3.14",
"description": "The host-backed FreeCut browser editor surface.",
"license": "MIT",
"repository": {
Expand Down
1 change: 1 addition & 0 deletions packages/freecut-editor/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,7 @@ export declare const SUPPORTED_HOST_COMMANDS: readonly [
'add_text',
'move_item',
'set_item_attachment',
'set_item_properties',
'trim_item',
'split_item',
'remove_item',
Expand Down
11 changes: 9 additions & 2 deletions provenance/freecut-baseline.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schema": "freecut-pr2-provenance-baseline/v1",
"issue": "https://github.com/quantfive/codepress/issues/5319",
"scope": "PR 2 FreeCut fork provenance and reproducible package baseline",
"scope": "PR 2 \u2014 FreeCut fork provenance and reproducible package baseline",
"upstream": {
"repository": "https://github.com/walterlow/freecut",
"fork": "https://github.com/quantfive/freecut",
Expand Down Expand Up @@ -79,7 +79,14 @@
"packageCommand": "npm run package:reproducible",
"artifactPattern": "artifacts/freecut-<source-revision>.tar.gz",
"archiveFormat": "deterministic tar.gz with sorted paths, zeroed mtimes, and uid/gid 0",
"contents": ["dist/", "LICENSE", "notices/", "package.json", "package-lock.json", "provenance/"]
"contents": [
"dist/",
"LICENSE",
"notices/",
"package.json",
"package-lock.json",
"provenance/"
]
},
"ciVerification": {
"workflow": ".github/workflows/reproducible-package.yml",
Expand Down
45 changes: 45 additions & 0 deletions scripts/editor-package-worker-urls.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { parseSync, Visitor } from 'vite-plus'

function isImportMetaUrl(node) {
return node?.type === 'MemberExpression' && !node.computed &&
node.object.type === 'MetaProperty' && node.object.meta.name === 'import' &&
node.object.property.name === 'meta' && node.property.type === 'Identifier' &&
node.property.name === 'url'
}

function isGeneratedWorkerBase(node) {
return isImportMetaUrl(node) || (node?.type === 'BinaryExpression' && node.operator === '+' &&
node.left.type === 'Literal' && node.left.value === '' && isImportMetaUrl(node.right))
}

function isNamedConstructor(node, name) {
return node?.type === 'NewExpression' && node.callee.type === 'Identifier' &&
node.callee.name === name
}

function publicWorkerUrl(url) {
if (!isNamedConstructor(url, 'URL') || url.arguments.length !== 2) return null
const [asset, base] = url.arguments
if (asset.type !== 'Literal' || typeof asset.value !== 'string' ||
!/^\/assets\/[A-Za-z0-9][A-Za-z0-9._-]*\.js$/.test(asset.value) || !isGeneratedWorkerBase(base)) return null
return { start: url.start, end: url.end, text: JSON.stringify(asset.value) }
}

/** Embedded hosts stage workers at their public root; keep them runtime URLs for Turbopack. */
export function normalizeEditorWorkerUrls(code, filename = 'editor-chunk.js') {
if (!code.includes('/assets/')) return code
const parsed = parseSync(filename, code)
if (parsed.errors.length) throw new Error(`Cannot inspect editor worker URLs in ${filename}`)
const replacements = []
new Visitor({
NewExpression(node) {
if (node.callee.type !== 'Identifier' || !['Worker', 'SharedWorker'].includes(node.callee.name)) return
const replacement = publicWorkerUrl(node.arguments[0])
if (replacement) replacements.push(replacement)
},
}).visit(parsed.program)
return replacements.sort((left, right) => right.start - left.start).reduce(
(result, replacement) => result.slice(0, replacement.start) + replacement.text + result.slice(replacement.end),
code,
)
}
26 changes: 26 additions & 0 deletions scripts/editor-package-worker-urls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict'
import { test } from 'vite-plus/test'
import { normalizeEditorWorkerUrls } from './editor-package-worker-urls.mjs'

test('unwraps generated worker URLs while retaining worker options and content', () => {
const source = `new Worker(new URL(/* @vite-ignore */ "/assets/decoder-abc.js", "" + import.meta.url), {type:"module"});
new SharedWorker(new URL('/assets/waveform-def.js', import.meta.url), {name:'audio'});`
assert.equal(normalizeEditorWorkerUrls(source), `new Worker("/assets/decoder-abc.js", {type:"module"});
new SharedWorker("/assets/waveform-def.js", {name:'audio'});`)
})

test('leaves relative, external, computed, non-worker URLs and documentation unchanged', () => {
const source = `new Worker(new URL('./relative.js', import.meta.url));
new Worker(new URL('https://cdn.example.com/assets/worker.js', import.meta.url));
new Worker(new URL('/assets/worker.js', otherBase));
new Worker(new URL(assetPath, import.meta.url));
new URL('/assets/worker.js', import.meta.url).href;
const documentation = 'new Worker(new URL("/assets/worker.js", import.meta.url))';`
assert.equal(normalizeEditorWorkerUrls(source), source)
})

test('is idempotent and rejects malformed chunks instead of silently changing code', () => {
const normalized = 'new Worker("/assets/worker.js", {type:"module"})'
assert.equal(normalizeEditorWorkerUrls(normalized), normalized)
assert.throws(() => normalizeEditorWorkerUrls('new Worker( /* /assets/ */'), /Cannot inspect/)
})
5 changes: 0 additions & 5 deletions scripts/fallow-unused-class-members.allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -452,11 +452,6 @@
"path": "src/runtime/player/video/VideoSourcePool.ts",
"reason": "Audited as a live video source pool API reached through preview/export integrations.",
"members": [
{
"parentName": "VideoSourcePool",
"memberName": "dispose",
"kind": "class_method"
},
{
"parentName": "VideoSourcePool",
"memberName": "preloadSource",
Expand Down
144 changes: 144 additions & 0 deletions src/features/editor/codepress/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1217,3 +1217,147 @@ describe('controlled command adapter', () => {
function itemIdForTest(item: TimelineState['tracks'][number]['items'][number]): string {
return item.item_type === 'caption_cue' ? item.cue_id : item.item_id
}

describe('adopted move and trim duration extension', () => {
it.each(['move', 'attached move', 'trim'] as const)(
'expands duration after %s before validating the resulting timeline',
(mode) => {
const initial = timeline({ duration_us: mode === 'attached move' ? 2_000_000 : 1_000_000 })
if (mode === 'attached move')
initial.tracks[0]!.items = [
...initial.tracks[0]!.items,
clip({ item_id: 'tail', timeline_start_us: 1_000_000, timeline_end_us: 2_000_000 }),
]
const adapter = createCodePressCommandAdapter({ document: documentFor(initial) })
const commands: EditCommandBatch['commands'] =
mode === 'trim'
? [
{
command_id: 'trim',
type: 'trim_item',
item_id: 'clip-a',
edge: 'end',
timeline_us: 3_000_000,
source_us: 3_000_000,
},
]
: [
{
command_id: 'move',
type: 'move_item',
item_id: 'clip-a',
to_track_id: 'track-video',
timeline_start_us: 2_000_000,
index: 0,
...(mode === 'attached move' ? { ripple: true } : {}),
},
]
applyRequest(adapter, {
contract_version: 1,
timeline_id: initial.timeline_id,
operation_id: 'extend',
idempotency_key: 'extend',
base_revision: 0,
preconditions: [],
commands,
})
expect(adapter.getDocument().timeline.duration_us).toBe(
mode === 'attached move' ? 4_000_000 : 3_000_000,
)
if (mode === 'attached move')
expect(adapter.getDocument().timeline.tracks[0]!.items[1]).toMatchObject({
timeline_start_us: 3_000_000,
timeline_end_us: 4_000_000,
})
},
)
})

describe('known source bounds when extending trim duration', () => {
it.each([30, 29.97])('uses source microseconds at %s fps including double-speed clips', (fps) => {
const frame = (value: number) => framesToMicroseconds(value, fps)
const initial = timeline({
duration_us: frame(30),
media: [{ ...videoMedia, duration_us: frame(180) }],
})
initial.tracks[0]!.items = [
clip({ timeline_end_us: frame(30), source_end_us: frame(60), speed: 2 }),
]
const adapter = createCodePressCommandAdapter({ document: documentFor(initial, fps) })
const request = (endFrame: number, revision: number): EditCommandBatch => ({
contract_version: 1,
timeline_id: initial.timeline_id,
operation_id: `trim-${endFrame}`,
idempotency_key: `trim-${endFrame}`,
base_revision: revision,
preconditions: [],
commands: [
{
command_id: 'trim',
type: 'trim_item',
item_id: 'clip-a',
edge: 'end',
timeline_us: frame(endFrame),
source_us: frame(endFrame * 2),
},
],
})
applyRequest(adapter, request(60, 0))
expect(adapter.getDocument().timeline.duration_us).toBe(frame(60))
applyRequest(adapter, request(90, 1))
expect(adapter.getDocument().timeline.duration_us).toBe(frame(90))
const before = structuredClone(adapter.getSnapshot())
const invalid = request(91, 2)
// A preceding valid property command must also roll back with the trim.
invalid.commands = [
{
command_id: 'opacity',
type: 'set_item_properties',
item_id: 'clip-a',
properties: { opacity: 0.5 },
},
...invalid.commands,
]
expect(adapter.apply(invalid)).toMatchObject({
status: 'rejected',
error: {
code: 'invalid_request',
message: expect.stringContaining('exceeds known media duration'),
},
})
expect(adapter.getSnapshot()).toEqual(before)
// Rejection does not advance authority or poison the successful request receipt.
expect(adapter.apply(request(90, 1))).toMatchObject({
status: 'replayed',
resulting_revision: 2,
})
expect(adapter.getSnapshot()).toEqual(before)
})

it('does not invent a source bound when media duration is unknown', () => {
const initial = timeline({
duration_us: 1_000_000,
media: [{ ...videoMedia, duration_us: null }],
})
const adapter = createCodePressCommandAdapter({ document: documentFor(initial) })
applyRequest(adapter, {
contract_version: 1,
timeline_id: initial.timeline_id,
operation_id: 'unknown',
idempotency_key: 'unknown',
base_revision: 0,
preconditions: [],
commands: [
{
command_id: 'trim',
type: 'trim_item',
item_id: 'clip-a',
edge: 'end',
timeline_us: 40_000_000,
source_us: 40_000_000,
},
],
})
expect(adapter.getDocument().timeline.duration_us).toBe(40_000_000)
})
})
15 changes: 15 additions & 0 deletions src/features/editor/codepress/edit-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ function applyMoveItem(
setItemFramePosition(member.item, range.start + deltaFrames, range.end + deltaFrames, fps),
)
}
recomputeDuration(timeline)
return {
...moved,
moved_item_ids: chainIds,
Expand All @@ -457,6 +458,7 @@ function applyMoveItem(
replaceTrackItems(timeline, located.trackIndex, sourceItems)
const targetItems = targetIndex === located.trackIndex ? sourceItems : target.items
replaceTrackItems(timeline, targetIndex, insertAt(targetItems, moved, command.index))
recomputeDuration(timeline)
return {
...emptyEffect(),
moved_item_ids: [command.item_id],
Expand Down Expand Up @@ -513,7 +515,20 @@ function applyTrim(
`Trim would make item "${command.item_id}" empty`,
command.command_id,
)
if (next.item_type === 'clip') {
const mediaDuration = timeline.media.find(
(media) => media.media_id === next.media_id,
)?.duration_us
if (mediaDuration != null && next.source_end_us > mediaDuration) {
throw new EditEngineError(
'invalid_request',
`Trim source end exceeds known media duration for item "${command.item_id}"`,
command.command_id,
)
}
}
setItemAt(timeline, located, next)
recomputeDuration(timeline)
return { ...emptyEffect(), updated_item_ids: [command.item_id] }
}

Expand Down
3 changes: 3 additions & 0 deletions src/features/editor/host/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ export const SUPPORTED_HOST_COMMANDS = [
'add_text',
'move_item',
'set_item_attachment',
'set_item_properties',
'trim_item',
'split_item',
'remove_item',
Expand All @@ -432,6 +433,8 @@ export function capabilityForCommand(command: EditCommand['type']): EditorCapabi
return 'timeline.add'
case 'move_item':
return 'timeline.move'
case 'set_item_properties':
return 'workspace.edit'
case 'set_item_attachment':
return 'timeline.attachment'
case 'trim_item':
Expand Down
Loading
Loading