diff --git a/packages/freecut-editor/consumer-smoke.test.tsx b/packages/freecut-editor/consumer-smoke.test.tsx index f6b902add..6140a997c 100644 --- a/packages/freecut-editor/consumer-smoke.test.tsx +++ b/packages/freecut-editor/consumer-smoke.test.tsx @@ -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 }, ) diff --git a/packages/freecut-editor/package.json b/packages/freecut-editor/package.json index 41c3dbf9e..5d1a8a09f 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.13", + "version": "0.3.14", "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 4c2cd2551..49f9aa1db 100644 --- a/packages/freecut-editor/src/index.d.ts +++ b/packages/freecut-editor/src/index.d.ts @@ -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', diff --git a/provenance/freecut-baseline.json b/provenance/freecut-baseline.json index 2171ac0ac..1c81b34ff 100644 --- a/provenance/freecut-baseline.json +++ b/provenance/freecut-baseline.json @@ -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", @@ -79,7 +79,14 @@ "packageCommand": "npm run package:reproducible", "artifactPattern": "artifacts/freecut-.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", diff --git a/scripts/editor-package-worker-urls.mjs b/scripts/editor-package-worker-urls.mjs new file mode 100644 index 000000000..66467095a --- /dev/null +++ b/scripts/editor-package-worker-urls.mjs @@ -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, + ) +} diff --git a/scripts/editor-package-worker-urls.test.ts b/scripts/editor-package-worker-urls.test.ts new file mode 100644 index 000000000..f6675f7a3 --- /dev/null +++ b/scripts/editor-package-worker-urls.test.ts @@ -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/) +}) diff --git a/scripts/fallow-unused-class-members.allowlist.json b/scripts/fallow-unused-class-members.allowlist.json index a7cf4d483..46946aca5 100644 --- a/scripts/fallow-unused-class-members.allowlist.json +++ b/scripts/fallow-unused-class-members.allowlist.json @@ -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", diff --git a/src/features/editor/codepress/adapter.test.ts b/src/features/editor/codepress/adapter.test.ts index d38ae6d14..5983cc1e3 100644 --- a/src/features/editor/codepress/adapter.test.ts +++ b/src/features/editor/codepress/adapter.test.ts @@ -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) + }) +}) diff --git a/src/features/editor/codepress/edit-engine.ts b/src/features/editor/codepress/edit-engine.ts index 0c6a3b9d1..caeacbb75 100644 --- a/src/features/editor/codepress/edit-engine.ts +++ b/src/features/editor/codepress/edit-engine.ts @@ -440,6 +440,7 @@ function applyMoveItem( setItemFramePosition(member.item, range.start + deltaFrames, range.end + deltaFrames, fps), ) } + recomputeDuration(timeline) return { ...moved, moved_item_ids: chainIds, @@ -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], @@ -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] } } diff --git a/src/features/editor/host/contract.ts b/src/features/editor/host/contract.ts index 7ce8f8783..c194c6fc0 100644 --- a/src/features/editor/host/contract.ts +++ b/src/features/editor/host/contract.ts @@ -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', @@ -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': diff --git a/src/features/editor/host/controller.test.ts b/src/features/editor/host/controller.test.ts index aa0bd9be7..8c7824a4e 100644 --- a/src/features/editor/host/controller.test.ts +++ b/src/features/editor/host/controller.test.ts @@ -757,6 +757,7 @@ describe('embedded FreeCut host controller', () => { 'add_text', 'move_item', 'set_item_attachment', + 'set_item_properties', 'trim_item', 'split_item', 'remove_item', @@ -1707,7 +1708,7 @@ describe('embedded FreeCut host controller', () => { } }) - it('still names a real resize once identity transforms compare equal', () => { + it('serializes a real resize once identity transforms compare equal', () => { const base = minimalTwoClipSnapshot() const sized = withClipTwo(base.timeline, { transform: { @@ -1736,13 +1737,14 @@ describe('embedded FreeCut host controller', () => { const derived = deriveSupportedHostEdit(sized, resized) - // The normalized transform has to carry the size, or a gizmo resize is - // indistinguishable from an untouched clip and is silently swallowed. - expect(derived.batch).toBeNull() - // `timelinePosition` rides along on every rejection of a clip that did - // not also move; `transform` is the predicate this pins. - expect(derived.detail?.failedPredicates).toEqual(['transform', 'timelinePosition']) - expect(derived.detail?.changedFields).toEqual(['transform.height', 'transform.width']) + expect(derived.batch?.commands).toEqual([ + { + command_id: 'properties-clip-2', + type: 'set_item_properties', + item_id: 'clip-2', + properties: { transform: expect.objectContaining({ width: 1280, height: 720 }) }, + }, + ]) }) }) }) @@ -1997,7 +1999,15 @@ it('rejects exhausted source handles before any host mutation', async () => { mode: 'normal', itemIds: ['clip-1'], }) - await expect(controller.submitEdit(batch)).resolves.toMatchObject({ status: 'rejected' }) + await expect(controller.submitEdit(batch)).resolves.toMatchObject({ + status: 'rejected', + result: { + error: { + code: 'invalid_request', + message: expect.stringContaining('exceeds known media duration'), + }, + }, + }) expect(harness.submitEdit).not.toHaveBeenCalled() expect(controller.getSnapshot()).toEqual(initial) }) @@ -2028,3 +2038,184 @@ it('does not restore a superseded timeline identity from a late receipt', async await pending expect(controller.getSnapshot().timeline.timelineId).toBe('replacement') }) + +describe('adopted host property compatibility', () => { + it('submits native placement and opacity, applies authority, and restores an authoritative undo snapshot', async () => { + const initial = snapshot() + const native = hostSnapshotToNativeTimeline(initial) + native.items[0]!.transform = { + x: 24, + y: 12, + width: 640, + height: 360, + anchorX: 320, + anchorY: 180, + rotation: 15, + opacity: 0.4, + } + const converted = nativeTimelineToFrameDocument(native, initial.timeline) + if (!converted.ok) throw new Error(converted.failure.reason) + const derived = deriveSupportedHostEdit(initial.timeline, converted.document) + expect(capabilityForCommand('set_item_properties')).toBe('workspace.edit') + expect(derived.batch?.commands).toMatchObject([ + { + type: 'set_item_properties', + properties: { opacity: 0.4, transform: native.items[0]!.transform }, + }, + ]) + expect(derived.batch?.preconditions).toHaveLength(1) + const applied = { ...initial, timeline: { ...converted.document, revision: 1 } } + const submitEdit = vi.fn( + async (): Promise => ({ + status: 'applied', + snapshot: applied, + result: { status: 'applied' } as HostAppliedEditResult['result'], + }), + ) + const host: EditorHost = { ...createFakeHost(initial).host, submitEdit } + const controller = new HostEditorController(host, initial) + host.history = { + undo: () => + controller.replaceAuthoritativeSnapshot({ + ...initial, + timeline: { ...initial.timeline, revision: 2 }, + }), + redo: () => undefined, + } + await expect(controller.submitEdit(derived.batch!)).resolves.toMatchObject({ + status: 'applied', + }) + expect(submitEdit).toHaveBeenCalledWith(derived.batch) + expect(controller.getSnapshot()).toEqual(applied) + await host.history.undo() + expect(controller.getSnapshot().timeline.tracks).toEqual(initial.timeline.tracks) + expect(controller.getSnapshot().timeline.revision).toBe(2) + expect(submitEdit).toHaveBeenCalledTimes(1) + }) + + it('gates property edits and does not broaden volume or combined move/property support', async () => { + const initial = snapshot() + const edited = structuredClone(initial) + Object.assign(edited.timeline.tracks[0]!.items[0]!, { opacity: 0.5 }) + const batch = deriveSupportedHostEdit(initial.timeline, edited.timeline).batch! + expect(batch.commands).toMatchObject([ + { type: 'set_item_properties', properties: { opacity: 0.5 } }, + ]) + const harness = createFakeHost(initial, { + ...DEFAULT_HOST_CAPABILITIES, + 'workspace.edit': false, + }) + await expect( + new HostEditorController(harness.host, initial).submitEdit(batch), + ).resolves.toMatchObject({ status: 'unsupported' }) + expect(harness.submitEdit).not.toHaveBeenCalled() + Object.assign(edited.timeline.tracks[0]!.items[0]!, { from: 10 }) + expect(deriveSupportedHostEdit(initial.timeline, edited.timeline).batch).toBeNull() + Object.assign(edited.timeline.tracks[0]!.items[0]!, { from: 0, volume: 0.5 }) + expect(deriveSupportedHostEdit(initial.timeline, edited.timeline).batch).toBeNull() + }) + + it.each(['video', 'text'] as const)( + 'preserves top-level opacity alongside a %s transform without deriving a phantom edit', + (type) => { + const initial = snapshot() + const base = initial.timeline.tracks[0]!.items[0]! + initial.timeline.tracks[0]!.items = [ + { + ...base, + type, + ...(type === 'text' ? { text: 'Title' } : {}), + opacity: 0.35, + transform: { + x: 20, + y: 10, + width: 640, + height: 360, + anchorX: 320, + anchorY: 180, + rotation: 5, + opacity: 1, + }, + } as typeof base, + ] + const native = hostSnapshotToNativeTimeline(initial) + expect(native.items[0]!.transform).toMatchObject({ x: 20, width: 640, opacity: 0.35 }) + // Top-level opacity is authoritative when both carriers exist. + const expected = structuredClone(initial.timeline) + const expectedItem = expected.tracks[0]!.items[0]! + if (expectedItem.type === 'caption_cue') throw new Error('Expected media or text') + expectedItem.transform!.opacity = 0.35 + const converted = nativeTimelineToFrameDocument(native, expected) + if (!converted.ok) throw new Error(converted.failure.reason) + expect(deriveSupportedHostEdit(expected, converted.document).batch).toBeNull() + }, + ) + + it('does not turn inherited caption style into incidental cue edits while changing media opacity', () => { + const initial = snapshot() + initial.timeline.tracks = [ + ...initial.timeline.tracks, + { + id: 'captions', + kind: 'caption', + name: 'Captions', + locked: false, + muted: false, + defaultStyle: { font_size: 52, color: '#ffaa00' }, + items: [ + { + id: 'cue', + type: 'caption_cue', + trackId: 'captions', + from: 0, + durationInFrames: 30, + text: 'Hello', + }, + ], + }, + ] + const native = hostSnapshotToNativeTimeline(initial) + native.items.find((item) => item.id === 'clip-1')!.transform = { opacity: 0.5 } + const converted = nativeTimelineToFrameDocument(native, initial.timeline) + if (!converted.ok) throw new Error(converted.failure.reason) + expect(converted.document.tracks[1]!.items[0]).not.toHaveProperty('style') + const batch = deriveSupportedHostEdit(initial.timeline, converted.document).batch! + expect(batch.commands).toMatchObject([ + { type: 'set_item_properties', item_id: 'clip-1', properties: { opacity: 0.5 } }, + ]) + expect(batch.commands).toHaveLength(1) + }) + + it('enforces 128-item attachment bounds before direct or diff-based submission', async () => { + const initial = snapshot({ durationInFrames: 129 * 60 }) + const anchor = initial.timeline.tracks[0]!.items[0]! + initial.timeline.tracks[0]!.items = Array.from({ length: 129 }, (_, index) => ({ + ...anchor, + id: `clip-${index}`, + from: index * 60, + })) + const ids = initial.timeline.tracks[0]!.items.map((item) => item.id) + const harness = createFakeHost(initial) + const controller = new HostEditorController(harness.host, initial) + await expect(controller.requestSetItemAttachment(ids, false)).resolves.toMatchObject({ + status: 'unsupported', + reason: expect.stringContaining('128'), + }) + expect(harness.submitEdit).not.toHaveBeenCalled() + const changed = structuredClone(initial.timeline) + changed.tracks[0]!.items.forEach((item) => { + item.rippleLinked = false + }) + expect(deriveSupportedHostEdit(initial.timeline, changed)).toMatchObject({ + batch: null, + reason: expect.stringContaining('128'), + }) + changed.tracks[0]!.items[128]!.rippleLinked = undefined + expect(deriveSupportedHostEdit(initial.timeline, changed).batch?.preconditions).toHaveLength( + 128, + ) + await expect( + controller.requestSetItemAttachment(ids.slice(0, 128), false), + ).resolves.toMatchObject({ status: 'applied' }) + }) +}) diff --git a/src/features/editor/host/controller.ts b/src/features/editor/host/controller.ts index b8686a953..b2c17672e 100644 --- a/src/features/editor/host/controller.ts +++ b/src/features/editor/host/controller.ts @@ -531,6 +531,11 @@ function deriveSetItemAttachment( const ids = [...new Set(itemIds)] const items = itemMap(previous) if (ids.length === 0) return { batch: null, reason: 'No timeline item is selected' } + if (ids.length > 128) + return { + batch: null, + reason: 'Cannot change attachment for more than 128 timeline items at once', + } 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' } @@ -624,6 +629,11 @@ export function deriveSupportedHostEdit( }) if (attachmentOnlyChange) { + if (changed.length > 128) + return { + batch: null, + reason: 'Cannot change attachment for more than 128 timeline items at once', + } commands.push({ command_id: `attachment-${operationId}`, type: 'set_item_attachment', @@ -933,6 +943,35 @@ export function deriveSupportedHostEdit( source_us: framesToMicroseconds(sourceFrame, fps), }) preconditions.push(preconditionForItem(before, fps)) + } else if ( + metadataUnchanged && + sourceUnchanged && + durationUnchanged && + timelineUnchanged && + sameTrack && + !transformUnchanged + ) { + const previousTransform = normalizedTransform(before) + const nextTransform = normalizedTransform(after) + const properties: Extract['properties'] = {} + const placementChanged = ['x', 'y', 'width', 'height', 'anchorX', 'anchorY', 'rotation'].some( + (key) => previousTransform[key] !== nextTransform[key], + ) + if (previousTransform.opacity !== nextTransform.opacity) + properties.opacity = nextTransform.opacity + // Preserve the surface's native placement payload. The host owns its + // canvas/media-dependent conversion to the wire transform coordinates. + if (placementChanged) + properties.transform = after.transform + ? ({ ...after.transform } as unknown as NonNullable) + : null + commands.push({ + command_id: `properties-${id}`, + type: 'set_item_properties', + item_id: id, + properties, + }) + preconditions.push(preconditionForItem(before, fps)) } else { const failedPredicates: HostEditPredicate[] = [] if (!metadataUnchanged) failedPredicates.push('metadata') diff --git a/src/features/editor/host/document.ts b/src/features/editor/host/document.ts index 0f4f96a1b..366ff8f12 100644 --- a/src/features/editor/host/document.ts +++ b/src/features/editor/host/document.ts @@ -177,7 +177,14 @@ function nativeItemFromHostItem( ? { textAlign: item.style.alignment } : {}), ...(item.opacity !== undefined ? { transform: { opacity: item.opacity } } : {}), - ...(item.transform ? { transform: frameTransformToNative(item.transform) } : {}), + ...(item.transform + ? { + transform: { + ...frameTransformToNative(item.transform), + ...(item.opacity !== undefined ? { opacity: item.opacity } : {}), + }, + } + : {}), } } @@ -239,7 +246,14 @@ function nativeItemFromHostItem( ...(item.volume !== undefined ? { volume: item.volume } : {}), ...(item.speed !== undefined ? { speed: item.speed } : {}), ...(item.opacity !== undefined ? { transform: { opacity: item.opacity } } : {}), - ...(item.transform ? { transform: frameTransformToNative(item.transform) } : {}), + ...(item.transform + ? { + transform: { + ...frameTransformToNative(item.transform), + ...(item.opacity !== undefined ? { opacity: item.opacity } : {}), + }, + } + : {}), } if (item.type === 'audio') return { ...common, type: 'audio' } diff --git a/src/features/media-library/components/media-card.test.tsx b/src/features/media-library/components/media-card.test.tsx index b6a22d1b6..261cc8160 100644 --- a/src/features/media-library/components/media-card.test.tsx +++ b/src/features/media-library/components/media-card.test.tsx @@ -3,6 +3,13 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import type { MouseEvent, ReactNode } from 'react' import type { MediaMetadata } from '@/types/storage' +const hostContextState = vi.hoisted(() => ({ hostMode: false })) + +vi.mock('../deps/editor', () => ({ + useEditorHostMode: () => hostContextState.hostMode, + useEditorCapability: () => true, +})) + const mediaLibraryServiceMocks = vi.hoisted(() => ({ getThumbnailBlobUrl: vi.fn(), getMediaFile: vi.fn(), @@ -365,6 +372,7 @@ describe('MediaCard', () => { beforeEach(() => { vi.clearAllMocks() vi.useRealTimers() + hostContextState.hostMode = false mediaStoreState.selectedMediaIds = [] mediaStoreState.mediaItems = [makeMedia()] mediaStoreState.importingIds = [] @@ -467,6 +475,18 @@ describe('MediaCard', () => { ) }) + it('hides local transcription actions in host mode even when transcription capability is enabled', () => { + hostContextState.hostMode = true + const { rerender } = render() + expect(screen.queryByText('Generate Transcript')).not.toBeInTheDocument() + mediaStoreState.transcriptStatus = new Map([['media-1', 'ready']]) + rerender() + expect(screen.queryByText('Refresh Transcript')).not.toBeInTheDocument() + expect(screen.queryByText('Delete Transcript')).not.toBeInTheDocument() + expect(screen.queryByTestId('transcribe-dialog')).not.toBeInTheDocument() + expect(mediaTranscriptionRunnerMocks.runMediaTranscriptionJob).not.toHaveBeenCalled() + }) + it('uses transcript wording in the media action menu', () => { const { rerender } = render() expect(screen.getByText('Generate Transcript')).toBeInTheDocument() diff --git a/src/features/media-library/components/media-card.tsx b/src/features/media-library/components/media-card.tsx index 9c60c68ad..f15865b94 100644 --- a/src/features/media-library/components/media-card.tsx +++ b/src/features/media-library/components/media-card.tsx @@ -710,7 +710,7 @@ const MediaCardInternal = memo(function MediaCardInternal({ const mediaType = getMediaType(media.mimeType) const isTranscribable = - canTranscribeCapability && (mediaType === 'video' || mediaType === 'audio') + !hostMode && canTranscribeCapability && (mediaType === 'video' || mediaType === 'audio') const canGenerateProxy = canGenerateProxyCapability && !hostMode && diff --git a/src/features/preview/components/edit-2up-panels.tsx b/src/features/preview/components/edit-2up-panels.tsx index 13e6cde3e..37e0abcd4 100644 --- a/src/features/preview/components/edit-2up-panels.tsx +++ b/src/features/preview/components/edit-2up-panels.tsx @@ -820,6 +820,7 @@ function ImageFrameImpl({ item }: ImageFrameProps) { if (!canvas) return const img = new Image() + img.crossOrigin = 'anonymous' img.onload = () => { canvas.width = img.naturalWidth || 280 canvas.height = img.naturalHeight || 158 diff --git a/src/features/preview/components/source-composition.tsx b/src/features/preview/components/source-composition.tsx index d1328f21d..e822b2154 100644 --- a/src/features/preview/components/source-composition.tsx +++ b/src/features/preview/components/source-composition.tsx @@ -917,7 +917,13 @@ function VideoSource({ display: showDecodedCanvas ? 'block' : 'none', }} /> -