From 3f2d3f0e8ffb42e59b1580fd31a8d00dd0f09e30 Mon Sep 17 00:00:00 2001 From: Ender Date: Fri, 24 Oct 2025 14:15:37 +0200 Subject: [PATCH] feat: add multi-clip recording and reordering support in audio recorder --- PLAN.md | 16 ++ apps/admin/src/features/recorder/Recorder.tsx | 172 ++++++++++-------- .../31ba935b-4424-4226-9f8b-803d401022a2.json | 4 +- 3 files changed, 118 insertions(+), 74 deletions(-) diff --git a/PLAN.md b/PLAN.md index 873fcbf..5e4ddc1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -42,6 +42,10 @@ Voice-first authoring tool for single-user Ghost blog. Capture audio, refine wit - [ ] Frontend: Buttons — "Save as Draft" and "Publish" (calls `/api/ghost/post`) - [ ] Show status toast and link to view post - [ ] ENV: `GHOST_ADMIN_API_URL`, `GHOST_ADMIN_API_KEY`, `GHOST_PUBLIC_URL` + - [ ] Media handling on publish: + - If `PUBLIC_MEDIA_BASE_URL` is set, copy each referenced media from `S3_BUCKET/` to `PUBLIC_MEDIA_BUCKET/` and rewrite HTML/`feature_image` to `PUBLIC_MEDIA_BASE_URL/`. + - If `PUBLIC_MEDIA_BASE_URL` is not set, fall back to presigned URLs (SigV4, max 7 days) for private buckets. + - Ensure destination bucket/prefix is publicly readable for anonymous GET (prefer prefix-only like `images/*`). - **M7 · Media Management** (Scope: Goal 7) - [x] Centralize media library view with reuse. - [ ] Background cleanup/retention policies. @@ -63,6 +67,9 @@ Voice-first authoring tool for single-user Ghost blog. Capture audio, refine wit - **Secrets** - [x] `.env.example` for common keys (ADMIN_PASSWORD_HASH, OPENAI_API_KEY, GHOST_ADMIN_API_KEY, S3 credentials). - [ ] Instructions for local secret population. + - [ ] Public media env: + - `PUBLIC_MEDIA_BUCKET` — bucket to store publicly-readable media copies (e.g., `public-media`). + - `PUBLIC_MEDIA_BASE_URL` — public HTTP base mapping directly to keys in `PUBLIC_MEDIA_BUCKET`. ## Tooling Decisions - **Dependency manager**: Adopt PNPM with workspace support for mono-repo friendliness and fast installs. @@ -93,6 +100,12 @@ Voice-first authoring tool for single-user Ghost blog. Capture audio, refine wit - [ ] Start Admin: `pnpm run dev -C apps/admin` - [ ] Record → Stop → Upload → Transcribe; see transcript populate Draft. - [ ] Save Draft (local) and verify persistence on reload. + - [ ] Public media: + - Set `.env`: `PUBLIC_MEDIA_BUCKET` and `PUBLIC_MEDIA_BASE_URL`. + - Ensure destination bucket/prefix is public (MinIO Console or `mc anonymous set public myminio//images`). + - Create a draft with an image and click Publish (draft or published). + - Check API logs for `[S3] Copy start`/`Copy done` and `[Ghost] Sample replacements` with `PUBLIC_MEDIA_BASE_URL/`. + - `curl -I ` should return HTTP/200 and `Content-Type: image/*`. ## MinIO Integration Checklist - [ ] Deploy MinIO on VPS (console `:9001`, API `:9000`). @@ -104,6 +117,9 @@ Voice-first authoring tool for single-user Ghost blog. Capture audio, refine wit - `S3_ACCESS_KEY=...` - `S3_SECRET_KEY=...` - [ ] Optional: Set bucket policy to allow public reads for media. + - [ ] Public media setup (if using a dedicated bucket): + - Create bucket `public-media` (or chosen name) and make `images/*` prefix public (anonymous `s3:GetObject`). + - Set `.env`: `PUBLIC_MEDIA_BUCKET=public-media`, `PUBLIC_MEDIA_BASE_URL=https:///public-media` (or path your gateway serves for that bucket). ## Scaffolding Plan (Draft) - **Frontend (`apps/admin`)** diff --git a/apps/admin/src/features/recorder/Recorder.tsx b/apps/admin/src/features/recorder/Recorder.tsx index 4c0de9f..35a61c7 100644 --- a/apps/admin/src/features/recorder/Recorder.tsx +++ b/apps/admin/src/features/recorder/Recorder.tsx @@ -6,14 +6,20 @@ export default function Recorder({ onTranscript }: { onTranscript?: (t: string) const chunksRef = useRef([]); const mimeRef = useRef('audio/webm'); const [recording, setRecording] = useState(false); - const [audioUrl, setAudioUrl] = useState(null); - const [audioBlob, setAudioBlob] = useState(null); - const [uploadKey, setUploadKey] = useState(null); - const [uploadBucket, setUploadBucket] = useState(null); - const [transcript, setTranscript] = useState(''); const [error, setError] = useState(''); - const [isUploading, setIsUploading] = useState(false); - const [isTranscribing, setIsTranscribing] = useState(false); + type Clip = { + id: string; + url: string; + blob: Blob; + mime: string; + uploadedKey?: string; + uploadedBucket?: string | null; + transcript?: string; + isUploading?: boolean; + isTranscribing?: boolean; + error?: string; + }; + const [clips, setClips] = useState([]); const requestStream = async (): Promise => { try { @@ -59,12 +65,8 @@ export default function Recorder({ onTranscript }: { onTranscript?: (t: string) mr.onstop = () => { const blob = new Blob(chunksRef.current, { type: mimeRef.current }); const url = URL.createObjectURL(blob); - setAudioUrl((prev) => { - if (prev) URL.revokeObjectURL(prev); - return url; - }); - setAudioBlob(blob); - // stop all tracks to release mic + const id = (globalThis.crypto && 'randomUUID' in crypto) ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(36).slice(2)}`; + setClips((prev) => [...prev, { id, url, blob, mime: mimeRef.current }]); stream.getTracks().forEach(t => t.stop()); }; @@ -77,51 +79,40 @@ export default function Recorder({ onTranscript }: { onTranscript?: (t: string) setRecording(false); }; - const uploadAudio = async () => { + const uploadClip = async (idx: number) => { + const c = clips[idx]; + if (!c) return; + setClips((prev) => prev.map((x, i) => i === idx ? { ...x, isUploading: true, error: '' } : x)); try { - setError(''); - setUploadKey(null); - setUploadBucket(null); - setTranscript(''); - setIsUploading(true); - if (!audioBlob) { - setError('No audio to upload'); - return; - } const form = new FormData(); - const ext = mimeRef.current.includes('mp4') ? 'm4a' : 'webm'; - form.append('audio', audioBlob, `recording.${ext}`); - const res = await fetch('/api/media/audio', { - method: 'POST', - body: form, - }); + const ext = c.mime.includes('mp4') ? 'm4a' : 'webm'; + form.append('audio', c.blob, `recording.${ext}`); + const res = await fetch('/api/media/audio', { method: 'POST', body: form }); if (!res.ok) { const txt = await res.text(); throw new Error(`Upload failed: ${res.status} ${txt}`); } const data = await res.json(); - setUploadKey(data.key || 'uploaded'); - setUploadBucket(data.bucket || null); + setClips((prev) => prev.map((x, i) => i === idx ? { ...x, uploadedKey: data.key || 'uploaded', uploadedBucket: data.bucket || null } : x)); } catch (e: any) { - setError(e?.message || 'Upload failed'); + setClips((prev) => prev.map((x, i) => i === idx ? { ...x, error: e?.message || 'Upload failed' } : x)); } finally { - setIsUploading(false); + setClips((prev) => prev.map((x, i) => i === idx ? { ...x, isUploading: false } : x)); } }; - const transcribe = async () => { + const transcribeClip = async (idx: number) => { + const c = clips[idx]; + if (!c) return; + setClips((prev) => prev.map((x, i) => i === idx ? { ...x, isTranscribing: true, error: '' } : x)); try { - setError(''); - setTranscript(''); - setIsTranscribing(true); - if (!uploadKey) { - setError('Upload audio before transcribing'); - return; + if (!c.uploadedKey) { + throw new Error('Upload before transcribing'); } const res = await fetch('/api/stt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ bucket: uploadBucket ?? undefined, key: uploadKey }), + body: JSON.stringify({ bucket: c.uploadedBucket ?? undefined, key: c.uploadedKey }), }); if (!res.ok) { const txt = await res.text(); @@ -129,52 +120,89 @@ export default function Recorder({ onTranscript }: { onTranscript?: (t: string) } const data = await res.json(); const t: string = data.transcript || ''; - setTranscript(t); - if (onTranscript) onTranscript(t); + setClips((prev) => prev.map((x, i) => i === idx ? { ...x, transcript: t } : x)); } catch (e: any) { - setError(e?.message || 'Transcription failed'); + setClips((prev) => prev.map((x, i) => i === idx ? { ...x, error: e?.message || 'Transcription failed' } : x)); } finally { - setIsTranscribing(false); + setClips((prev) => prev.map((x, i) => i === idx ? { ...x, isTranscribing: false } : x)); } }; + const moveClip = (from: number, to: number) => { + setClips((prev) => { + if (to < 0 || to >= prev.length) return prev; + const arr = prev.slice(); + const [item] = arr.splice(from, 1); + arr.splice(to, 0, item); + return arr; + }); + }; + + const removeClip = (idx: number) => { + setClips((prev) => { + const arr = prev.slice(); + const [item] = arr.splice(idx, 1); + if (item?.url) URL.revokeObjectURL(item.url); + return arr; + }); + }; + + const applyTranscriptsToDraft = () => { + const text = clips.map(c => c.transcript || '').filter(Boolean).join('\n\n'); + if (onTranscript) onTranscript(text); + }; + useEffect(() => { return () => { - if (audioUrl) URL.revokeObjectURL(audioUrl); + clips.forEach(c => c.url && URL.revokeObjectURL(c.url)); }; - }, [audioUrl]); + }, [clips]); return ( Audio Recorder - - - - - + + + + + {recording ? 'Recording…' : ''} {error && {error}} - {(isUploading || isTranscribing) && ( - - {isUploading ? 'Uploading…' : 'Transcribing…'} - - )} - {audioUrl && ( - - - )} - {uploadKey && ( - - Uploaded as key: {uploadKey} - - )} - {transcript && ( - - Transcript - {transcript} - + {clips.length === 0 && ( + No recordings yet. )} + + {clips.map((c, idx) => ( + + + Clip {idx + 1} + + + + + + + + ))} + ); } diff --git a/data/drafts/31ba935b-4424-4226-9f8b-803d401022a2.json b/data/drafts/31ba935b-4424-4226-9f8b-803d401022a2.json index 0ab9751..b699780 100644 --- a/data/drafts/31ba935b-4424-4226-9f8b-803d401022a2.json +++ b/data/drafts/31ba935b-4424-4226-9f8b-803d401022a2.json @@ -1,5 +1,5 @@ { "id": "31ba935b-4424-4226-9f8b-803d401022a2", - "content": "
enasdasdasd

zdfsdfsadsdfsdfsdf

sdfsdfs

\"Vector-2.png\"

  • df

", - "updatedAt": "2025-10-24T09:28:18.204Z" + "content": "
enasdasdasd

zdfsdfsadsdfsdfsdf

asdasd

\"Vector-2.png\"

  • df

", + "updatedAt": "2025-10-24T12:11:46.031Z" } \ No newline at end of file