From b2c7b352cfb4c10a782c214d71e3d0b0bc81d300 Mon Sep 17 00:00:00 2001 From: FernandoM33 Date: Mon, 7 Sep 2026 01:05:54 -0700 Subject: [PATCH] ux: share manual clip selection action --- .../scripts/smoke-create-clips-workspace.mjs | 7 +++- frontend/src/components/AIPanel.tsx | 16 +++++++- frontend/src/components/TranscriptEditor.tsx | 39 +------------------ frontend/src/features/clips/ClipFindStage.tsx | 23 +++++++++++ frontend/src/features/clips/clipDraftModel.ts | 31 +++++++++++++++ 5 files changed, 77 insertions(+), 39 deletions(-) diff --git a/frontend/scripts/smoke-create-clips-workspace.mjs b/frontend/scripts/smoke-create-clips-workspace.mjs index c1bf743..98a9936 100644 --- a/frontend/scripts/smoke-create-clips-workspace.mjs +++ b/frontend/scripts/smoke-create-clips-workspace.mjs @@ -51,6 +51,7 @@ assert.doesNotMatch(prepareExportSource, /useAIStore|useEditorStore|fetch\(|loca assert.match(panelSource, /from '\.\.\/features\/clips\/clipDraftModel'/); assert.match(clipDraftModelSource, /export function createShortsClipDraft\(/); assert.match(clipDraftModelSource, /export function appendDiscoveredClipDrafts\(/); +assert.match(clipDraftModelSource, /export function appendTranscriptSelectionClipDraft\(/); assert.doesNotMatch(clipDraftModelSource, /useAIStore|useEditorStore|fetch\(|localStorage/, 'clip draft domain helpers must stay pure'); assert.match(panelSource, /from '\.\.\/features\/clips\/clipExportFiles'/); assert.match(clipExportFilesSource, /export function buildClipOutputPath\(/); @@ -66,6 +67,8 @@ assert.match(panelSource, /setClipStage\('prepare'\);\s+setActiveClipDraftId\(dr assert.match(findStageSource, /Find moments with AI/); assert.match(findStageSource, />Choose moments<\/h3>/); assert.match(findStageSource, /Start from the transcript — no AI required/); +assert.match(findStageSource, /Draft selected clip/); +assert.match(findStageSource, /selectedWordCount/); assert.match(findStageSource, /Optional AI discovery/); assert.ok( findStageSource.indexOf('Start from the transcript — no AI required') < findStageSource.indexOf('Find moments with AI'), @@ -104,7 +107,9 @@ assert.match(panelSource, /const activeReviewPreviewKey =\s*isPlaying && preview assert.match(panelSource, /activePreviewKey=\{activeReviewPreviewKey\}/); assert.doesNotMatch(panelSource, /activePreviewKey=\{activeClipPreviewKey\}/); assert.doesNotMatch(panelSource, /clipQueueSummary\.suggested === 0/); -assert.match(transcriptSource, /source: 'transcript-selection'/); +assert.match(clipDraftModelSource, /'transcript-selection'/); +assert.match(transcriptSource, /appendTranscriptSelectionClipDraft\(current, selectionSummary\)/); +assert.match(panelSource, /appendTranscriptSelectionClipDraft\(current, transcriptSelectionSummary\)/); assert.match(panelSource, /type ClipDiscoveryResult/); assert.match(panelSource, /requestedCount\?: number/); assert.match(panelSource, /returnedCount\?: number/); diff --git a/frontend/src/components/AIPanel.tsx b/frontend/src/components/AIPanel.tsx index d122057..9eb15b9 100644 --- a/frontend/src/components/AIPanel.tsx +++ b/frontend/src/components/AIPanel.tsx @@ -49,7 +49,8 @@ import ClipPrepareExportControls from '../features/clips/ClipPrepareExportContro import ClipWorkspaceHeader from '../features/clips/ClipWorkspaceHeader'; import { useClipReviewActions } from '../features/clips/useClipReviewActions'; import { formatClipTime } from '../features/clips/presentation'; -import { appendDiscoveredClipDrafts, createShortsClipDraft, SHORTS_DRAFT_DEFAULTS } from '../features/clips/clipDraftModel'; +import { appendDiscoveredClipDrafts, appendTranscriptSelectionClipDraft, createShortsClipDraft, SHORTS_DRAFT_DEFAULTS } from '../features/clips/clipDraftModel'; +import { formatSelectionDuration, summarizeWordSelection } from '../utils/transcriptSelection'; import { buildClipOutputPath, formatPublishingCopy, @@ -112,6 +113,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { getMutedRanges, getCaptionHiddenIndices, setSelectedWordIndices, + selectedWordIndices, activeWordIndex, isPlaying, previewRangeEnd, @@ -181,6 +183,10 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { () => getPendingReviewItems(clipDrafts, clipSuggestions, clipReviewDecisions), [clipDrafts, clipReviewDecisions, clipSuggestions], ); + const transcriptSelectionSummary = useMemo( + () => summarizeWordSelection(selectedWordIndices, words), + [selectedWordIndices, words], + ); const skippedReviewItems = useMemo( () => getSkippedReviewItems(clipDrafts, clipSuggestions, clipReviewDecisions), [clipDrafts, clipReviewDecisions, clipSuggestions], @@ -224,6 +230,11 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { setSelectedWordIndices(getWordIndicesForClip(words, draft)); }, [clipDrafts, mode, setSelectedWordIndices, words]); + const draftSelectedTranscriptClip = useCallback(() => { + if (!transcriptSelectionSummary) return; + setClipDrafts((current) => appendTranscriptSelectionClipDraft(current, transcriptSelectionSummary)); + }, [setClipDrafts, transcriptSelectionSummary]); + const reviewedCount = useMemo(() => { if (!fillerResult) return 0; return fillerResult.fillerWords.filter( @@ -1748,7 +1759,10 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) { isProcessing={isProcessing} hasWords={words.length > 0} processingMessage={processingMessage} + selectedWordCount={transcriptSelectionSummary?.indices.length || 0} + selectionDurationLabel={transcriptSelectionSummary ? formatSelectionDuration(transcriptSelectionSummary.duration) : ''} speakerTurnCount={speakerTurnClips.length} + onDraftSelection={draftSelectedTranscriptClip} onFindWithAI={createClips} onDraftSpeakerTurns={createSpeakerTurnDrafts} /> diff --git a/frontend/src/components/TranscriptEditor.tsx b/frontend/src/components/TranscriptEditor.tsx index 0ea7fe4..40289f0 100644 --- a/frontend/src/components/TranscriptEditor.tsx +++ b/frontend/src/components/TranscriptEditor.tsx @@ -4,10 +4,10 @@ import { useAIStore } from '../store/aiStore'; import { Virtuoso } from 'react-virtuoso'; import type { VirtuosoHandle } from 'react-virtuoso'; import { CaptionsOff, ChevronLeft, ChevronRight, Copy, Film, Pencil, Play, RotateCcw, Search, Trash2, UserRoundCheck, VolumeX, Waves, X } from 'lucide-react'; -import type { ClipDraft } from '../types/project'; import { adjustWordSelectionBoundary, formatSelectionDuration, summarizeWordSelection } from '../utils/transcriptSelection'; import { findTranscriptMatches } from '../utils/transcriptSearch'; import { formatSpeakerDuration, getSpeakerStats } from '../utils/speakerStats'; +import { appendTranscriptSelectionClipDraft } from '../features/clips/clipDraftModel'; import CreatorDialog from './CreatorDialog'; import CreatorNotice, { type CreatorNoticeData } from './CreatorNotice'; import { getCreatorErrorPresentation } from '../utils/creatorErrors'; @@ -321,42 +321,7 @@ export default function TranscriptEditor() { const draftClipFromSelection = useCallback(() => { if (!selectionSummary) return; - const title = selectionSummary.text.split(/\s+/).slice(0, 8).join(' ') || 'Transcript clip'; - const draft: ClipDraft = { - id: `transcript_clip_${Date.now()}`, - title, - reason: 'Created from transcript selection', - startWordIndex: selectionSummary.startIndex, - endWordIndex: selectionSummary.endIndex, - startTime: selectionSummary.startTime, - endTime: selectionSummary.endTime, - status: 'draft', - platform: 'shorts', - format: 'mp4', - resolution: '1080p', - aspectRatio: 'vertical', - reframe: { x: 50, y: 50 }, - enhanceAudio: false, - captions: 'burn-in', - captionStyle: { - preset: 'creator', - fontName: 'Arial', - fontSize: 58, - fontColor: '#ffffff', - backgroundColor: '#111827', - position: 'bottom', - bold: true, - highlightColor: '#facc15', - wordsPerLine: 5, - }, - backgroundRemoval: { enabled: false, replacement: 'blur', color: '#111827' }, - hook: '', - description: '', - caption: '', - hashtags: [], - source: 'transcript-selection', - }; - setClipDrafts((current) => [...current, draft]); + setClipDrafts((current) => appendTranscriptSelectionClipDraft(current, selectionSummary)); }, [selectionSummary, setClipDrafts]); const renderSegment = useCallback( diff --git a/frontend/src/features/clips/ClipFindStage.tsx b/frontend/src/features/clips/ClipFindStage.tsx index 0756205..099ebad 100644 --- a/frontend/src/features/clips/ClipFindStage.tsx +++ b/frontend/src/features/clips/ClipFindStage.tsx @@ -4,7 +4,10 @@ type ClipFindStageProps = { isProcessing: boolean; hasWords: boolean; processingMessage: string; + selectedWordCount: number; + selectionDurationLabel: string; speakerTurnCount: number; + onDraftSelection: () => void; onFindWithAI: () => void; onDraftSpeakerTurns: () => void; }; @@ -13,10 +16,15 @@ export default function ClipFindStage({ isProcessing, hasWords, processingMessage, + selectedWordCount, + selectionDurationLabel, speakerTurnCount, + onDraftSelection, onFindWithAI, onDraftSpeakerTurns, }: ClipFindStageProps) { + const hasSelection = selectedWordCount > 0; + return (

Choose moments

@@ -25,6 +33,21 @@ export default function ClipFindStage({

Select the words you want, preview the selection, then choose Draft clip.

+
+ + {hasSelection + ? `${selectedWordCount} words selected · ${selectionDurationLabel}` + : 'Select transcript words to create a manual clip.'} + + +
diff --git a/frontend/src/features/clips/clipDraftModel.ts b/frontend/src/features/clips/clipDraftModel.ts index 3b945e6..98392de 100644 --- a/frontend/src/features/clips/clipDraftModel.ts +++ b/frontend/src/features/clips/clipDraftModel.ts @@ -1,5 +1,6 @@ import type { ClipDraft, ClipDraftStatus, ClipSuggestion } from '../../types/project'; import { isSameClipRange } from '../../utils/clipWorkspace'; +import type { TranscriptSelectionSummary } from '../../utils/transcriptSelection'; import { CLIP_CAPTION_PRESETS } from './presentation'; export const SHORTS_DRAFT_DEFAULTS = { @@ -43,3 +44,33 @@ export function appendDiscoveredClipDrafts( } return next; } + +export function appendTranscriptSelectionClipDraft( + current: ClipDraft[], + selection: TranscriptSelectionSummary, +) { + const title = selection.text.split(/\s+/).slice(0, 8).join(' ') || 'Transcript clip'; + const clip: ClipSuggestion = { + title, + reason: 'Created from transcript selection', + startWordIndex: selection.startIndex, + endWordIndex: selection.endIndex, + startTime: selection.startTime, + endTime: selection.endTime, + }; + return [ + ...current, + { + ...createShortsClipDraft( + clip, + `transcript_clip_${Date.now()}_${current.length}`, + 'draft', + 'transcript-selection', + ), + hook: '', + description: '', + caption: '', + hashtags: [], + }, + ]; +}