From 77d2b496b42a46f6db6c8fd564ae86dbf77bcc6a Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Tue, 18 Aug 2026 22:31:08 -0700 Subject: [PATCH 1/2] feat(editor): add host-backed transcript consumer --- packages/freecut-editor/README.md | 17 +- packages/freecut-editor/package.json | 2 +- packages/freecut-editor/src/index.d.ts | 134 +++ packages/freecut-editor/src/index.ts | 20 + .../editor/components/media-sidebar.tsx | 18 +- src/features/editor/host/contract.ts | 158 +++ src/features/editor/host/editor-surface.tsx | 33 +- src/features/editor/host/index.ts | 20 + .../editor/host/transcript-editor-context.tsx | 26 + .../editor/host/transcript-editor.test.tsx | 425 +++++++++ .../editor/host/transcript-editor.tsx | 903 ++++++++++++++++++ 11 files changed, 1732 insertions(+), 24 deletions(-) create mode 100644 src/features/editor/host/transcript-editor-context.tsx create mode 100644 src/features/editor/host/transcript-editor.test.tsx create mode 100644 src/features/editor/host/transcript-editor.tsx diff --git a/packages/freecut-editor/README.md b/packages/freecut-editor/README.md index 73529c066..3565a6d37 100644 --- a/packages/freecut-editor/README.md +++ b/packages/freecut-editor/README.md @@ -26,8 +26,17 @@ The `EditorHost` contract carries opaque media locators and authoritative snapshots. It never accepts filesystem paths, permanent URLs, provider keys, or media bytes. Supported edits are submitted through `submitEdit`; rejected or conflicting results return an authoritative snapshot to the surface. The -0.2.0 surface adds host-backed caption tracks, bounded cues, caption styles, -and display toggles to that same command path. +0.3.0 surface adds an optional host-backed transcript consumer. Hosts opt into the +transcript tab by providing `EditorHost.transcript` and explicitly enabling +`media.transcription`. The port returns a compact status receipt and bounded +microsecond sections, and previews source-bound caption commands with +`willMutateTimeline: false`; only an explicit user action submits that returned +batch through `submitEdit`. Transcript IDs, asset IDs, source hashes, cursors, +and structured errors are opaque browser data—authentication, transport, +provider details, URLs, paths, and media bytes remain host-owned. + +The same 0.3.0 surface retains the host-backed caption tracks, bounded cues, +caption styles, and display toggles from 0.2.0. This package is built from a specific FreeCut commit. To create the local consumer artifact from a clean checkout, run: @@ -49,7 +58,7 @@ that has `write:packages` and run: ```bash NODE_AUTH_TOKEN="$GITHUB_CLASSIC_PAT" npm publish \ - artifacts/freecut-editor-surface-0.2.0.tgz \ + artifacts/freecut-editor-surface-0.3.0.tgz \ --registry=https://npm.pkg.github.com ``` @@ -69,5 +78,5 @@ It can then install the exact published version and keep it pinned in its lockfile: ```bash -npm install @quantfive/freecut-editor-surface@0.2.0 +npm install @quantfive/freecut-editor-surface@0.3.0 ``` diff --git a/packages/freecut-editor/package.json b/packages/freecut-editor/package.json index 250e6d7a0..aa3d2f303 100644 --- a/packages/freecut-editor/package.json +++ b/packages/freecut-editor/package.json @@ -1,6 +1,6 @@ { "name": "@quantfive/freecut-editor-surface", - "version": "0.2.0", + "version": "0.3.0", "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 f2c5107b2..1ca774a12 100644 --- a/packages/freecut-editor/src/index.d.ts +++ b/packages/freecut-editor/src/index.d.ts @@ -166,6 +166,139 @@ export interface HostNotice { operationId?: string } +export declare const MAX_TRANSCRIPT_SELECTIONS: number +export declare const MAX_TRANSCRIPT_SECTION_PAGE_SIZE: number +export declare const MAX_TRANSCRIPT_SECTION_TEXT_BYTES: number +export declare const MAX_TRANSCRIPT_COMMAND_TEXT_BYTES: number +export declare const MAX_TRANSCRIPT_DURATION_US: number +export declare const MAX_TRANSCRIPT_CURSOR_LENGTH: number +export declare const MAX_TRANSCRIPT_QUERY_LENGTH: number + +export type HostTranscriptStatus = + | 'pending' + | 'running' + | 'succeeded' + | 'failed' + | 'stale' + | 'purged' + +export interface HostTranscriptError { + code: string + message: string + retryable: boolean + details?: Readonly> +} + +export interface HostTranscriptStatusReceipt { + transcriptId: string + assetId: string | null + sourceAssetHash: string + status: HostTranscriptStatus + language?: string | null + durationUs: number | null + sectionCount: number + error?: HostTranscriptError | null +} + +export interface HostTranscriptSection { + id: string + transcriptId: string + ordinal: number + startUs: number + endUs: number + text: string + speaker?: string | null +} + +export interface HostTranscriptSectionsRequest { + transcriptId: string + cursor?: string | null + limit?: number + startUs?: number + endUs?: number +} + +export interface HostTranscriptSectionsPage { + transcriptId: string + sections: readonly HostTranscriptSection[] + nextCursor?: string | null + hasMore: boolean +} + +export interface HostTranscriptSearchRequest { + transcriptId: string + query: string + cursor?: string | null + limit?: number +} + +export interface HostTranscriptSearchPage { + transcriptId: string + query: string + sections: readonly HostTranscriptSection[] + nextCursor?: string | null + hasMore: boolean +} + +export interface HostTranscriptRange { + startUs: number + endUs: number + text?: string +} + +export type HostTranscriptCommandAction = 'cut' | 'captions' | 'ripple_cut' | 'caption' + +export interface HostTranscriptCommandPreviewRequest { + transcriptId: string + assetId: string + sourceAssetHash: string + operationId: string + idempotencyKey: string + baseRevision: number + action: HostTranscriptCommandAction + timestampCapability: 'section' | 'word' | 'frame' + sectionIds?: readonly string[] + ranges?: readonly HostTranscriptRange[] + captionTrackId?: string + captionTrackName?: string + captionLanguage?: string | null + preconditions?: readonly object[] +} + +export interface HostTranscriptCommandPreview { + status: 'preview' | 'replayed' + receiptId: string + transcriptId: string + assetId: string + sourceAssetHash: string + timestampCapability: 'section' + timelineId: string + operationId: string + idempotencyKey: string + baseRevision: number + commandBatch: EditCommandBatch + preview: Readonly<{ + action?: string + sectionCount?: number + captionCount?: number + willMutateTimeline: false + [key: string]: unknown + }> +} + +export interface EditorTranscriptPort { + getStatus(): Promise | HostTranscriptStatusReceipt | null + getSections( + request: HostTranscriptSectionsRequest, + ): Promise | HostTranscriptSectionsPage + search?( + request: HostTranscriptSearchRequest, + ): Promise | HostTranscriptSearchPage + previewCommands( + request: HostTranscriptCommandPreviewRequest, + ): Promise | HostTranscriptCommandPreview +} + export interface HostAppliedEditResult { status: 'applied' | 'replayed' snapshot: EmbeddedEditorSnapshot @@ -207,6 +340,7 @@ export interface EditorHost { locator: MediaLocator, ): Promise | ResolvedMediaLocator | null submitEdit(batch: EditCommandBatch): Promise | HostEditResult + transcript?: EditorTranscriptPort navigation?: EditorHostNavigation notify?(notice: HostNotice): void } diff --git a/packages/freecut-editor/src/index.ts b/packages/freecut-editor/src/index.ts index 37bfa15f9..ac374e68d 100644 --- a/packages/freecut-editor/src/index.ts +++ b/packages/freecut-editor/src/index.ts @@ -2,6 +2,13 @@ export { FreeCutEditorSurface } from '@/features/editor/host/editor-surface' export { EditorHostProvider } from '@/features/editor/host/context-provider' export { DEFAULT_HOST_CAPABILITIES, + MAX_TRANSCRIPT_COMMAND_TEXT_BYTES, + MAX_TRANSCRIPT_CURSOR_LENGTH, + MAX_TRANSCRIPT_DURATION_US, + MAX_TRANSCRIPT_QUERY_LENGTH, + MAX_TRANSCRIPT_SECTION_PAGE_SIZE, + MAX_TRANSCRIPT_SECTION_TEXT_BYTES, + MAX_TRANSCRIPT_SELECTIONS, SUPPORTED_HOST_COMMANDS, capabilityForCommand, createLocalEditorHost, @@ -17,11 +24,24 @@ export type { EmbeddedEditorAsset, EmbeddedEditorProject, EmbeddedEditorSnapshot, + EditorTranscriptPort, HostAppliedEditResult, HostConflictResult, HostEditResult, HostMediaKind, HostNotice, + HostTranscriptCommandAction, + HostTranscriptCommandPreview, + HostTranscriptCommandPreviewRequest, + HostTranscriptError, + HostTranscriptRange, + HostTranscriptSearchPage, + HostTranscriptSearchRequest, + HostTranscriptSection, + HostTranscriptSectionsPage, + HostTranscriptSectionsRequest, + HostTranscriptStatus, + HostTranscriptStatusReceipt, LocalEditorHostOptions, MediaLocator, ResolvedMediaLocator, diff --git a/src/features/editor/components/media-sidebar.tsx b/src/features/editor/components/media-sidebar.tsx index ba6cfe1a7..5d57f3ae8 100644 --- a/src/features/editor/components/media-sidebar.tsx +++ b/src/features/editor/components/media-sidebar.tsx @@ -60,7 +60,8 @@ import { EffectThumbnail, useGpuEffectPreviewData } from '@/features/editor/deps import { createLogger } from '@/shared/logging/logger' import { useSettingsStore } from '@/features/editor/deps/settings' import { resolveGeneratedLayerCanvasSize } from '../utils/generated-layer-canvas-size' -import { useEditorCapability, useEditorHostMode } from '../host/context' +import { useEditorCapability, useEditorHostContext, useEditorHostMode } from '../host/context' +import { HostTranscriptEditor } from '../host/transcript-editor' const LazyAiPanel = lazy(() => import('./ai-tab').then((m) => ({ default: m.AiTab }))) const LazyTranscriptEditorPanel = lazy(() => importTranscriptEditorPanel().then(({ TranscriptEditorPanel }) => ({ @@ -290,7 +291,9 @@ const ADD_TEXT_TEMPLATE_LABEL = 'Add Text' export const MediaSidebar = memo(function MediaSidebar() { const { t } = useTranslation() const hostMode = useEditorHostMode() + const { host } = useEditorHostContext() const canAddTimeline = useEditorCapability('timeline.add') + const canTranscribe = useEditorCapability('media.transcription') const editorDensity = useSettingsStore((s) => s.editorDensity) const editorLayout = getEditorLayout(editorDensity) // Use granular selectors - Zustand v5 best practice @@ -557,7 +560,12 @@ export const MediaSidebar = memo(function MediaSidebar() { { id: 'ai' as const, icon: WandSparkles, label: t('editor.mediaSidebar.ai') }, ] const visibleCategories = hostMode - ? categories.filter(({ id }) => id === 'media' || (id === 'text' && canAddTimeline)) + ? categories.filter( + ({ id }) => + id === 'media' || + (id === 'text' && canAddTimeline) || + (id === 'transcript' && canTranscribe && !!host?.transcript), + ) : categories const shouldSuppressGeneratedItemClick = useCallback(() => { @@ -1165,11 +1173,13 @@ export const MediaSidebar = memo(function MediaSidebar() {
- {activeTab === 'transcript' && ( + {activeTab === 'transcript' && hostMode ? ( + + ) : activeTab === 'transcript' ? ( - )} + ) : null}
{/* AI Tab */} diff --git a/src/features/editor/host/contract.ts b/src/features/editor/host/contract.ts index ac906a336..53d7fb2f7 100644 --- a/src/features/editor/host/contract.ts +++ b/src/features/editor/host/contract.ts @@ -2,6 +2,9 @@ import type { EditApplyResult, EditCommandBatch, EditCommand, + Microseconds, + Precondition, + TimelineRevision, } from '@/features/editor/codepress/contract' import type { FreeCutFrameDocument } from '@/features/editor/codepress/document' @@ -111,6 +114,159 @@ export interface HostNotice { operationId?: string } +/** Maximum section/range selection accepted by the PR9B transcript adapter. */ +export const MAX_TRANSCRIPT_SELECTIONS = 64 +/** The backend exposes at most this many sections per bounded page. */ +export const MAX_TRANSCRIPT_SECTION_PAGE_SIZE = 50 +/** Maximum UTF-8 bytes accepted for one bounded transcript section. */ +export const MAX_TRANSCRIPT_SECTION_TEXT_BYTES = 4_000 +/** Maximum UTF-8 bytes accepted for one generated transcript command. */ +export const MAX_TRANSCRIPT_COMMAND_TEXT_BYTES = 64 * 1024 +/** Maximum source duration representable by a transcript command selection. */ +export const MAX_TRANSCRIPT_DURATION_US = 3_600_000_000 +/** Maximum cursor/query payload size accepted by the browser port. */ +export const MAX_TRANSCRIPT_CURSOR_LENGTH = 256 +export const MAX_TRANSCRIPT_QUERY_LENGTH = 256 + +export type HostTranscriptStatus = + | 'pending' + | 'running' + | 'succeeded' + | 'failed' + | 'stale' + | 'purged' + +/** Safe, structured transcript failure information from the application host. */ +export interface HostTranscriptError { + code: string + message: string + retryable: boolean + details?: Readonly> +} + +/** + * Compact source-bound transcript receipt. The host owns authorization and + * transport; FreeCut receives only opaque IDs, a source hash, status, and + * bounded recovery information. + */ +export interface HostTranscriptStatusReceipt { + transcriptId: string + assetId: string | null + sourceAssetHash: string + status: HostTranscriptStatus + language?: string | null + durationUs: Microseconds | null + sectionCount: number + error?: HostTranscriptError | null +} + +/** One bounded, source-addressable transcript section. */ +export interface HostTranscriptSection { + id: string + transcriptId: string + ordinal: number + startUs: Microseconds + endUs: Microseconds + text: string + speaker?: string | null +} + +export interface HostTranscriptSectionsRequest { + transcriptId: string + cursor?: string | null + limit?: number + startUs?: Microseconds + endUs?: Microseconds +} + +export interface HostTranscriptSectionsPage { + transcriptId: string + sections: readonly HostTranscriptSection[] + nextCursor?: string | null + hasMore: boolean +} + +export interface HostTranscriptSearchRequest { + transcriptId: string + query: string + cursor?: string | null + limit?: number +} + +export interface HostTranscriptSearchPage { + transcriptId: string + query: string + sections: readonly HostTranscriptSection[] + nextCursor?: string | null + hasMore: boolean +} + +/** A positive integer-microsecond source range selected for preview. */ +export interface HostTranscriptRange { + startUs: Microseconds + endUs: Microseconds + text?: string +} + +export type HostTranscriptCommandAction = 'cut' | 'captions' | 'ripple_cut' | 'caption' + +/** PR9B request shape. It previews only; it never mutates the host timeline. */ +export interface HostTranscriptCommandPreviewRequest { + transcriptId: string + assetId: string + sourceAssetHash: string + operationId: string + idempotencyKey: string + baseRevision: TimelineRevision + action: HostTranscriptCommandAction + timestampCapability: 'section' | 'word' | 'frame' + sectionIds?: readonly string[] + ranges?: readonly HostTranscriptRange[] + captionTrackId?: string + captionTrackName?: string + captionLanguage?: string | null + preconditions?: readonly Precondition[] +} + +export interface HostTranscriptCommandPreview { + status: 'preview' | 'replayed' + receiptId: string + transcriptId: string + assetId: string + sourceAssetHash: string + timestampCapability: 'section' + timelineId: string + operationId: string + idempotencyKey: string + baseRevision: TimelineRevision + commandBatch: EditCommandBatch + preview: Readonly<{ + action?: string + sectionCount?: number + captionCount?: number + willMutateTimeline: false + [key: string]: unknown + }> +} + +/** + * Optional host-backed transcript consumer port. Implementations may use an + * authenticated API, desktop bridge, or another application-owned transport; + * those details never enter the FreeCut surface. + */ +export interface EditorTranscriptPort { + getStatus(): Promise | HostTranscriptStatusReceipt | null + getSections( + request: HostTranscriptSectionsRequest, + ): Promise | HostTranscriptSectionsPage + search?( + request: HostTranscriptSearchRequest, + ): Promise | HostTranscriptSearchPage + previewCommands( + request: HostTranscriptCommandPreviewRequest, + ): Promise | HostTranscriptCommandPreview +} + export interface HostAppliedEditResult { status: 'applied' | 'replayed' snapshot: EmbeddedEditorSnapshot @@ -136,6 +292,8 @@ export interface EditorHost { locator: MediaLocator, ): Promise | ResolvedMediaLocator | null submitEdit(batch: EditCommandBatch): Promise | HostEditResult + /** Optional application-issued transcript read/preview boundary. */ + transcript?: EditorTranscriptPort navigation?: EditorHostNavigation notify?(notice: HostNotice): void } diff --git a/src/features/editor/host/editor-surface.tsx b/src/features/editor/host/editor-surface.tsx index ff93af22a..cf3b69fb8 100644 --- a/src/features/editor/host/editor-surface.tsx +++ b/src/features/editor/host/editor-surface.tsx @@ -13,6 +13,7 @@ import { } from './contract' import { EditorHostProvider } from './context-provider' import { HostCaptionEditorProvider } from './caption-editor-context' +import { HostTranscriptEditorProvider } from './transcript-editor-context' import { EmbeddedEditorHostRuntime } from './runtime' import '@/index.css' @@ -79,21 +80,23 @@ export function FreeCutEditorSurface({ host }: { host: EditorHost }) { - -
- -
-
+ + +
+ +
+
+
diff --git a/src/features/editor/host/index.ts b/src/features/editor/host/index.ts index d2cb0b20b..10447f0de 100644 --- a/src/features/editor/host/index.ts +++ b/src/features/editor/host/index.ts @@ -7,6 +7,13 @@ export type { EditorHostContextValue } from './context' export type { EditorHostProviderProps } from './context-provider' export { DEFAULT_HOST_CAPABILITIES, + MAX_TRANSCRIPT_CURSOR_LENGTH, + MAX_TRANSCRIPT_COMMAND_TEXT_BYTES, + MAX_TRANSCRIPT_DURATION_US, + MAX_TRANSCRIPT_QUERY_LENGTH, + MAX_TRANSCRIPT_SECTION_PAGE_SIZE, + MAX_TRANSCRIPT_SECTION_TEXT_BYTES, + MAX_TRANSCRIPT_SELECTIONS, SUPPORTED_HOST_COMMANDS, capabilityForCommand, createLocalEditorHost, @@ -20,11 +27,24 @@ export type { EmbeddedEditorAsset, EmbeddedEditorProject, EmbeddedEditorSnapshot, + EditorTranscriptPort, HostAppliedEditResult, HostConflictResult, HostEditResult, HostMediaKind, HostNotice, + HostTranscriptCommandAction, + HostTranscriptCommandPreview, + HostTranscriptCommandPreviewRequest, + HostTranscriptError, + HostTranscriptRange, + HostTranscriptSearchPage, + HostTranscriptSearchRequest, + HostTranscriptSection, + HostTranscriptSectionsPage, + HostTranscriptSectionsRequest, + HostTranscriptStatus, + HostTranscriptStatusReceipt, LocalEditorHostOptions, MediaLocator, ResolvedMediaLocator, diff --git a/src/features/editor/host/transcript-editor-context.tsx b/src/features/editor/host/transcript-editor-context.tsx new file mode 100644 index 000000000..4fbf3bcff --- /dev/null +++ b/src/features/editor/host/transcript-editor-context.tsx @@ -0,0 +1,26 @@ +/* eslint-disable react/only-export-components */ + +import { createContext, useContext, type ReactNode } from 'react' + +import { EmbeddedEditorHostRuntime } from './runtime' + +const HostTranscriptEditorRuntimeContext = createContext(null) + +/** Supplies the already-mounted host runtime to the Media/Transcript surface. */ +export function HostTranscriptEditorProvider({ + runtime, + children, +}: { + runtime: EmbeddedEditorHostRuntime + children: ReactNode +}) { + return ( + + {children} + + ) +} + +export function useHostTranscriptEditorRuntime(): EmbeddedEditorHostRuntime | null { + return useContext(HostTranscriptEditorRuntimeContext) +} diff --git a/src/features/editor/host/transcript-editor.test.tsx b/src/features/editor/host/transcript-editor.test.tsx new file mode 100644 index 000000000..48e8a6249 --- /dev/null +++ b/src/features/editor/host/transcript-editor.test.tsx @@ -0,0 +1,425 @@ +// @vitest-environment jsdom + +import { readFileSync } from 'node:fs' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vite-plus/test' + +import { + controlledDocumentToFreeCutDocument, + createCodePressCommandAdapter, + freeCutDocumentToControlledDocument, + type EditCommandBatch, +} from '@/features/editor/codepress' +import { EditorHostProvider } from './context-provider' +import { + DEFAULT_HOST_CAPABILITIES, + type EditorHost, + type EmbeddedEditorSnapshot, + type HostEditResult, + type HostTranscriptCommandPreview, + type HostTranscriptCommandPreviewRequest, + type HostTranscriptSection, +} from './contract' +import { EmbeddedEditorHostRuntime } from './runtime' +import { HostTranscriptEditor } from './transcript-editor' +import { HostTranscriptEditorProvider } from './transcript-editor-context' + +function snapshot(): EmbeddedEditorSnapshot { + return { + project: { + id: 'host-transcript-project', + name: 'Host transcript project', + width: 1920, + height: 1080, + fps: 30, + }, + timeline: { + timelineId: 'host-transcript-timeline', + revision: 0, + fps: 30, + durationInFrames: 300, + media: [], + tracks: [], + width: 1920, + height: 1080, + }, + assets: [], + } +} + +const sections: HostTranscriptSection[] = [ + { + id: 'transcript-section-1', + transcriptId: 'transcript-1', + ordinal: 0, + startUs: 1_000_000, + endUs: 2_000_000, + text: 'First bounded caption.', + speaker: 'Speaker 1', + }, + { + id: 'transcript-section-2', + transcriptId: 'transcript-1', + ordinal: 1, + startUs: 4_000_000, + endUs: 5_000_000, + text: 'Second bounded caption.', + speaker: 'Speaker 1', + }, +] + +function createHarness( + initial: EmbeddedEditorSnapshot, + transcriptStatus: + | 'pending' + | 'running' + | 'succeeded' + | 'failed' + | 'stale' + | 'purged' = 'succeeded', + previewStatus: 'preview' | 'replayed' = 'preview', + conflictOnApply = false, + previewTimestampCapability: 'section' | 'word' = 'section', +) { + const remoteAdapter = createCodePressCommandAdapter({ + document: freeCutDocumentToControlledDocument(initial.timeline), + }) + let remoteSnapshot = initial + const submitEdit = vi.fn(async (batch: EditCommandBatch): Promise => { + if (conflictOnApply) { + return { + status: 'conflict', + snapshot: remoteSnapshot, + result: { + status: 'rejected', + timeline_id: initial.timeline.timelineId, + operation_id: batch.operation_id, + idempotency_key: batch.idempotency_key, + base_revision: batch.base_revision, + error: { + code: 'revision_conflict', + message: 'The timeline changed before this transcript edit was applied.', + retryable: true, + operation_id: batch.operation_id, + details: { + kind: 'revision_conflict', + base_revision: batch.base_revision, + current_revision: batch.base_revision + 1, + rebase: { + status: 'required', + automatic: false, + strategy: 'refresh_then_resubmit', + retry_with_revision: batch.base_revision + 1, + }, + }, + }, + }, + } + } + const result = remoteAdapter.apply(batch) + if (result.status === 'rejected') { + return { + status: 'rejected', + snapshot: remoteSnapshot, + result, + } + } + remoteSnapshot = { + ...remoteSnapshot, + timeline: controlledDocumentToFreeCutDocument(remoteAdapter.getDocument()), + } + return { status: result.status, snapshot: remoteSnapshot, result } + }) + + const previewBatch: EditCommandBatch = { + contract_version: 1, + timeline_id: initial.timeline.timelineId, + operation_id: 'transcript-preview-operation', + idempotency_key: 'transcript-preview-idempotency', + base_revision: 0, + preconditions: [], + commands: [ + { + command_id: 'add-transcript-track', + type: 'add_caption_track', + track_id: 'host-transcript-captions', + name: 'Transcript captions', + language: 'en', + index: 0, + }, + { + command_id: 'upsert-transcript-cues', + type: 'upsert_caption_cues', + track_id: 'host-transcript-captions', + cues: [ + { + item_type: 'caption_cue', + cue_id: 'transcript-cue-1', + track_id: 'host-transcript-captions', + start_us: sections[0]!.startUs, + end_us: sections[0]!.endUs, + text: sections[0]!.text, + speaker: sections[0]!.speaker, + }, + ], + }, + ], + } + + const previewCommands = vi.fn( + async ( + _request: HostTranscriptCommandPreviewRequest, + ): Promise => ({ + status: previewStatus, + receiptId: 'transcript-receipt-1', + transcriptId: 'transcript-1', + assetId: 'asset-1', + sourceAssetHash: 'sha256:source-1', + timestampCapability: + previewTimestampCapability as HostTranscriptCommandPreview['timestampCapability'], + timelineId: initial.timeline.timelineId, + operationId: previewBatch.operation_id, + idempotencyKey: previewBatch.idempotency_key, + baseRevision: 0, + commandBatch: previewBatch, + preview: { + action: 'captions', + captionCount: 1, + willMutateTimeline: false, + }, + }), + ) + + const host: EditorHost = { + capabilities: { + ...DEFAULT_HOST_CAPABILITIES, + 'media.transcription': true, + 'timeline.caption': true, + }, + load: () => initial, + resolveMedia: () => null, + submitEdit, + transcript: { + getStatus: vi.fn(() => + transcriptStatus === 'succeeded' + ? { + transcriptId: 'transcript-1', + assetId: 'asset-1', + sourceAssetHash: 'sha256:source-1', + status: 'succeeded' as const, + language: 'en', + durationUs: 10_000_000, + sectionCount: sections.length, + error: null, + } + : { + transcriptId: 'transcript-1', + assetId: 'asset-1', + sourceAssetHash: 'sha256:source-1', + status: transcriptStatus, + language: 'en', + durationUs: 10_000_000, + sectionCount: 0, + error: + transcriptStatus === 'pending' || transcriptStatus === 'running' + ? { + code: 'transcript_not_ready', + message: 'The transcript is not ready for command conversion.', + retryable: true, + } + : { + code: 'transcript_content_unavailable', + message: 'Transcript sections are no longer available.', + retryable: false, + }, + }, + ), + getSections: vi.fn(() => ({ + transcriptId: 'transcript-1', + sections, + hasMore: false, + nextCursor: null, + })), + previewCommands, + }, + } + return { + host, + runtime: new EmbeddedEditorHostRuntime(host, initial), + submitEdit, + previewCommands, + } +} + +function renderHostEditor(harness: ReturnType) { + return render( + + + + + , + ) +} + +afterEach(() => cleanup()) + +describe('host-backed transcript consumer', () => { + it('displays bounded sections, previews without mutation, then applies through submitEdit', async () => { + const harness = createHarness(snapshot()) + renderHostEditor(harness) + + expect( + await screen.findByTestId('host-transcript-section-transcript-section-1'), + ).toBeInTheDocument() + fireEvent.click(screen.getByTestId('host-transcript-section-transcript-section-1')) + expect(screen.getByTestId('host-transcript-preview-button')).not.toBeDisabled() + + fireEvent.click(screen.getByTestId('host-transcript-preview-button')) + await waitFor(() => expect(screen.getByTestId('host-transcript-preview')).toBeInTheDocument()) + + expect(harness.previewCommands).toHaveBeenCalledWith( + expect.objectContaining({ + transcriptId: 'transcript-1', + assetId: 'asset-1', + sourceAssetHash: 'sha256:source-1', + timestampCapability: 'section', + ranges: [ + { + startUs: 1_000_000, + endUs: 2_000_000, + text: 'First bounded caption.', + }, + ], + }), + ) + expect(harness.submitEdit).not.toHaveBeenCalled() + expect(JSON.stringify(harness.previewCommands.mock.calls[0]?.[0])).not.toMatch( + /https?:|\/Users\/|Bearer|provider|upload_url|media_bytes/i, + ) + + fireEvent.click(screen.getByTestId('host-transcript-apply')) + await waitFor(() => expect(harness.submitEdit).toHaveBeenCalledTimes(1)) + expect(harness.submitEdit.mock.calls[0]?.[0].commands).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'add_caption_track' }), + expect.objectContaining({ type: 'upsert_caption_cues' }), + ]), + ) + }) + + it('surfaces replay receipts and clears a stale preview on apply conflict', async () => { + const replayed = createHarness(snapshot(), 'succeeded', 'replayed') + renderHostEditor(replayed) + expect( + await screen.findByTestId('host-transcript-section-transcript-section-1'), + ).toBeInTheDocument() + fireEvent.click(screen.getByTestId('host-transcript-section-transcript-section-1')) + fireEvent.click(screen.getByTestId('host-transcript-preview-button')) + await waitFor(() => expect(screen.getByText('Preview replayed safely.')).toBeInTheDocument()) + + cleanup() + const stale = createHarness(snapshot(), 'succeeded', 'preview', true) + renderHostEditor(stale) + expect( + await screen.findByTestId('host-transcript-section-transcript-section-1'), + ).toBeInTheDocument() + fireEvent.click(screen.getByTestId('host-transcript-section-transcript-section-1')) + fireEvent.click(screen.getByTestId('host-transcript-preview-button')) + await waitFor(() => expect(screen.getByTestId('host-transcript-preview')).toBeInTheDocument()) + fireEvent.click(screen.getByTestId('host-transcript-apply')) + await waitFor(() => + expect(screen.getByTestId('host-transcript-error')).toHaveTextContent( + 'timeline changed before this transcript edit was applied', + ), + ) + expect(screen.queryByTestId('host-transcript-preview')).not.toBeInTheDocument() + }) + + it('fails closed for unsupported timestamp previews and oversized pages', async () => { + const unsupported = createHarness(snapshot(), 'succeeded', 'preview', false, 'word') + renderHostEditor(unsupported) + expect( + await screen.findByTestId('host-transcript-section-transcript-section-1'), + ).toBeInTheDocument() + fireEvent.click(screen.getByTestId('host-transcript-section-transcript-section-1')) + fireEvent.click(screen.getByTestId('host-transcript-preview-button')) + await waitFor(() => + expect(screen.getByTestId('host-transcript-error')).toHaveTextContent( + 'could not be prepared', + ), + ) + expect(unsupported.submitEdit).not.toHaveBeenCalled() + + cleanup() + const oversized = createHarness(snapshot()) + oversized.host.transcript!.getSections = vi.fn(() => ({ + transcriptId: 'transcript-1', + sections: Array.from({ length: 51 }, (_, index) => ({ + ...sections[0]!, + id: `oversized-section-${index}`, + ordinal: index, + startUs: index * 1_000, + endUs: index * 1_000 + 500, + })), + hasMore: false, + nextCursor: null, + })) + renderHostEditor(oversized) + expect(await screen.findByTestId('host-transcript-unavailable')).toHaveTextContent( + 'could not be loaded', + ) + expect(oversized.previewCommands).not.toHaveBeenCalled() + }) + + it('keeps pending and running states retryable and terminal states fail closed', async () => { + const states = [ + { status: 'pending' as const, retryable: true, message: 'not ready for command conversion' }, + { status: 'running' as const, retryable: true, message: 'not ready for command conversion' }, + { status: 'failed' as const, retryable: false, message: 'no longer available' }, + { status: 'stale' as const, retryable: false, message: 'no longer available' }, + { status: 'purged' as const, retryable: false, message: 'no longer available' }, + ] + + for (const state of states) { + const harness = createHarness(snapshot(), state.status) + renderHostEditor(harness) + expect(await screen.findByTestId('host-transcript-unavailable')).toHaveTextContent( + state.message, + ) + if (state.retryable) { + expect(screen.getByTestId('host-transcript-retry')).toBeInTheDocument() + } else { + expect(screen.queryByTestId('host-transcript-retry')).not.toBeInTheDocument() + } + expect(harness.host.transcript?.getSections).not.toHaveBeenCalled() + cleanup() + } + }) + + it('fails closed for malformed host sections and does not import local transcript services', async () => { + const harness = createHarness(snapshot()) + harness.host.transcript!.getSections = vi.fn(() => ({ + transcriptId: 'transcript-1', + sections: [ + { + ...sections[0]!, + endUs: sections[0]!.startUs, + }, + ], + hasMore: false, + nextCursor: null, + })) + renderHostEditor(harness) + expect(await screen.findByTestId('host-transcript-unavailable')).toHaveTextContent( + 'could not be loaded', + ) + expect(harness.previewCommands).not.toHaveBeenCalled() + + const source = readFileSync('src/features/editor/host/transcript-editor.tsx', 'utf8') + expect(source).not.toMatch( + /mediaTranscriptionService|useTranscriptIgnoreStore|loadTimeline|saveTimeline/, + ) + }) +}) diff --git a/src/features/editor/host/transcript-editor.tsx b/src/features/editor/host/transcript-editor.tsx new file mode 100644 index 000000000..466058eae --- /dev/null +++ b/src/features/editor/host/transcript-editor.tsx @@ -0,0 +1,903 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { AlertCircle, CheckCircle2, ChevronDown, Loader2, RefreshCw, Search } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import type { EditCommandBatch, EditCommand } from '@/features/editor/codepress/contract' +import { validateCommandBatch } from '@/features/editor/codepress/contract' +import { + capabilityForCommand, + isHostCapabilityEnabled, + MAX_TRANSCRIPT_COMMAND_TEXT_BYTES, + MAX_TRANSCRIPT_CURSOR_LENGTH, + MAX_TRANSCRIPT_DURATION_US, + MAX_TRANSCRIPT_QUERY_LENGTH, + MAX_TRANSCRIPT_SECTION_PAGE_SIZE, + MAX_TRANSCRIPT_SECTION_TEXT_BYTES, + MAX_TRANSCRIPT_SELECTIONS, + type EditorCapabilityMap, + type HostTranscriptCommandPreview, + type HostTranscriptError, + type HostTranscriptRange, + type HostTranscriptSection, + type HostTranscriptSectionsPage, + type HostTranscriptStatusReceipt, + type HostTranscriptStatus, +} from './contract' +import { useEditorHostContext } from './context' +import { useHostTranscriptEditorRuntime } from './transcript-editor-context' + +const OPAQUE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u +const SAFE_HASH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u +const TRANSCRIPT_STATUSES: readonly HostTranscriptStatus[] = [ + 'pending', + 'running', + 'succeeded', + 'failed', + 'stale', + 'purged', +] + +interface TranscriptUiError extends HostTranscriptError { + source: 'host' | 'validation' | 'submission' +} + +interface NormalizedPage extends HostTranscriptSectionsPage { + nextCursor: string | null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isOpaqueId(value: unknown): value is string { + return typeof value === 'string' && OPAQUE_ID_PATTERN.test(value) +} + +function isSafeHash(value: unknown): value is string { + return typeof value === 'string' && SAFE_HASH_PATTERN.test(value) +} + +function boundedText(value: unknown, maxBytes: number): value is string { + return ( + typeof value === 'string' && + value.trim().length > 0 && + new TextEncoder().encode(value).byteLength <= maxBytes + ) +} + +// fallow-ignore-next-line complexity +function errorFromHost( + value: unknown, + fallback: string, + source: TranscriptUiError['source'] = 'host', +): TranscriptUiError { + if (isRecord(value)) { + const nested = isRecord(value.error) ? value.error : value + const code = typeof nested.code === 'string' && nested.code.length <= 128 ? nested.code : null + const message = + typeof nested.message === 'string' && + nested.message.length > 0 && + nested.message.length <= 512 + ? nested.message + : null + const retryable = typeof nested.retryable === 'boolean' ? nested.retryable : false + if (code && message) { + return { + code, + message, + retryable, + ...(isRecord(nested.details) ? { details: nested.details } : {}), + source, + } + } + } + return { code: 'transcript_unavailable', message: fallback, retryable: false, source } +} + +function unavailableError(status: HostTranscriptStatus): TranscriptUiError { + if (status === 'pending' || status === 'running') { + return { + code: 'transcript_not_ready', + message: 'The transcript is not ready yet. Retry when processing finishes.', + retryable: true, + source: 'host', + } + } + return { + code: 'transcript_content_unavailable', + message: 'Transcript content is no longer available.', + retryable: false, + source: 'host', + } +} + +// fallow-ignore-next-line complexity +function normalizeStatus(value: unknown): HostTranscriptStatusReceipt | null { + if (value === null) return null + if (!isRecord(value)) throw new Error('The host returned an invalid transcript status.') + + const status = value.status + const transcriptId = value.transcriptId + const assetId = value.assetId + const sourceAssetHash = value.sourceAssetHash + const durationUs = value.durationUs + const sectionCount = value.sectionCount + const validDuration = + durationUs === null || + (typeof durationUs === 'number' && + Number.isSafeInteger(durationUs) && + durationUs >= 0 && + durationUs <= MAX_TRANSCRIPT_DURATION_US) + const validSectionCount = + typeof sectionCount === 'number' && Number.isSafeInteger(sectionCount) && sectionCount >= 0 + if ( + typeof status !== 'string' || + !TRANSCRIPT_STATUSES.includes(status as HostTranscriptStatus) || + !isOpaqueId(transcriptId) || + (assetId !== null && !isOpaqueId(assetId)) || + !isSafeHash(sourceAssetHash) || + !validDuration || + !validSectionCount + ) { + throw new Error('The host returned an invalid transcript status.') + } + if (status === 'succeeded' && !isOpaqueId(assetId)) { + throw new Error('The host returned a succeeded transcript without an asset binding.') + } + + const rawLanguage = value.language + const language = + rawLanguage === null || rawLanguage === undefined + ? null + : typeof rawLanguage === 'string' && rawLanguage.length <= 32 + ? rawLanguage + : null + const normalizedError = + value.error === null || value.error === undefined + ? status === 'succeeded' + ? null + : unavailableError(status as HostTranscriptStatus) + : errorFromHost(value.error, 'Transcript content is unavailable.') + const error = + normalizedError && status !== 'succeeded' && status !== 'pending' && status !== 'running' + ? { ...normalizedError, retryable: false } + : normalizedError + + return { + transcriptId, + assetId, + sourceAssetHash, + status: status as HostTranscriptStatus, + language, + durationUs: durationUs as number | null, + sectionCount: sectionCount as number, + error, + } +} + +// fallow-ignore-next-line complexity +function normalizeSection(value: unknown, expectedTranscriptId: string): HostTranscriptSection { + if (!isRecord(value)) throw new Error('The host returned an invalid transcript section.') + const { id, transcriptId, ordinal, startUs, endUs, text, speaker } = value + const validOrdinal = typeof ordinal === 'number' && Number.isSafeInteger(ordinal) && ordinal >= 0 + const validStartUs = typeof startUs === 'number' && Number.isSafeInteger(startUs) && startUs >= 0 + const validEndUs = typeof endUs === 'number' && Number.isSafeInteger(endUs) + if ( + !isOpaqueId(id) || + transcriptId !== expectedTranscriptId || + !validOrdinal || + !validStartUs || + !validEndUs || + (validStartUs && validEndUs && endUs <= startUs) || + (validEndUs && endUs > MAX_TRANSCRIPT_DURATION_US) || + !boundedText(text, MAX_TRANSCRIPT_SECTION_TEXT_BYTES) + ) { + throw new Error('The host returned an invalid transcript section.') + } + if ( + speaker !== undefined && + speaker !== null && + (typeof speaker !== 'string' || speaker.length > MAX_TRANSCRIPT_SECTION_TEXT_BYTES) + ) { + throw new Error('The host returned an invalid transcript speaker.') + } + return { + id, + transcriptId, + ordinal: ordinal as number, + startUs: startUs as number, + endUs: endUs as number, + text, + speaker: speaker ?? null, + } +} + +// fallow-ignore-next-line complexity +function normalizePage(value: unknown, expectedTranscriptId: string): NormalizedPage { + if (!isRecord(value) || value.transcriptId !== expectedTranscriptId) { + throw new Error('The host returned an invalid transcript section page.') + } + if (!Array.isArray(value.sections) || value.sections.length > MAX_TRANSCRIPT_SECTION_PAGE_SIZE) { + throw new Error('The host returned an oversized transcript section page.') + } + if (typeof value.hasMore !== 'boolean') { + throw new Error('The host returned an invalid transcript cursor state.') + } + const rawCursor = value.nextCursor + const nextCursor = rawCursor === null || rawCursor === undefined ? null : rawCursor + if ( + nextCursor !== null && + (typeof nextCursor !== 'string' || + nextCursor.length === 0 || + nextCursor.length > MAX_TRANSCRIPT_CURSOR_LENGTH) + ) { + throw new Error('The host returned an invalid transcript cursor.') + } + if (value.hasMore && nextCursor === null) { + throw new Error('The host returned an uncontinuable transcript cursor.') + } + const sections = value.sections.map((section) => normalizeSection(section, expectedTranscriptId)) + const ids = new Set() + for (const section of sections) { + if (ids.has(section.id)) throw new Error('The host returned duplicate transcript sections.') + ids.add(section.id) + } + return { + transcriptId: expectedTranscriptId, + sections, + hasMore: value.hasMore, + nextCursor, + } +} + +// fallow-ignore-next-line complexity +function normalizeCommandBatch(value: unknown, expectedRevision: number): EditCommandBatch { + if (!isRecord(value)) throw new Error('The host returned an invalid transcript command batch.') + if ( + value.base_revision !== expectedRevision || + !isOpaqueId(value.timeline_id) || + !isOpaqueId(value.operation_id) || + !isOpaqueId(value.idempotency_key) + ) { + throw new Error('The host returned an invalid transcript command batch.') + } + const validation = validateCommandBatch(value) + if (!validation.ok) throw new Error('The host returned an invalid transcript command batch.') + return validation.value +} + +// fallow-ignore-next-line complexity +function normalizePreview( + value: unknown, + expected: { + transcriptId: string + assetId: string + sourceAssetHash: string + timelineId: string + baseRevision: number + }, +): HostTranscriptCommandPreview { + if (!isRecord(value)) throw new Error('The host returned an invalid transcript preview.') + const previewValue = isRecord(value.preview) ? value.preview : null + const willMutateTimeline = previewValue + ? (previewValue.willMutateTimeline ?? previewValue.will_mutate_timeline) + : undefined + if ( + (value.status !== 'preview' && value.status !== 'replayed') || + !isOpaqueId(value.receiptId) || + value.transcriptId !== expected.transcriptId || + value.assetId !== expected.assetId || + value.sourceAssetHash !== expected.sourceAssetHash || + value.timestampCapability !== 'section' || + value.timelineId !== expected.timelineId || + !isOpaqueId(value.operationId) || + !isOpaqueId(value.idempotencyKey) || + value.baseRevision !== expected.baseRevision || + previewValue === null || + willMutateTimeline !== false + ) { + throw new Error('The host returned an invalid transcript preview.') + } + const commandBatch = normalizeCommandBatch(value.commandBatch, expected.baseRevision) + if ( + commandBatch.timeline_id !== expected.timelineId || + commandBatch.operation_id !== value.operationId || + commandBatch.idempotency_key !== value.idempotencyKey + ) { + throw new Error('The host returned a transcript preview for a different operation.') + } + return { + status: value.status, + receiptId: value.receiptId, + transcriptId: value.transcriptId, + assetId: value.assetId, + sourceAssetHash: value.sourceAssetHash, + timestampCapability: 'section', + timelineId: value.timelineId, + operationId: value.operationId, + idempotencyKey: value.idempotencyKey, + baseRevision: value.baseRevision, + commandBatch, + preview: { + ...(previewValue as HostTranscriptCommandPreview['preview']), + willMutateTimeline: false, + }, + } +} + +function newOperationId(prefix: string): string { + const suffix = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : Math.random().toString(36).slice(2) + return `${prefix}-${suffix}` +} + +function formatTimecode(microseconds: number): string { + const totalSeconds = Math.max(0, Math.floor(microseconds / 1_000_000)) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return `${minutes}:${String(seconds).padStart(2, '0')}` +} + +function rangesForSelection(sections: readonly HostTranscriptSection[]): HostTranscriptRange[] { + const ordered = [...sections].sort((left, right) => left.ordinal - right.ordinal) + const ranges: HostTranscriptRange[] = [] + let totalTextBytes = 0 + let previous: HostTranscriptSection | undefined + for (const section of ordered) { + if (section.endUs <= section.startUs || section.endUs > MAX_TRANSCRIPT_DURATION_US) { + throw new Error('The selected transcript range is invalid.') + } + if (previous && section.startUs < previous.endUs) { + throw new Error('The selected transcript ranges overlap.') + } + totalTextBytes += new TextEncoder().encode(section.text).byteLength + if (totalTextBytes > MAX_TRANSCRIPT_COMMAND_TEXT_BYTES) { + throw new Error('The selected transcript text exceeds the bounded command limit.') + } + ranges.push({ startUs: section.startUs, endUs: section.endUs, text: section.text }) + previous = section + } + if (ranges.length === 0) throw new Error('Select at least one transcript section.') + if (ranges.length > MAX_TRANSCRIPT_SELECTIONS) { + throw new Error(`Select no more than ${MAX_TRANSCRIPT_SELECTIONS} transcript sections.`) + } + return ranges +} + +function commandIsSupported(command: EditCommand, capabilities: EditorCapabilityMap): boolean { + const capability = capabilityForCommand(command.type) + return capability !== null && isHostCapabilityEnabled(capabilities, capability) +} + +function UnavailableTranscript({ + error, + onRetry, +}: { + error: TranscriptUiError + onRetry?: () => void +}) { + return ( +
+ +
+

{error.message}

+

{error.code}

+
+ {error.retryable && onRetry ? ( + + ) : null} +
+ ) +} + +/** + * Host-mode transcript consumer for the real Media/Transcript sidebar path. + * This component intentionally has no dependency on FreeCut transcript stores, + * transcription services, IndexedDB, OPFS, workspace handles, or project save + * paths. Preview is held locally; only Apply crosses the host edit port. + */ +// fallow-ignore-next-line complexity +export function HostTranscriptEditor({ active = true }: { active?: boolean }) { + const { t } = useTranslation() + const { capabilities, host } = useEditorHostContext() + const runtime = useHostTranscriptEditorRuntime() + const port = host?.transcript + const canTranscribe = isHostCapabilityEnabled(capabilities, 'media.transcription') + + const [status, setStatus] = useState(null) + const [sections, setSections] = useState([]) + const [nextCursor, setNextCursor] = useState(null) + const [hasMore, setHasMore] = useState(false) + const [loading, setLoading] = useState(false) + const [loadingMore, setLoadingMore] = useState(false) + const [error, setError] = useState(null) + const [query, setQuery] = useState('') + const [selectedIds, setSelectedIds] = useState>(() => new Set()) + const [selectionAnchor, setSelectionAnchor] = useState(null) + const [preview, setPreview] = useState(null) + const [previewing, setPreviewing] = useState(false) + const [applying, setApplying] = useState(false) + const [announcement, setAnnouncement] = useState('') + const requestGeneration = useRef(0) + const loadedSectionCount = useRef(0) + + const loadSectionsPage = useCallback( + async ( + receipt: HostTranscriptStatusReceipt, + cursor: string | null, + replace: boolean, + ): Promise => { + if (!port) return + const page = normalizePage( + await port.getSections({ + transcriptId: receipt.transcriptId, + cursor, + limit: MAX_TRANSCRIPT_SECTION_PAGE_SIZE, + }), + receipt.transcriptId, + ) + const durationUs = receipt.durationUs + if ( + (durationUs !== null && page.sections.some((section) => section.endUs > durationUs)) || + (!replace && + page.sections.length + loadedSectionCount.current > + MAX_TRANSCRIPT_SELECTIONS * MAX_TRANSCRIPT_SECTION_PAGE_SIZE) + ) { + throw new Error('The host returned transcript sections outside the bounded transcript.') + } + setSections((current) => { + const next = replace ? [] : [...current] + const ids = new Set(next.map((section) => section.id)) + for (const section of page.sections) { + if (!ids.has(section.id)) next.push(section) + } + const sorted = next.toSorted((left, right) => left.ordinal - right.ordinal) + loadedSectionCount.current = sorted.length + return sorted + }) + setNextCursor(page.nextCursor) + setHasMore(page.hasMore) + }, + [port], + ) + + // fallow-ignore-next-line complexity + const refresh = useCallback(async () => { + if (!port || !canTranscribe) return + const generation = requestGeneration.current + 1 + requestGeneration.current = generation + setLoading(true) + setError(null) + setPreview(null) + setSelectedIds(new Set()) + setSelectionAnchor(null) + loadedSectionCount.current = 0 + try { + const receipt = normalizeStatus(await port.getStatus()) + if (requestGeneration.current !== generation) return + setStatus(receipt) + setSections([]) + setNextCursor(null) + setHasMore(false) + if (!receipt) { + setError({ + code: 'transcript_unavailable', + message: 'No application transcript is available for this project.', + retryable: false, + source: 'host', + }) + return + } + if (receipt.status !== 'succeeded') { + setError( + errorFromHost( + receipt.error ?? unavailableError(receipt.status), + 'Transcript content is unavailable.', + ), + ) + return + } + await loadSectionsPage(receipt, null, true) + } catch (caught) { + if (requestGeneration.current !== generation) return + setStatus(null) + setSections([]) + loadedSectionCount.current = 0 + setError(errorFromHost(caught, 'The transcript could not be loaded.')) + } finally { + if (requestGeneration.current === generation) setLoading(false) + } + }, [canTranscribe, loadSectionsPage, port]) + + useEffect(() => { + if (!active || !port || !canTranscribe) return + void refresh() + }, [active, canTranscribe, port, refresh]) + + const loadMore = useCallback(async () => { + if (!status || status.status !== 'succeeded' || !nextCursor || !port || loadingMore) return + setLoadingMore(true) + try { + await loadSectionsPage(status, nextCursor, false) + } catch (caught) { + setError(errorFromHost(caught, 'More transcript sections could not be loaded.')) + } finally { + setLoadingMore(false) + } + }, [loadSectionsPage, loadingMore, nextCursor, port, status]) + + const normalizedQuery = query.trim().toLowerCase() + const visibleSections = useMemo(() => { + if (!normalizedQuery) return sections + return sections.filter((section) => { + const haystack = `${section.text} ${section.speaker ?? ''}`.toLowerCase() + return haystack.includes(normalizedQuery) + }) + }, [normalizedQuery, sections]) + + const selectedSections = useMemo( + () => sections.filter((section) => selectedIds.has(section.id)), + [sections, selectedIds], + ) + + const selectSection = useCallback( + (index: number, shiftKey: boolean) => { + const section = visibleSections[index] + if (!section) return + setSelectedIds((current) => { + const next = new Set(current) + if (shiftKey && selectionAnchor !== null) { + const lo = Math.min(selectionAnchor, index) + const hi = Math.min(visibleSections.length - 1, Math.max(selectionAnchor, index)) + for (const candidate of visibleSections.slice(lo, hi + 1)) { + if (next.size >= MAX_TRANSCRIPT_SELECTIONS && !next.has(candidate.id)) break + next.add(candidate.id) + } + } else if (next.has(section.id)) { + next.delete(section.id) + } else if (next.size < MAX_TRANSCRIPT_SELECTIONS) { + next.add(section.id) + } + return next + }) + setSelectionAnchor(index) + setPreview(null) + setError(null) + }, + [selectionAnchor, visibleSections], + ) + + // fallow-ignore-next-line complexity + const previewSelection = useCallback(async () => { + if (!port || !runtime || !status || status.status !== 'succeeded' || previewing) return + if (!isOpaqueId(status.assetId)) { + setError({ + code: 'transcript_content_unavailable', + message: 'The succeeded transcript no longer has an asset binding.', + retryable: false, + source: 'validation', + }) + return + } + setPreviewing(true) + setError(null) + setAnnouncement('Preparing a non-mutating transcript preview…') + try { + const ranges = rangesForSelection(selectedSections) + const currentSnapshot = runtime.controller.getSnapshot() + const request = { + transcriptId: status.transcriptId, + assetId: status.assetId, + sourceAssetHash: status.sourceAssetHash, + operationId: newOperationId('transcript-preview'), + idempotencyKey: newOperationId('transcript-preview-key'), + baseRevision: currentSnapshot.timeline.revision, + action: 'captions' as const, + timestampCapability: 'section' as const, + ranges, + captionTrackId: 'host-transcript-captions', + captionTrackName: 'Transcript captions', + captionLanguage: status.language ?? null, + preconditions: [], + } + const result = normalizePreview(await port.previewCommands(request), { + transcriptId: status.transcriptId, + assetId: status.assetId, + sourceAssetHash: status.sourceAssetHash, + timelineId: currentSnapshot.timeline.timelineId, + baseRevision: currentSnapshot.timeline.revision, + }) + if ( + result.commandBatch.commands.some((command) => !commandIsSupported(command, capabilities)) + ) { + throw new Error('The transcript preview contains an unsupported timeline command.') + } + setPreview(result) + setAnnouncement( + result.status === 'replayed' + ? 'Transcript preview replayed safely.' + : 'Transcript preview ready. The timeline was not changed.', + ) + } catch (caught) { + setPreview(null) + const nextError = + caught instanceof Error && caught.message.startsWith('Select') + ? errorFromHost( + { code: 'invalid_selection', message: caught.message, retryable: false }, + caught.message, + 'validation', + ) + : errorFromHost( + caught, + 'The transcript preview could not be prepared.', + caught instanceof Error ? 'validation' : 'host', + ) + setError(nextError) + setAnnouncement(nextError.message) + } finally { + setPreviewing(false) + } + }, [capabilities, port, previewing, runtime, selectedSections, status]) + + // fallow-ignore-next-line complexity + const applyPreview = useCallback(async () => { + if (!runtime || !preview || applying) return + setApplying(true) + setError(null) + setAnnouncement('Applying transcript captions…') + try { + const result = await runtime.controller.submitEdit(preview.commandBatch) + if (result.status === 'applied' || result.status === 'replayed') { + setAnnouncement( + result.status === 'replayed' + ? 'Transcript captions replayed safely.' + : 'Transcript captions applied.', + ) + return + } + const message = + result.status === 'unsupported' + ? result.reason + : result.status === 'conflict' || result.status === 'rejected' + ? result.result.error.message || 'The transcript caption edit was rejected.' + : 'The transcript caption edit was rejected.' + setPreview(null) + const nextError = errorFromHost( + { + code: result.status === 'conflict' ? 'revision_conflict' : 'transcript_apply_rejected', + message, + retryable: result.status === 'conflict', + }, + message, + 'submission', + ) + setError(nextError) + setAnnouncement(message) + } catch (caught) { + setPreview(null) + const nextError = errorFromHost( + caught, + 'The transcript caption edit could not be applied.', + 'submission', + ) + setError(nextError) + setAnnouncement(nextError.message) + } finally { + setApplying(false) + } + }, [applying, preview, runtime]) + + if (!port || !runtime || !canTranscribe) { + return ( + + ) + } + + if (loading) { + return ( +
+ + {t('transcript.loading', { defaultValue: 'Loading transcript…' })} +
+ ) + } + + if (error && (!status || status.status !== 'succeeded')) { + return + } + + return ( +
+
+
+ + { + const next = event.target.value + if (next.length <= MAX_TRANSCRIPT_QUERY_LENGTH) setQuery(next) + }} + placeholder={t('transcript.searchPlaceholder', { defaultValue: 'Search transcript' })} + aria-label={t('transcript.searchPlaceholder', { defaultValue: 'Search transcript' })} + className="h-8 pl-7 text-xs" + /> +
+ +
+ +
+ {status?.status} + + {selectedIds.size}/{MAX_TRANSCRIPT_SELECTIONS} selected + +
+ + {error ? ( +
+ + {error.message} + {error.retryable ? ( + + ) : null} +
+ ) : null} + +
+ {visibleSections.length === 0 ? ( +
+ {normalizedQuery + ? 'No transcript sections match this search.' + : 'No transcript sections are available.'} +
+ ) : ( +
+ {visibleSections.map((section, index) => { + const selected = selectedIds.has(section.id) + return ( + + ) + })} +
+ )} + {hasMore ? ( +
+ +
+ ) : null} +
+ + {preview ? ( +
+
+ +
+

+ {preview.status === 'replayed' ? 'Preview replayed safely.' : 'Preview ready.'} +

+

+ {preview.preview.captionCount ?? selectedIds.size} caption(s) · timeline unchanged +

+
+
+ +
+ ) : null} + +
+ + {selectedIds.size > 0 + ? `${selectedIds.size} section${selectedIds.size === 1 ? '' : 's'} selected` + : 'Select sections to preview captions'} + + +
+ + + {announcement} + +
+ ) +} From 431988e18379caceb4cf196c86a9f1b3050fbe52 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Wed, 19 Aug 2026 04:23:35 -0700 Subject: [PATCH 2/2] fix(editor): keep host-visible sidebar tab across authoritative snapshots Applying a transcript edit installs the authoritative snapshot, and the host runtime reset the active sidebar tab to Media on every install. That unmounted the transcript panel before it could show its applied state or its inline revision-conflict error (the conflict only reached the user through the host notify channel). Preserve the active tab when host mode still shows it (media always, text/transcript per capability and port), and keep resetting tabs host mode does not expose. Cover the applied and conflict outcomes through the real MediaSidebar path. --- src/features/editor/host/runtime.ts | 25 ++++- .../editor/host/transcript-editor.test.tsx | 94 ++++++++++++++++++- 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/src/features/editor/host/runtime.ts b/src/features/editor/host/runtime.ts index dd7b96461..7d2960ee2 100644 --- a/src/features/editor/host/runtime.ts +++ b/src/features/editor/host/runtime.ts @@ -15,7 +15,12 @@ import { usePlaybackStore } from '@/shared/state/playback' import { useEditorStore } from '@/shared/state/editor' import { useSelectionStore } from '@/shared/state/selection' import { useGizmoStore, useMaskEditorStore } from '@/features/editor/deps/preview' -import type { EmbeddedEditorSnapshot, EditorHost, HostNotice } from './contract' +import { + isHostCapabilityEnabled, + type EmbeddedEditorSnapshot, + type EditorHost, + type HostNotice, +} from './contract' import { HostEditorController, deriveSupportedHostEdit } from './controller' import { hostAssetsToMediaMetadata, @@ -120,8 +125,22 @@ export class EmbeddedEditorHostRuntime implements EmbeddedEditorHostRuntimeContr useProjectStore.getState().setCurrentProject(hostSnapshotToProject(snapshot)) // The first host slice exposes the normal Edit layout only. Set this // in memory so a local user's persisted Color/Motion preference cannot - // activate an unsupported host workspace. - useEditorStore.setState({ workspace: 'edit', activeTab: 'media' }) + // activate an unsupported host workspace. Preserve the active sidebar + // tab when host mode still shows it: resetting it on every authoritative + // snapshot would unmount the transcript panel mid-apply, losing its + // applied/conflict state before the user can see it. + const currentTab = useEditorStore.getState().activeTab + const currentTabVisibleInHostMode = + currentTab === 'media' || + (currentTab === 'text' && + isHostCapabilityEnabled(this.host.capabilities, 'timeline.add')) || + (currentTab === 'transcript' && + isHostCapabilityEnabled(this.host.capabilities, 'media.transcription') && + this.host.transcript !== undefined) + useEditorStore.setState({ + workspace: 'edit', + activeTab: currentTabVisibleInHostMode ? currentTab : 'media', + }) const mediaItems = hostAssetsToMediaMetadata(snapshot.assets) useMediaLibraryStore.getState().setCurrentProject(snapshot.project.id) diff --git a/src/features/editor/host/transcript-editor.test.tsx b/src/features/editor/host/transcript-editor.test.tsx index 48e8a6249..8ae078735 100644 --- a/src/features/editor/host/transcript-editor.test.tsx +++ b/src/features/editor/host/transcript-editor.test.tsx @@ -1,15 +1,27 @@ // @vitest-environment jsdom import { readFileSync } from 'node:fs' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vite-plus/test' +// The transcript tab renders inside the real MediaSidebar; the media library +// grid is unrelated to the tab lifecycle and stays stubbed out. +vi.mock('@/features/editor/deps/media-library', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + MediaLibrary: () =>
, + } +}) + import { controlledDocumentToFreeCutDocument, createCodePressCommandAdapter, freeCutDocumentToControlledDocument, type EditCommandBatch, } from '@/features/editor/codepress' +import { useEditorStore } from '@/shared/state/editor' +import { MediaSidebar } from '../components/media-sidebar' import { EditorHostProvider } from './context-provider' import { DEFAULT_HOST_CAPABILITIES, @@ -423,3 +435,83 @@ describe('host-backed transcript consumer', () => { ) }) }) + +describe('transcript tab across authoritative snapshots (real MediaSidebar path)', () => { + function renderRealSidebar(harness: ReturnType) { + harness.runtime.mountStores() + return render( + + + + + , + ) + } + + async function drivePreviewAndApply() { + act(() => { + useEditorStore.getState().setActiveTab('transcript') + }) + expect( + await screen.findByTestId('host-transcript-section-transcript-section-1'), + ).toBeInTheDocument() + fireEvent.click(screen.getByTestId('host-transcript-section-transcript-section-1')) + fireEvent.click(screen.getByTestId('host-transcript-preview-button')) + await waitFor(() => expect(screen.getByTestId('host-transcript-preview')).toBeInTheDocument()) + fireEvent.click(screen.getByTestId('host-transcript-apply')) + } + + afterEach(() => { + useEditorStore.setState({ activeTab: 'media' }) + }) + + it('keeps the transcript panel mounted with its applied state after apply', async () => { + const harness = createHarness(snapshot()) + try { + renderRealSidebar(harness) + await drivePreviewAndApply() + await waitFor(() => expect(harness.submitEdit).toHaveBeenCalledTimes(1)) + // Installing the authoritative snapshot must not unmount the panel. + expect(useEditorStore.getState().activeTab).toBe('transcript') + expect(screen.getByTestId('host-transcript-editor')).toBeInTheDocument() + await waitFor(() => + expect(screen.getByText('Transcript captions applied.')).toBeInTheDocument(), + ) + } finally { + harness.runtime.unmountStores() + } + }) + + it('keeps the transcript panel mounted with the inline revision-conflict error', async () => { + const harness = createHarness(snapshot(), 'succeeded', 'preview', true) + try { + renderRealSidebar(harness) + await drivePreviewAndApply() + await waitFor(() => + expect(screen.getByTestId('host-transcript-error')).toHaveTextContent( + 'timeline changed before this transcript edit was applied', + ), + ) + expect(useEditorStore.getState().activeTab).toBe('transcript') + expect(screen.getByTestId('host-transcript-editor')).toBeInTheDocument() + expect(screen.queryByTestId('host-transcript-preview')).not.toBeInTheDocument() + } finally { + harness.runtime.unmountStores() + } + }) + + it('still resets a sidebar tab that host mode does not show', () => { + const harness = createHarness(snapshot()) + try { + act(() => { + useEditorStore.getState().setActiveTab('effects') + }) + harness.runtime.mountStores() + expect(useEditorStore.getState().activeTab).toBe('media') + } finally { + harness.runtime.unmountStores() + } + }) +})