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/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-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..8ae078735 --- /dev/null +++ b/src/features/editor/host/transcript-editor.test.tsx @@ -0,0 +1,517 @@ +// @vitest-environment jsdom + +import { readFileSync } from 'node:fs' +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, + 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/, + ) + }) +}) + +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() + } + }) +}) 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} + +
+ ) +}