auto-save 2026-05-14 10:59 (~6)
This commit is contained in:
@@ -1,26 +1,5 @@
|
|||||||
{
|
{
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
|
||||||
"files_changed": 1,
|
|
||||||
"hash": "f4a421b",
|
|
||||||
"message": "auto-save 2026-05-13 02:07 (~1)",
|
|
||||||
"ts": "2026-05-13T02:08:10+08:00",
|
|
||||||
"type": "commit"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files_changed": 1,
|
|
||||||
"hash": "a63d7c7",
|
|
||||||
"message": "auto-save 2026-05-13 02:13 (~1)",
|
|
||||||
"ts": "2026-05-13T02:14:06+08:00",
|
|
||||||
"type": "commit"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files_changed": 1,
|
|
||||||
"hash": "1a5f5be",
|
|
||||||
"message": "auto-save 2026-05-13 02:19 (~1)",
|
|
||||||
"ts": "2026-05-13T02:20:00+08:00",
|
|
||||||
"type": "commit"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"files_changed": 1,
|
"files_changed": 1,
|
||||||
"hash": "bcc7ce0",
|
"hash": "bcc7ce0",
|
||||||
@@ -3309,6 +3288,25 @@
|
|||||||
"type": "session-heartbeat",
|
"type": "session-heartbeat",
|
||||||
"message": "Codex 会话活跃 · 最近命令:codex · 5 项未提交变更 · 最近提交:auto-save 2026-05-14 10:45 (+1, ~5)",
|
"message": "Codex 会话活跃 · 最近命令:codex · 5 项未提交变更 · 最近提交:auto-save 2026-05-14 10:45 (+1, ~5)",
|
||||||
"files_changed": 5
|
"files_changed": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-14T10:53:54+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-14 10:51 (~7)",
|
||||||
|
"hash": "8bd52f6",
|
||||||
|
"files_changed": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-14T02:56:10Z",
|
||||||
|
"type": "session-heartbeat",
|
||||||
|
"message": "Codex 会话活跃 · 最近命令:codex · 3 项未提交变更 · 最近提交:auto-save 2026-05-14 10:51 (~7)",
|
||||||
|
"files_changed": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-14T02:58:38Z",
|
||||||
|
"type": "session-heartbeat",
|
||||||
|
"message": "Codex 会话活跃 · 最近命令:codex · 5 项未提交变更 · 最近提交:auto-save 2026-05-14 10:51 (~7)",
|
||||||
|
"files_changed": 5
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ uvicorn main:app --port 4291 --reload
|
|||||||
- `GET /health` — 健康检查 + 配置状态
|
- `GET /health` — 健康检查 + 配置状态
|
||||||
- `POST /jobs` `{url}` — 创建 job,后台跑下载/拆轨/抽帧
|
- `POST /jobs` `{url}` — 创建 job,后台跑下载/拆轨/抽帧
|
||||||
- `GET /jobs/{id}` — 当前状态 + 产物
|
- `GET /jobs/{id}` — 当前状态 + 产物
|
||||||
- `POST /jobs/{id}/transcribe` — 触发 ASR + 翻译 + SKG 文案改写;配置 MiniMax 后生成配音
|
- `POST /jobs/{id}/transcribe` — 触发音频提取 + ASR + 翻译 + SKG 文案改写;配置 MiniMax 后生成配音。前端 Audio 节点提供“提取音频 / 重新提取音频”按钮,不依赖抽帧完成
|
||||||
- `GET /jobs/{id}/video.mp4` — 原视频
|
- `GET /jobs/{id}/video.mp4` — 原视频
|
||||||
- `GET /jobs/{id}/audio.wav` — 拆轨后的原始音频,供前端底部音频条生成波形
|
- `GET /jobs/{id}/audio.wav` — 拆轨后的原始音频,供前端底部音频条生成波形
|
||||||
- `GET /jobs/{id}/audio-script.mp3` — 改写文案的 MiniMax 配音
|
- `GET /jobs/{id}/audio-script.mp3` — 改写文案的 MiniMax 配音
|
||||||
|
|||||||
24
api/main.py
24
api/main.py
@@ -97,7 +97,8 @@ def llm() -> OpenAI:
|
|||||||
return _llm_client
|
return _llm_client
|
||||||
|
|
||||||
# Pipeline 状态:
|
# Pipeline 状态:
|
||||||
# created → downloading → downloaded(停,等用户点解析)→ splitting → frames_extracted
|
# created → downloading → downloaded(停,等用户点解析/提取音频)
|
||||||
|
# → splitting → frames_extracted
|
||||||
# → transcribing → transcribed | failed
|
# → transcribing → transcribed | failed
|
||||||
JobStatus = Literal[
|
JobStatus = Literal[
|
||||||
"created", "downloading", "downloaded",
|
"created", "downloading", "downloaded",
|
||||||
@@ -1563,7 +1564,17 @@ async def pipeline_transcribe(job_id: str) -> None:
|
|||||||
wav = d / "audio.wav"
|
wav = d / "audio.wav"
|
||||||
try:
|
try:
|
||||||
if not wav.exists():
|
if not wav.exists():
|
||||||
raise RuntimeError("audio.wav 不存在")
|
mp4 = d / "source.mp4"
|
||||||
|
if not mp4.exists():
|
||||||
|
raise RuntimeError("source.mp4 不存在,视频导入完成后再提取音频")
|
||||||
|
update(job, status="transcribing", message="ffmpeg 提取音频轨…", progress=max(job.progress, 45), error="")
|
||||||
|
run([
|
||||||
|
"ffmpeg", "-y", "-i", str(mp4),
|
||||||
|
"-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le",
|
||||||
|
str(wav),
|
||||||
|
])
|
||||||
|
if not wav.exists():
|
||||||
|
raise RuntimeError("音频提取完成但找不到 audio.wav")
|
||||||
|
|
||||||
if not LLM_API_KEY:
|
if not LLM_API_KEY:
|
||||||
# 无 key 模式:mock 数据
|
# 无 key 模式:mock 数据
|
||||||
@@ -2004,9 +2015,12 @@ async def trigger_transcribe(job_id: str, bg: BackgroundTasks) -> Job:
|
|||||||
job = JOBS.get(job_id)
|
job = JOBS.get(job_id)
|
||||||
if not job:
|
if not job:
|
||||||
raise HTTPException(404, "job not found")
|
raise HTTPException(404, "job not found")
|
||||||
if job.status not in {"frames_extracted", "transcribed", "failed"}:
|
mp4 = job_dir(job_id) / "source.mp4"
|
||||||
raise HTTPException(409, f"status must be frames_extracted/transcribed/failed, got {job.status}")
|
if job.status in {"created", "downloading"} or not mp4.exists():
|
||||||
update(job, status="transcribing", progress=max(job.progress, 72), error="", message="准备音频转写…")
|
raise HTTPException(409, f"video not ready, got {job.status}")
|
||||||
|
if job.status in {"splitting", "transcribing"} or job.audio_script.status == "rewriting":
|
||||||
|
raise HTTPException(409, f"job is busy, got {job.status}")
|
||||||
|
update(job, status="transcribing", progress=max(job.progress, 45), error="", message="准备提取音频…")
|
||||||
bg.add_task(pipeline_transcribe, job_id)
|
bg.add_task(pipeline_transcribe, job_id)
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
|||||||
@@ -792,7 +792,7 @@ SubjectAsset {
|
|||||||
<tr><td>上传视频</td><td><code>POST /jobs/upload</code></td><td><code>uploadJob</code></td><td>保存 source.mp4,然后同样进入下载完成状态。</td></tr>
|
<tr><td>上传视频</td><td><code>POST /jobs/upload</code></td><td><code>uploadJob</code></td><td>保存 source.mp4,然后同样进入下载完成状态。</td></tr>
|
||||||
<tr><td>删除输入视频</td><td><code>DELETE /jobs/{id}</code></td><td><code>deleteJob</code></td><td>从任务队列、URL 和磁盘 <code>jobs/<id></code> 目录移除整个 job,包括源视频、关键帧、元素提取图和生成视频。</td></tr>
|
<tr><td>删除输入视频</td><td><code>DELETE /jobs/{id}</code></td><td><code>deleteJob</code></td><td>从任务队列、URL 和磁盘 <code>jobs/<id></code> 目录移除整个 job,包括源视频、关键帧、元素提取图和生成视频。</td></tr>
|
||||||
<tr><td>解析视频</td><td><code>POST /jobs/{id}/analyze?frames=&target=&mode=&quality=</code></td><td><code>analyzeJob</code></td><td>拆轨 + 目标化抽关键帧。默认 <code>frames=12</code>;<code>target</code> 支持透明骨架人、综合、清晰主体、转场变化、表情瞬间、动作峰值;当前 UI 默认 <code>transparent_human</code>。透明骨架人目标会先扩大本地候选池,再调用 Vision 按 6 个分数验收;不合格候选自动丢弃并抽下一候选。<code>mode=append</code> 追加新关键帧;<code>quality=auto</code> 根据本机算力和视频时长自动选择快速、精细或极准。多个抽帧请求进入后端队列顺序处理。</td></tr>
|
<tr><td>解析视频</td><td><code>POST /jobs/{id}/analyze?frames=&target=&mode=&quality=</code></td><td><code>analyzeJob</code></td><td>拆轨 + 目标化抽关键帧。默认 <code>frames=12</code>;<code>target</code> 支持透明骨架人、综合、清晰主体、转场变化、表情瞬间、动作峰值;当前 UI 默认 <code>transparent_human</code>。透明骨架人目标会先扩大本地候选池,再调用 Vision 按 6 个分数验收;不合格候选自动丢弃并抽下一候选。<code>mode=append</code> 追加新关键帧;<code>quality=auto</code> 根据本机算力和视频时长自动选择快速、精细或极准。多个抽帧请求进入后端队列顺序处理。</td></tr>
|
||||||
<tr><td>音频文案轨</td><td><code>POST /jobs/{id}/transcribe</code></td><td><code>triggerTranscribe</code></td><td>读取拆轨得到的 <code>audio.wav</code>,先 ASR 得到英文时间戳段落,再翻译中文,随后按 <code>AUDIO_PRODUCT_BRIEF</code> 生成 <code>audio_script.rewritten_text</code>;配置 <code>MINIMAX_API_KEY</code> 后调用 MiniMax T2A 生成 <code>audio_script.voice_url</code>。</td></tr>
|
<tr><td>音频文案轨</td><td><code>POST /jobs/{id}/transcribe</code></td><td><code>triggerTranscribe</code></td><td>读取拆轨得到的 <code>audio.wav</code>,先 ASR 得到英文时间戳段落,再翻译中文,随后按 <code>AUDIO_PRODUCT_BRIEF</code> 生成 <code>audio_script.rewritten_text</code>;配置 <code>MINIMAX_API_KEY</code> 后调用 MiniMax T2A 生成 <code>audio_script.voice_url</code>。前端在抽帧完成且尚无 transcript 时会自动触发一次;Audio 节点也提供“开始/重新处理音频”按钮。</td></tr>
|
||||||
<tr><td>原始音频文件</td><td><code>GET /jobs/{id}/audio.wav</code></td><td><code>sourceAudioUrl</code></td><td>返回拆轨得到的 wav;底部 <code>AudioStrip</code> 拉取该文件,用 Web Audio API 解码并计算波形峰值,只读展示,不参与改写。</td></tr>
|
<tr><td>原始音频文件</td><td><code>GET /jobs/{id}/audio.wav</code></td><td><code>sourceAudioUrl</code></td><td>返回拆轨得到的 wav;底部 <code>AudioStrip</code> 拉取该文件,用 Web Audio API 解码并计算波形峰值,只读展示,不参与改写。</td></tr>
|
||||||
<tr><td>改写配音文件</td><td><code>GET /jobs/{id}/audio-script.mp3</code></td><td><code>apiAssetUrl(job.audio_script.voice_url)</code></td><td>返回 MiniMax T2A 生成的 mp3。没有配置 MiniMax 或生成失败时该文件不存在,但改写文案仍会保存在 <code>audio_script.rewritten_text</code>。</td></tr>
|
<tr><td>改写配音文件</td><td><code>GET /jobs/{id}/audio-script.mp3</code></td><td><code>apiAssetUrl(job.audio_script.voice_url)</code></td><td>返回 MiniMax T2A 生成的 mp3。没有配置 MiniMax 或生成失败时该文件不存在,但改写文案仍会保存在 <code>audio_script.rewritten_text</code>。</td></tr>
|
||||||
<tr><td>手动加帧</td><td><code>POST /jobs/{id}/frames?t=</code></td><td><code>addManualFrame</code></td><td>按视频时间戳抽一帧,index 递增但 frames 按 timestamp 排序。</td></tr>
|
<tr><td>手动加帧</td><td><code>POST /jobs/{id}/frames?t=</code></td><td><code>addManualFrame</code></td><td>按视频时间戳抽一帧,index 递增但 frames 按 timestamp 排序。</td></tr>
|
||||||
@@ -841,7 +841,7 @@ SubjectAsset {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="tag gray">Audio / ASR / Rewrite</span></td>
|
<td><span class="tag gray">Audio / ASR / Rewrite</span></td>
|
||||||
<td>独立声音文案轨:从 <code>audio.wav</code> 提取原始口播、翻译中文、改写成 SKG 产品语境口播;MiniMax T2A 配置后生成配音 mp3。主画布的 <code>AudioNode</code> 用“改前 · 原音频 / 改后 · SKG 口播”摘要展示;底部 <code>AudioStrip</code> 吸附屏幕底端,可拖拽调整高度,按时间段展示英文、中文翻译和波形;侧栏 Rewrite 展开后显示完整审核视图。</td>
|
<td>独立声音文案轨:从 <code>audio.wav</code> 提取原始口播、翻译中文、改写成 SKG 产品语境口播;MiniMax T2A 配置后生成配音 mp3。抽帧完成后自动触发一次,也可在主画布 <code>AudioNode</code> 手动开始/重新处理。<code>AudioNode</code> 用“改前 · 原音频 / 改后 · SKG 口播”摘要展示;底部 <code>AudioStrip</code> 吸附屏幕底端,可拖拽调整高度,按时间段展示英文、中文翻译和波形;侧栏 Rewrite 展开后显示完整审核视图。</td>
|
||||||
<td>不要阻断视觉素材管线。</td>
|
<td>不要阻断视觉素材管线。</td>
|
||||||
<td><code>AudioNode</code>、<code>AudioStrip</code>、<code>ASRNode</code>、<code>TranslateNode</code>、<code>RewriteNode</code>、<code>pipeline_transcribe</code>、<code>AudioScript</code></td>
|
<td><code>AudioNode</code>、<code>AudioStrip</code>、<code>ASRNode</code>、<code>TranslateNode</code>、<code>RewriteNode</code>、<code>pipeline_transcribe</code>、<code>AudioScript</code></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -918,6 +918,18 @@ SubjectAsset {
|
|||||||
<h2>变更记录</h2>
|
<h2>变更记录</h2>
|
||||||
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
||||||
<div class="changelog">
|
<div class="changelog">
|
||||||
|
<article class="change">
|
||||||
|
<header>
|
||||||
|
<h3>2026-05-14 · 音频处理支持自动触发和手动重试</h3>
|
||||||
|
<span class="tag gray">Audio</span>
|
||||||
|
<span class="tag blue">Workflow</span>
|
||||||
|
</header>
|
||||||
|
<div class="body">
|
||||||
|
<p><strong>问题:</strong>后端已有 <code>/transcribe</code> 接口,但前端没有入口调用,用户不知道什么时候音频开始工作。</p>
|
||||||
|
<p><strong>改动:</strong>前端在 job 进入 <code>frames_extracted</code> 且没有 transcript 时自动调用一次 <code>triggerTranscribe</code>;<code>AudioNode</code> 增加“开始音频处理 / 重新处理音频”按钮。后端触发接口会立即把 job 状态置为 <code>transcribing</code>,让轮询、节点状态和底部音频条能立刻进入运行态。</p>
|
||||||
|
<p><strong>影响:</strong><code>web/app/page.tsx</code>、<code>web/components/nodes/index.tsx</code>、<code>api/main.py</code>、<code>docs/source-analysis.html</code>。</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
<article class="change">
|
<article class="change">
|
||||||
<header>
|
<header>
|
||||||
<h3>2026-05-14 · 新增底部可伸缩音频条</h3>
|
<h3>2026-05-14 · 新增底部可伸缩音频条</h3>
|
||||||
|
|||||||
@@ -390,31 +390,27 @@ export default function Home() {
|
|||||||
if (!targetId) return
|
if (!targetId) return
|
||||||
const target = jobs.find((item) => item.id === targetId)
|
const target = jobs.find((item) => item.id === targetId)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
if (!["frames_extracted", "transcribed", "failed"].includes(target.status)) {
|
if (!target.video_url) {
|
||||||
if (!options?.silent) toast.info("先完成抽帧,音频轨会自动开始处理")
|
if (!options?.silent) toast.info("视频导入完成后,可在音频卡片点击提取音频")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (target.status === "splitting") {
|
||||||
|
if (!options?.silent) toast.info("当前正在抽帧,结束后可重新点击提取音频")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (target.status === "transcribing" || target.audio_script?.status === "rewriting") {
|
||||||
|
if (!options?.silent) toast.info("音频正在处理中")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const updated = await triggerTranscribe(targetId)
|
const updated = await triggerTranscribe(targetId)
|
||||||
updateJobInList(updated)
|
updateJobInList(updated)
|
||||||
if (!options?.silent) toast.success("音频处理已开始")
|
if (!options?.silent) toast.success("已开始提取音频")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!options?.silent) toast.error("音频处理启动失败:" + (e instanceof Error ? e.message : String(e)))
|
if (!options?.silent) toast.error("音频处理启动失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
}
|
}
|
||||||
}, [activeJobId, jobs, updateJobInList])
|
}, [activeJobId, jobs, updateJobInList])
|
||||||
|
|
||||||
const autoAudioStartedRef = useRef<Set<string>>(new Set())
|
|
||||||
useEffect(() => {
|
|
||||||
for (const item of jobs) {
|
|
||||||
const audioStatus = item.audio_script?.status ?? "idle"
|
|
||||||
const hasAudioOutput = item.transcript.length > 0 || !!item.audio_script?.rewritten_text
|
|
||||||
const ready = item.status === "frames_extracted" && !hasAudioOutput && audioStatus !== "rewriting"
|
|
||||||
if (!ready || autoAudioStartedRef.current.has(item.id)) continue
|
|
||||||
autoAudioStartedRef.current.add(item.id)
|
|
||||||
void handleTranscribeAudio(item.id, { silent: true })
|
|
||||||
}
|
|
||||||
}, [jobs, handleTranscribeAudio])
|
|
||||||
|
|
||||||
const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => {
|
const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => {
|
||||||
if (!job) return
|
if (!job) return
|
||||||
const frame = job.frames.find((f) => f.index === frameIdx)
|
const frame = job.frames.find((f) => f.index === frameIdx)
|
||||||
|
|||||||
@@ -2109,7 +2109,19 @@ export function AudioNode({ data, selected }: any) {
|
|||||||
const voiceUrl = apiAssetUrl(audioScript?.voice_url)
|
const voiceUrl = apiAssetUrl(audioScript?.voice_url)
|
||||||
const hasASR = transcript.length > 0
|
const hasASR = transcript.length > 0
|
||||||
const isRewriting = audioScript?.status === "rewriting"
|
const isRewriting = audioScript?.status === "rewriting"
|
||||||
const canTriggerAudio = !!job && ["frames_extracted", "transcribed", "failed"].includes(job.status) && !isRewriting && job.status !== "transcribing"
|
const hasVideo = !!job?.video_url
|
||||||
|
const isAudioBusy = !!job && (job.status === "transcribing" || isRewriting)
|
||||||
|
const isVisualBusy = !!job && job.status === "splitting"
|
||||||
|
const audioButtonDisabled = !job || !hasVideo || isAudioBusy || isVisualBusy
|
||||||
|
const audioButtonLabel = !hasVideo
|
||||||
|
? "等待视频就绪"
|
||||||
|
: isAudioBusy
|
||||||
|
? "正在提取音频"
|
||||||
|
: isVisualBusy
|
||||||
|
? "抽帧中,稍后提取"
|
||||||
|
: hasASR || rewrittenText
|
||||||
|
? "重新提取音频"
|
||||||
|
: "提取音频"
|
||||||
const originalPreview = transcript
|
const originalPreview = transcript
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.map((s) => (s.zh || s.en).trim())
|
.map((s) => (s.zh || s.en).trim())
|
||||||
@@ -2139,17 +2151,25 @@ export function AudioNode({ data, selected }: any) {
|
|||||||
{audioScript?.rewrite_model || "AUDIO_REWRITE_MODEL"} → {audioScript?.voice_model || "MiniMax T2A"}
|
{audioScript?.rewrite_model || "AUDIO_REWRITE_MODEL"} → {audioScript?.voice_model || "MiniMax T2A"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{canTriggerAudio && (
|
{job && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={audioButtonDisabled}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
if (audioButtonDisabled) return
|
||||||
void d.onTranscribeAudio?.(job.id)
|
void d.onTranscribeAudio?.(job.id)
|
||||||
}}
|
}}
|
||||||
className="inline-flex min-h-8 w-full items-center justify-center gap-1.5 rounded-md border border-violet-300/25 bg-violet-400/10 px-2.5 py-1.5 text-[11px] font-medium text-[var(--text-strong)] transition hover:border-violet-200/45 hover:bg-violet-400/18"
|
className="inline-flex min-h-8 w-full items-center justify-center gap-1.5 rounded-md border border-violet-300/25 bg-violet-400/10 px-2.5 py-1.5 text-[11px] font-medium text-[var(--text-strong)] transition hover:border-violet-200/45 hover:bg-violet-400/18 disabled:cursor-not-allowed disabled:border-white/10 disabled:bg-white/[0.03] disabled:text-[var(--text-faint)]"
|
||||||
>
|
>
|
||||||
{hasASR || rewrittenText ? <RotateCcw className="h-3.5 w-3.5" /> : <PlayCircle className="h-3.5 w-3.5" />}
|
{isAudioBusy ? (
|
||||||
{hasASR || rewrittenText ? "重新处理音频" : "开始音频处理"}
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : hasASR || rewrittenText ? (
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<PlayCircle className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{audioButtonLabel}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(originalPreview || rewrittenText) && (
|
{(originalPreview || rewrittenText) && (
|
||||||
|
|||||||
Reference in New Issue
Block a user