auto-save 2026-05-14 10:45 (+1, ~5)
This commit is contained in:
@@ -1,12 +1,5 @@
|
|||||||
{
|
{
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
|
||||||
"files_changed": 1,
|
|
||||||
"hash": "ab6f035",
|
|
||||||
"message": "auto-save 2026-05-13 01:42 (~1)",
|
|
||||||
"ts": "2026-05-13T01:42:52+08:00",
|
|
||||||
"type": "commit"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"files_changed": 1,
|
"files_changed": 1,
|
||||||
"hash": "6128084",
|
"hash": "6128084",
|
||||||
@@ -3311,6 +3304,13 @@
|
|||||||
"type": "session-heartbeat",
|
"type": "session-heartbeat",
|
||||||
"message": "Codex 会话活跃 · 最近命令:codex · 5 项未提交变更 · 最近提交:auto-save 2026-05-14 10:31 (~4)",
|
"message": "Codex 会话活跃 · 最近命令:codex · 5 项未提交变更 · 最近提交:auto-save 2026-05-14 10:31 (~4)",
|
||||||
"files_changed": 5
|
"files_changed": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-14T10:40:12+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-14 10:36 (~5)",
|
||||||
|
"hash": "1014114",
|
||||||
|
"files_changed": 5
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
47
api/main.py
47
api/main.py
@@ -561,7 +561,36 @@ async def lifespan(_: FastAPI):
|
|||||||
for p in JOBS_DIR.iterdir():
|
for p in JOBS_DIR.iterdir():
|
||||||
if p.is_dir() and (p / "state.json").exists():
|
if p.is_dir() and (p / "state.json").exists():
|
||||||
try:
|
try:
|
||||||
JOBS[p.name] = Job.model_validate_json((p / "state.json").read_text())
|
job = Job.model_validate_json((p / "state.json").read_text())
|
||||||
|
source_exists = (p / "source.mp4").exists()
|
||||||
|
if job.status in {"created", "downloading"}:
|
||||||
|
if source_exists:
|
||||||
|
update(job, status="downloaded", progress=25, message="服务重启 · 视频已恢复,可重新解析")
|
||||||
|
else:
|
||||||
|
update(job, status="failed", message="服务重启 · 下载任务已中断,请重新提交")
|
||||||
|
elif job.status == "splitting":
|
||||||
|
update(
|
||||||
|
job,
|
||||||
|
status="frames_extracted" if job.frames else "downloaded",
|
||||||
|
progress=70 if job.frames else 25,
|
||||||
|
message="服务重启 · 上次抽帧已中断,可重新抽帧",
|
||||||
|
)
|
||||||
|
elif job.status == "transcribing":
|
||||||
|
audio_script = job.audio_script
|
||||||
|
if audio_script.status == "rewriting":
|
||||||
|
audio_script = audio_script.model_copy(update={
|
||||||
|
"status": "failed",
|
||||||
|
"error": "服务重启 · 上次音频改写/配音已中断,可重新处理",
|
||||||
|
"created_at": audio_script.created_at or time.time(),
|
||||||
|
})
|
||||||
|
update(
|
||||||
|
job,
|
||||||
|
status="frames_extracted",
|
||||||
|
progress=70,
|
||||||
|
audio_script=audio_script,
|
||||||
|
message="服务重启 · 上次音频处理已中断,可重新处理",
|
||||||
|
)
|
||||||
|
JOBS[p.name] = job
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
yield
|
yield
|
||||||
@@ -1122,7 +1151,7 @@ def ffprobe_meta(mp4: Path) -> dict:
|
|||||||
return json.loads(out)
|
return json.loads(out)
|
||||||
|
|
||||||
|
|
||||||
async def pipeline_download(job_id: str) -> None:
|
def pipeline_download(job_id: str) -> None:
|
||||||
"""阶段 1:仅下载(或上传跳过),落 source.mp4,停在 downloaded 等用户点解析。"""
|
"""阶段 1:仅下载(或上传跳过),落 source.mp4,停在 downloaded 等用户点解析。"""
|
||||||
job = JOBS[job_id]
|
job = JOBS[job_id]
|
||||||
d = job_dir(job_id)
|
d = job_dir(job_id)
|
||||||
@@ -1159,7 +1188,7 @@ async def pipeline_download(job_id: str) -> None:
|
|||||||
update(job, status="failed", error=str(e), message="下载失败")
|
update(job, status="failed", error=str(e), message="下载失败")
|
||||||
|
|
||||||
|
|
||||||
async def pipeline_analyze(
|
def pipeline_analyze(
|
||||||
job_id: str,
|
job_id: str,
|
||||||
frame_count: int = KEYFRAME_COUNT,
|
frame_count: int = KEYFRAME_COUNT,
|
||||||
target: FrameExtractTarget = "transparent_human",
|
target: FrameExtractTarget = "transparent_human",
|
||||||
@@ -1311,7 +1340,7 @@ async def pipeline_analyze(
|
|||||||
update(job, status="failed", error=str(e), message="解析失败")
|
update(job, status="failed", error=str(e), message="解析失败")
|
||||||
|
|
||||||
|
|
||||||
async def analyze_queue_worker() -> None:
|
def analyze_queue_worker() -> None:
|
||||||
global ANALYZE_WORKER_RUNNING
|
global ANALYZE_WORKER_RUNNING
|
||||||
ANALYZE_WORKER_RUNNING = True
|
ANALYZE_WORKER_RUNNING = True
|
||||||
try:
|
try:
|
||||||
@@ -1319,7 +1348,7 @@ async def analyze_queue_worker() -> None:
|
|||||||
job_id, frames, target, mode, quality = ANALYZE_QUEUE.pop(0)
|
job_id, frames, target, mode, quality = ANALYZE_QUEUE.pop(0)
|
||||||
if job_id not in JOBS:
|
if job_id not in JOBS:
|
||||||
continue
|
continue
|
||||||
await pipeline_analyze(job_id, frames, target, mode, quality)
|
pipeline_analyze(job_id, frames, target, mode, quality)
|
||||||
if ANALYZE_QUEUE:
|
if ANALYZE_QUEUE:
|
||||||
for pos, (queued_job_id, *_rest) in enumerate(ANALYZE_QUEUE, start=1):
|
for pos, (queued_job_id, *_rest) in enumerate(ANALYZE_QUEUE, start=1):
|
||||||
queued_job = JOBS.get(queued_job_id)
|
queued_job = JOBS.get(queued_job_id)
|
||||||
@@ -1984,6 +2013,14 @@ def get_video(job_id: str):
|
|||||||
return FileResponse(p, media_type="video/mp4")
|
return FileResponse(p, media_type="video/mp4")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/jobs/{job_id}/audio.wav")
|
||||||
|
def get_source_audio(job_id: str):
|
||||||
|
p = job_dir(job_id) / "audio.wav"
|
||||||
|
if not p.exists():
|
||||||
|
raise HTTPException(404, "audio not found")
|
||||||
|
return FileResponse(p, media_type="audio/wav")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/jobs/{job_id}/audio-script.mp3")
|
@app.get("/jobs/{job_id}/audio-script.mp3")
|
||||||
def get_audio_script(job_id: str):
|
def get_audio_script(job_id: str):
|
||||||
p = job_dir(job_id) / "audio_script.mp3"
|
p = job_dir(job_id) / "audio_script.mp3"
|
||||||
|
|||||||
@@ -572,6 +572,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr><td><code>web/app/page.tsx</code></td><td>产品工作台主状态:jobs、activeJobId、按 job 隔离的 selectedFrames/详情面板状态、clipboard、ReactFlow 节点和边;负责打开/找回画布工作面板。</td></tr>
|
<tr><td><code>web/app/page.tsx</code></td><td>产品工作台主状态:jobs、activeJobId、按 job 隔离的 selectedFrames/详情面板状态、clipboard、ReactFlow 节点和边;负责打开/找回画布工作面板。</td></tr>
|
||||||
<tr><td><code>web/components/nodes/index.tsx</code></td><td>DAG 节点定义:Input、VisualLab、Audio、Compose,以及画布工作面板 KeyframePanel / VideoFramePanel;旧 Keyframe/Storyboard/VideoGen 组件保留但不再挂主画布。</td></tr>
|
<tr><td><code>web/components/nodes/index.tsx</code></td><td>DAG 节点定义:Input、VisualLab、Audio、Compose,以及画布工作面板 KeyframePanel / VideoFramePanel;旧 Keyframe/Storyboard/VideoGen 组件保留但不再挂主画布。</td></tr>
|
||||||
|
<tr><td><code>web/components/audio-strip.tsx</code></td><td>底部吸附音频条:可拖拽调整高度;按时间段展示英文、中文翻译和音频波形,并在右侧固定显示 SKG 改写稿和 MiniMax 配音。</td></tr>
|
||||||
<tr><td><code>web/components/lightbox.tsx</code></td><td>关键帧素材准备面板:清洗、统一主体候选、参考帧网格、六张主体重绘图、每帧去主体场景图、纵向 6 行产品融合镜头工作表和审核。</td></tr>
|
<tr><td><code>web/components/lightbox.tsx</code></td><td>关键帧素材准备面板:清洗、统一主体候选、参考帧网格、六张主体重绘图、每帧去主体场景图、纵向 6 行产品融合镜头工作表和审核。</td></tr>
|
||||||
<tr><td><code>web/components/product-library-picker.tsx</code></td><td>SKG 内置白底产品图库选择器:搜索、品类筛选、预览尺寸,并把库内图片复制为当前 job 的 <code>asset</code>。</td></tr>
|
<tr><td><code>web/components/product-library-picker.tsx</code></td><td>SKG 内置白底产品图库选择器:搜索、品类筛选、预览尺寸,并把库内图片复制为当前 job 的 <code>asset</code>。</td></tr>
|
||||||
<tr><td><code>web/components/storyboard-bar.tsx</code></td><td>顶部分镜编排条:展示选入编排的关键帧,并作为唯一分镜导航。</td></tr>
|
<tr><td><code>web/components/storyboard-bar.tsx</code></td><td>顶部分镜编排条:展示选入编排的关键帧,并作为唯一分镜导航。</td></tr>
|
||||||
@@ -587,6 +588,7 @@
|
|||||||
<tr><td><code>api/main.py</code></td><td>FastAPI 单文件后端:状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、音频文案改写、MiniMax 配音、文件返回。</td></tr>
|
<tr><td><code>api/main.py</code></td><td>FastAPI 单文件后端:状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、音频文案改写、MiniMax 配音、文件返回。</td></tr>
|
||||||
<tr><td><code>api/product_library/skg-products</code></td><td>内置 SKG 白底产品图库:<code>manifest.json</code> 记录从桌面产品图筛出的 gallery 白底图,<code>images/</code> 存 41 张压缩后的参考图。</td></tr>
|
<tr><td><code>api/product_library/skg-products</code></td><td>内置 SKG 白底产品图库:<code>manifest.json</code> 记录从桌面产品图筛出的 gallery 白底图,<code>images/</code> 存 41 张压缩后的参考图。</td></tr>
|
||||||
<tr><td><code>jobs/<jobId>/state.json</code></td><td>运行时状态文件,不在源码列表里,但刷新恢复依赖它。</td></tr>
|
<tr><td><code>jobs/<jobId>/state.json</code></td><td>运行时状态文件,不在源码列表里,但刷新恢复依赖它。</td></tr>
|
||||||
|
<tr><td><code>jobs/<jobId>/audio.wav</code></td><td>拆轨得到的原始音频,底部 Audio Strip 会通过只读接口拉取并在浏览器里解码成波形峰值。</td></tr>
|
||||||
<tr><td><code>jobs/<jobId>/frames</code></td><td>关键帧 jpg。注意 frame.index 是稳定 ID,不等于数组下标。</td></tr>
|
<tr><td><code>jobs/<jobId>/frames</code></td><td>关键帧 jpg。注意 frame.index 是稳定 ID,不等于数组下标。</td></tr>
|
||||||
<tr><td><code>jobs/<jobId>/cleaned</code></td><td>清洗后待应用图片。</td></tr>
|
<tr><td><code>jobs/<jobId>/cleaned</code></td><td>清洗后待应用图片。</td></tr>
|
||||||
<tr><td><code>jobs/<jobId>/elements</code></td><td>元素提取图,多版本命名:<code>idx_elementId_cutoutId.jpg</code>。</td></tr>
|
<tr><td><code>jobs/<jobId>/elements</code></td><td>元素提取图,多版本命名:<code>idx_elementId_cutoutId.jpg</code>。</td></tr>
|
||||||
@@ -599,6 +601,7 @@
|
|||||||
web/app/page.tsx
|
web/app/page.tsx
|
||||||
-> ReactFlow 节点:web/components/nodes/index.tsx
|
-> ReactFlow 节点:web/components/nodes/index.tsx
|
||||||
-> 主画布:Input → VisualLab / Audio → Compose
|
-> 主画布:Input → VisualLab / Audio → Compose
|
||||||
|
-> 底部音频条:web/components/audio-strip.tsx(英文 / 中文 / 波形 / 改写稿)
|
||||||
-> 画布内视频抽帧面板:InputNode 单击视频缩略图打开 videoFramePanel
|
-> 画布内视频抽帧面板:InputNode 单击视频缩略图打开 videoFramePanel
|
||||||
-> 画布内镜头拆解面板:VisualLabNode 打开 keyframePanel,内嵌 web/components/lightbox.tsx
|
-> 画布内镜头拆解面板:VisualLabNode 打开 keyframePanel,内嵌 web/components/lightbox.tsx
|
||||||
-> 分镜工作台:web/components/storyboard-workbench.tsx(底层保留)
|
-> 分镜工作台:web/components/storyboard-workbench.tsx(底层保留)
|
||||||
@@ -790,6 +793,7 @@ SubjectAsset {
|
|||||||
<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>。</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>
|
||||||
<tr><td>Vision 识别</td><td><code>POST /frames/{idx}/describe</code></td><td><code>describeFrame</code></td><td>写入 frame.description,后续可从 objects 加候选元素。</td></tr>
|
<tr><td>Vision 识别</td><td><code>POST /frames/{idx}/describe</code></td><td><code>describeFrame</code></td><td>写入 frame.description,后续可从 objects 加候选元素。</td></tr>
|
||||||
@@ -837,9 +841,9 @@ 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 口播”摘要展示,侧栏 Rewrite 展开后显示完整逐段 ASR/翻译、改写稿、产品依据和配音播放器。</td>
|
<td>独立声音文案轨:从 <code>audio.wav</code> 提取原始口播、翻译中文、改写成 SKG 产品语境口播;MiniMax T2A 配置后生成配音 mp3。主画布的 <code>AudioNode</code> 用“改前 · 原音频 / 改后 · SKG 口播”摘要展示;底部 <code>AudioStrip</code> 吸附屏幕底端,可拖拽调整高度,按时间段展示英文、中文翻译和波形;侧栏 Rewrite 展开后显示完整审核视图。</td>
|
||||||
<td>不要阻断视觉素材管线。</td>
|
<td>不要阻断视觉素材管线。</td>
|
||||||
<td><code>AudioNode</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>
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="tag green">Video / Compose</span></td>
|
<td><span class="tag green">Video / Compose</span></td>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type NodeData,
|
type NodeData,
|
||||||
} from "@/components/nodes"
|
} from "@/components/nodes"
|
||||||
import { ThemeToggle } from "@/components/theme-toggle"
|
import { ThemeToggle } from "@/components/theme-toggle"
|
||||||
|
import { AudioStrip } from "@/components/audio-strip"
|
||||||
import {
|
import {
|
||||||
addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage,
|
addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage,
|
||||||
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, createProductFusionGuide,
|
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, createProductFusionGuide,
|
||||||
@@ -976,9 +977,10 @@ export default function Home() {
|
|||||||
<MiniMap position="bottom-right" pannable zoomable nodeStrokeWidth={2} />
|
<MiniMap position="bottom-right" pannable zoomable nodeStrokeWidth={2} />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
|
<AudioStrip job={job} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Toaster theme="system" position="bottom-center" />
|
<Toaster theme="system" position="top-center" />
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
255
web/components/audio-strip.tsx
Normal file
255
web/components/audio-strip.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"
|
||||||
|
import { ChevronDown, ChevronUp, GripHorizontal, Mic2, Volume2 } from "lucide-react"
|
||||||
|
import { apiAssetUrl, sourceAudioUrl, type Job, type TranscriptSegment } from "@/lib/api"
|
||||||
|
|
||||||
|
const STORAGE_KEY = "skg.audio-strip.height"
|
||||||
|
const MIN_HEIGHT = 132
|
||||||
|
const MAX_HEIGHT = 420
|
||||||
|
const DEFAULT_HEIGHT = 236
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackPeaks(count: number, seedText: string) {
|
||||||
|
let seed = 0
|
||||||
|
for (let i = 0; i < seedText.length; i++) seed = (seed * 31 + seedText.charCodeAt(i)) % 9973
|
||||||
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
const wave = Math.sin((i + seed) * 0.43) * 0.35 + Math.sin((i + seed) * 0.11) * 0.25
|
||||||
|
const pulse = ((i + seed) % 9) / 18
|
||||||
|
return clamp(0.22 + Math.abs(wave) + pulse, 0.18, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function slicePeaks(peaks: number[], start: number, end: number, duration: number, count = 56) {
|
||||||
|
if (peaks.length === 0 || duration <= 0 || end <= start) return fallbackPeaks(count, `${start}-${end}`)
|
||||||
|
const from = clamp(Math.floor((start / duration) * peaks.length), 0, peaks.length - 1)
|
||||||
|
const to = clamp(Math.ceil((end / duration) * peaks.length), from + 1, peaks.length)
|
||||||
|
const source = peaks.slice(from, to)
|
||||||
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
const a = Math.floor((i / count) * source.length)
|
||||||
|
const b = Math.max(a + 1, Math.floor(((i + 1) / count) * source.length))
|
||||||
|
return Math.max(...source.slice(a, b), 0.12)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function Waveform({ peaks, active = false }: { peaks: number[]; active?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-10 items-center gap-[2px] rounded-md border border-white/10 bg-black/20 px-2">
|
||||||
|
{peaks.map((p, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={active ? "bg-emerald-300/80" : "bg-violet-300/65"}
|
||||||
|
style={{
|
||||||
|
width: 3,
|
||||||
|
height: `${Math.round(8 + p * 28)}px`,
|
||||||
|
borderRadius: 999,
|
||||||
|
opacity: 0.42 + p * 0.45,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SegmentCard({
|
||||||
|
segment,
|
||||||
|
peaks,
|
||||||
|
duration,
|
||||||
|
}: {
|
||||||
|
segment: TranscriptSegment
|
||||||
|
peaks: number[]
|
||||||
|
duration: number
|
||||||
|
}) {
|
||||||
|
const segDuration = Math.max(1.2, segment.end - segment.start)
|
||||||
|
const width = clamp(180 + segDuration * 42, 220, 520)
|
||||||
|
const segPeaks = slicePeaks(peaks, segment.start, segment.end, duration)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="shrink-0 rounded-lg border border-white/10 bg-white/[0.045] p-3 shadow-[0_12px_30px_-22px_rgba(0,0,0,0.8)]"
|
||||||
|
style={{ width }}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<span className="font-mono text-[10px] text-[var(--text-faint)]">
|
||||||
|
{segment.start.toFixed(1)}s -> {segment.end.toFixed(1)}s
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-white/10 px-2 py-0.5 text-[9.5px] uppercase tracking-widest text-[var(--text-faint)]">
|
||||||
|
#{segment.index + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{segment.en && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-[9.5px] uppercase tracking-widest text-violet-200/70">English</div>
|
||||||
|
<p className="line-clamp-3 text-[12px] leading-relaxed text-[var(--text-strong)]">{segment.en}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-[9.5px] uppercase tracking-widest text-emerald-200/75">中文翻译</div>
|
||||||
|
<p className="line-clamp-3 text-[12.5px] leading-relaxed text-[var(--text-strong)]">
|
||||||
|
{segment.zh || <span className="text-[var(--text-faint)] italic">翻译中...</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Waveform peaks={segPeaks} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decodeWaveform(url: string, targetPeaks = 1800) {
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) throw new Error(`audio ${res.status}`)
|
||||||
|
const arrayBuffer = await res.arrayBuffer()
|
||||||
|
const AudioContextClass = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||||
|
if (!AudioContextClass) throw new Error("AudioContext unavailable")
|
||||||
|
const ctx = new AudioContextClass()
|
||||||
|
try {
|
||||||
|
const buffer = await ctx.decodeAudioData(arrayBuffer.slice(0))
|
||||||
|
const data = buffer.getChannelData(0)
|
||||||
|
const bucket = Math.max(1, Math.floor(data.length / targetPeaks))
|
||||||
|
let maxPeak = 0.01
|
||||||
|
const raw: number[] = []
|
||||||
|
for (let i = 0; i < targetPeaks; i++) {
|
||||||
|
const start = i * bucket
|
||||||
|
const end = Math.min(data.length, start + bucket)
|
||||||
|
let peak = 0
|
||||||
|
for (let j = start; j < end; j++) peak = Math.max(peak, Math.abs(data[j] || 0))
|
||||||
|
raw.push(peak)
|
||||||
|
maxPeak = Math.max(maxPeak, peak)
|
||||||
|
}
|
||||||
|
return raw.map((p) => clamp(p / maxPeak, 0.08, 1))
|
||||||
|
} finally {
|
||||||
|
void ctx.close().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioStrip({ job }: { job: Job | null }) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const [height, setHeight] = useState(DEFAULT_HEIGHT)
|
||||||
|
const [peaks, setPeaks] = useState<number[]>([])
|
||||||
|
const dragRef = useRef<{ startY: number; startHeight: number } | null>(null)
|
||||||
|
const transcript = job?.transcript ?? []
|
||||||
|
const audioScript = job?.audio_script
|
||||||
|
const voiceUrl = apiAssetUrl(audioScript?.voice_url)
|
||||||
|
const hasAudio = !!job && (transcript.length > 0 || !!audioScript?.rewritten_text || job.status === "transcribing")
|
||||||
|
const duration = useMemo(() => {
|
||||||
|
const lastTranscriptEnd = transcript.reduce((max, s) => Math.max(max, s.end || 0), 0)
|
||||||
|
return Math.max(job?.duration ?? 0, lastTranscriptEnd, 1)
|
||||||
|
}, [job?.duration, transcript])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const stored = Number(window.localStorage.getItem(STORAGE_KEY) || "")
|
||||||
|
if (Number.isFinite(stored) && stored > 0) setHeight(clamp(stored, MIN_HEIGHT, MAX_HEIGHT))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setPeaks([])
|
||||||
|
if (!job?.id || !hasAudio) return
|
||||||
|
decodeWaveform(sourceAudioUrl(job.id))
|
||||||
|
.then((next) => {
|
||||||
|
if (!cancelled) setPeaks(next)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setPeaks(fallbackPeaks(1800, `${job.id}-${transcript.length}`))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [job?.id, hasAudio, transcript.length])
|
||||||
|
|
||||||
|
if (!hasAudio || !job) return null
|
||||||
|
|
||||||
|
const startDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
dragRef.current = { startY: e.clientY, startHeight: height }
|
||||||
|
const onMove = (ev: PointerEvent) => {
|
||||||
|
if (!dragRef.current) return
|
||||||
|
const next = clamp(dragRef.current.startHeight + (dragRef.current.startY - ev.clientY), MIN_HEIGHT, MAX_HEIGHT)
|
||||||
|
setHeight(next)
|
||||||
|
}
|
||||||
|
const onUp = () => {
|
||||||
|
if (dragRef.current) {
|
||||||
|
try { window.localStorage.setItem(STORAGE_KEY, String(height)) } catch {}
|
||||||
|
}
|
||||||
|
dragRef.current = null
|
||||||
|
window.removeEventListener("pointermove", onMove)
|
||||||
|
window.removeEventListener("pointerup", onUp)
|
||||||
|
}
|
||||||
|
window.addEventListener("pointermove", onMove)
|
||||||
|
window.addEventListener("pointerup", onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="pointer-events-auto absolute inset-x-4 bottom-4 z-40 overflow-hidden rounded-xl border border-white/12 bg-[rgba(10,13,28,0.88)] shadow-[0_24px_80px_-28px_rgba(0,0,0,0.85)] backdrop-blur-xl"
|
||||||
|
style={{ height: collapsed ? 48 : height }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex h-4 cursor-ns-resize items-center justify-center border-b border-white/8 bg-white/[0.035]"
|
||||||
|
onPointerDown={startDrag}
|
||||||
|
title="拖拽调整音频条高度"
|
||||||
|
>
|
||||||
|
<GripHorizontal className="h-3.5 w-3.5 text-white/45" />
|
||||||
|
</div>
|
||||||
|
<div className="flex h-8 items-center justify-between gap-3 border-b border-white/8 px-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<Mic2 className="h-3.5 w-3.5 shrink-0 text-violet-200" />
|
||||||
|
<span className="truncate text-[12px] font-semibold text-white/90">底部音频条 · 原文 / 翻译 / 波形</span>
|
||||||
|
<span className="rounded-full border border-white/10 px-2 py-0.5 text-[10px] text-white/45">{transcript.length || 0} 段</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{voiceUrl && (
|
||||||
|
<div className="hidden items-center gap-1.5 text-[10px] text-emerald-200/80 sm:flex">
|
||||||
|
<Volume2 className="h-3.5 w-3.5" />
|
||||||
|
MiniMax ready
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-white/10 text-white/65 transition hover:bg-white/10 hover:text-white"
|
||||||
|
title={collapsed ? "展开音频条" : "收起音频条"}
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="grid h-[calc(100%-48px)] grid-cols-[minmax(0,1fr)_300px] gap-3 p-3 max-lg:grid-cols-1">
|
||||||
|
<div className="min-w-0 overflow-x-auto overflow-y-hidden pb-1">
|
||||||
|
{transcript.length > 0 ? (
|
||||||
|
<div className="flex h-full items-stretch gap-3">
|
||||||
|
{transcript.map((segment) => (
|
||||||
|
<SegmentCard key={segment.index} segment={segment} peaks={peaks} duration={duration} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center rounded-lg border border-dashed border-white/12 text-[12px] text-white/45">
|
||||||
|
音频识别完成后,这里会按时间显示英文、中文翻译和对应波形。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 overflow-y-auto rounded-lg border border-emerald-300/20 bg-emerald-300/[0.07] p-3 max-lg:hidden">
|
||||||
|
<div className="mb-2 text-[10px] uppercase tracking-widest text-emerald-100/70">改后 · SKG 口播</div>
|
||||||
|
<p className="text-[12.5px] leading-relaxed text-white/90">
|
||||||
|
{audioScript?.rewritten_text || "等待转录完成后生成适合 SKG 产品视频的口播文案。"}
|
||||||
|
</p>
|
||||||
|
{voiceUrl && (
|
||||||
|
<audio controls src={voiceUrl} className="mt-3 h-8 w-full" />
|
||||||
|
)}
|
||||||
|
{audioScript?.product_brief && (
|
||||||
|
<div className="mt-3 border-t border-white/10 pt-2 text-[11px] leading-relaxed text-white/55">
|
||||||
|
{audioScript.product_brief}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -537,6 +537,10 @@ export function videoUrl(jobId: string): string {
|
|||||||
return `${API_BASE}/jobs/${jobId}/video.mp4`
|
return `${API_BASE}/jobs/${jobId}/video.mp4`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sourceAudioUrl(jobId: string): string {
|
||||||
|
return `${API_BASE}/jobs/${jobId}/audio.wav`
|
||||||
|
}
|
||||||
|
|
||||||
export function cleanedFrameUrl(jobId: string, frameIndex: number, bust?: string | number): string {
|
export function cleanedFrameUrl(jobId: string, frameIndex: number, bust?: string | number): string {
|
||||||
const u = `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/cleaned.jpg`
|
const u = `${API_BASE}/jobs/${jobId}/frames/${frameIndex}/cleaned.jpg`
|
||||||
return bust ? `${u}?t=${bust}` : u
|
return bust ? `${u}?t=${bust}` : u
|
||||||
|
|||||||
Reference in New Issue
Block a user