feat: 将视频转写重构为纯文字异步语音识别
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
DATABASE_URL=postgresql://postgres:password@localhost:5432/douyin_archive
|
||||
PORT=3000
|
||||
MINIO_ENDPOINT=localhost
|
||||
MINIO_PORT=9000
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_ACCESS_KEY=
|
||||
MINIO_SECRET_KEY=
|
||||
MINIO_BUCKET_NAME=home-page
|
||||
MINIO_PUBLIC_DOMAIN=https://your-public-storage.example.com
|
||||
|
||||
# Native DashScope ASR, not the OpenAI compatible-mode endpoint.
|
||||
DASHSCOPE_API_KEY=
|
||||
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
DASHSCOPE_ASR_MODEL=qwen3-asr-flash-filetrans
|
||||
|
||||
FFMPEG_PATH=ffmpeg
|
||||
FFPROBE_PATH=ffprobe
|
||||
CHROMIUM_HEADLESS=true
|
||||
@@ -32,6 +32,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -34,3 +34,73 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
## 视频文字转写(DashScope ASR)
|
||||
|
||||
仅使用 `qwen3-asr-flash-filetrans` 做文字识别,不使用通用模型理解、分类、总结或润色。新视频保存后入队;历史文字保留,后台可单条重转或批量补转。关闭页面不影响已入队任务。
|
||||
|
||||
处理流程:从 MinIO 将原视频流式下载到临时文件 → ffprobe 检查音轨及实际时长 → ffmpeg 提取整段 **16 kHz 单声道、64 kbps MP3** → 上传至 MinIO → 将 **音频 URL** 提交到 DashScope → 后台取回文字。没有分块、视频上传到模型或二次模型调用。无音轨直接保存空文字;ASR 空结果显示“未识别到文字”。
|
||||
|
||||
### 配置与启动
|
||||
|
||||
参考 `.env.example`。模型配置统一为:
|
||||
|
||||
```dotenv
|
||||
DASHSCOPE_API_KEY=你的百炼Key
|
||||
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
DASHSCOPE_ASR_MODEL=qwen3-asr-flash-filetrans
|
||||
```
|
||||
|
||||
这是 DashScope **原生 API** 地址,不是 `/compatible-mode/v1`。Key 和端点地域必须匹配。也可使用北京业务空间域名的 `/api/v1` 地址。旧 `OPENAI_*`、`QWEN_*` 和转写模块的全局代理配置不再使用;已有 Omni 的百炼 Key 可用于 ASR,仍需账号开通相应模型权限。
|
||||
|
||||
需要 ffmpeg、ffprobe(可通过 `FFMPEG_PATH`、`FFPROBE_PATH` 指定)。`MINIO_ENDPOINT`/端口/SSL 必须是运行机器可鉴权读写的 S3 API;`MINIO_PUBLIC_DOMAIN` 必须让 DashScope 能下载所上传的音频。执行器不修改 Bucket 策略。音频存储于 `transcription-audio/<视频ID>/<时间戳>.mp3`;独立对象防止手动重试覆盖仍在使用的音频。可为该前缀配置不少于 7 天的生命周期,视频原文件不受影响。本地视频临时文件在成功或失败后都会清理。
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npx prisma generate
|
||||
npm run dev
|
||||
# 另一个终端(开发与生产均需执行器)
|
||||
npm run stt:worker
|
||||
```
|
||||
|
||||
生产使用 `pm2 startOrReload pm2.config.cjs --env production --update-env`,配置同时包含 `DouyinArchive` 和 `DouyinArchive-ASR`。全局最多两个处理中任务,每 10 秒查一次供应商状态。任务保存在 PostgreSQL,不需要 Redis。未配置 Key 不阻止网站启动,转写会显示配置错误。
|
||||
|
||||
### 数据库迁移与发布顺序
|
||||
|
||||
1. 备份现有数据库,保存旧版本用于回退。
|
||||
2. 在停止服务前安装依赖、生成 Prisma Client 并运行 `npm run build`。
|
||||
3. 停止旧网站及旧后台执行器,运行 `npx prisma migrate deploy`。
|
||||
4. 用上述 PM2 命令启动新网站和 ASR 执行器,检查两者日志;确认转写与搜索后再完成发布验收。
|
||||
|
||||
迁移保留 `VideoTranscript` 的历史文字、关联和时间字段;删除 `speech_detected`、`language`、`audio_type`、`non_speech_summary`,以及部分部署存在的 `content_tags`。不自动补转或重转历史视频。新建 `TranscriptionTask` 保存状态、任务 ID、租约和重试信息。重转成功时原子替换文字,失败保留旧文字。
|
||||
|
||||
迁移会替换已知的旧全文检索触发器,并维护 `transcript_zh_tsv` 与 GIN 索引。**已有 `public.zhcfg` 中文分词配置会保留**;新库没有该配置时创建 `simple` 回退配置,它不提供中文分词。新部署若需要与旧库相同的中文搜索效果,先安装同样的中文分词扩展和 `zhcfg`;后续调整分词配置后应重新计算现有索引字段。不要在迁移后直接回滚到依赖旧字段的应用;需要回退时恢复数据库备份和旧应用版本。
|
||||
|
||||
### 接口和任务行为
|
||||
|
||||
- `POST /api/stt`,JSON `{ "awemeId": "视频ID" }`:首次转写或手动重转;处理中重复点击复用当前任务。
|
||||
- `GET /api/stt?awemeId=视频ID`:只读取 `{ status, error, transcript }`,不触发转写。`transcript` 为 `string[] | null`;`null` 表示没有完成记录,`[]` 表示完成但没有文字。
|
||||
- `POST /api/stt/batch`:一次将所有没有完成记录、且未在排队或处理中的视频加入数据库队列,失败记录可补转,历史完成记录不重转。
|
||||
- 状态为 `PENDING`(尚未入队)、`QUEUED`、`PROCESSING`、`SUCCEEDED`、`FAILED`。后台显示各状态数量、错误和重试按钮。
|
||||
|
||||
音频限制为 12 小时、2 GB,超限失败而非截断。执行器重启后复用已有供应商任务 ID;租约到期可恢复领取。提交前写入标记,提交阶段崩溃或超时无法确认任务 ID 时,标为失败并要求核对百炼任务后手动重试,避免盲目重复付费。查询和结果下载的网络错误/429/5xx 按 10 秒至 5 分钟退避重试;鉴权和明确的供应商失败不自动重提。超过 24 小时的任务标为失败。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
npm run test:stt
|
||||
# 必须是独立本地库 douyin_asr_test;此测试会重建该库的 public schema
|
||||
TEST_DATABASE_URL=postgresql://你的本地用户名@127.0.0.1:5432/douyin_asr_test npm run test:stt:integration
|
||||
npx tsc --noEmit
|
||||
npm run build
|
||||
```
|
||||
|
||||
部署后可用管理页测试短视频、超过 5 分钟的视频、无发言/无音轨视频。检查新归档自动入队、关页面继续处理、重启续查、失败保留旧文字及文字搜索。
|
||||
|
||||
也可对已归档的视频独立实测(会上传音频、调用付费 ASR;**不修改数据库里的原转写**):
|
||||
|
||||
```bash
|
||||
npx tsx scripts/stt-smoke.ts 7682010237578136858
|
||||
```
|
||||
|
||||
脚本将供应商任务 ID 和结果保存在 `/tmp/douyin-asr-<视频ID>.json`。已有未完成任务时应查询原任务,不要重复提交。
|
||||
|
||||
+181
-361
@@ -1,437 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
|
||||
type VideoTranscript = {
|
||||
id: string;
|
||||
speech_detected: boolean;
|
||||
language: string | null;
|
||||
audio_type: string | null;
|
||||
transcript: string | null;
|
||||
non_speech_summary: string | null;
|
||||
};
|
||||
import {
|
||||
isActive,
|
||||
statusLabels,
|
||||
type TranscriptionState,
|
||||
type TranscriptionStatus,
|
||||
} from "@/lib/stt/types";
|
||||
|
||||
type Video = {
|
||||
aweme_id: string;
|
||||
desc: string;
|
||||
cover_url: string;
|
||||
duration_ms: number;
|
||||
author: {
|
||||
nickname: string;
|
||||
};
|
||||
transcript: VideoTranscript | null;
|
||||
};
|
||||
|
||||
type ApiResponse = {
|
||||
videos: Video[];
|
||||
total: number;
|
||||
author: { nickname: string };
|
||||
transcription: TranscriptionState;
|
||||
};
|
||||
const filters: ("all" | TranscriptionStatus)[] = [
|
||||
"all",
|
||||
"PENDING",
|
||||
"QUEUED",
|
||||
"PROCESSING",
|
||||
"SUCCEEDED",
|
||||
"FAILED",
|
||||
];
|
||||
|
||||
export default function SttVideosPage() {
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [transcribing, setTranscribing] = useState<Set<string>>(new Set());
|
||||
const [filter, setFilter] = useState<"all" | "transcribed" | "pending">(
|
||||
"all",
|
||||
);
|
||||
const [batchTranscribing, setBatchTranscribing] = useState(false);
|
||||
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
}, []);
|
||||
|
||||
const fetchVideos = async () => {
|
||||
const [notice, setNotice] = useState("");
|
||||
const [submitting, setSubmitting] = useState<Set<string>>(new Set());
|
||||
const [batchSubmitting, setBatchSubmitting] = useState(false);
|
||||
const [filter, setFilter] = useState<"all" | TranscriptionStatus>("all");
|
||||
const fetching = useRef(false);
|
||||
const fetchVideos = useCallback(async () => {
|
||||
if (fetching.current) return;
|
||||
fetching.current = true;
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch("/api/admin/stt/videos");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch videos");
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
const response = await fetch("/api/admin/stt/videos", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) throw new Error("读取转写列表失败");
|
||||
const data: { videos: Video[] } = await response.json();
|
||||
setVideos(data.videos);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
setError(err instanceof Error ? err.message : "读取失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
fetching.current = false;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void fetchVideos();
|
||||
const timer = setInterval(() => {
|
||||
void fetchVideos();
|
||||
}, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [fetchVideos]);
|
||||
|
||||
// 简单的 sleep 工具
|
||||
const sleep = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
type PollOptions = {
|
||||
intervalMs?: number;
|
||||
maxAttempts?: number;
|
||||
};
|
||||
|
||||
// 轮询某个视频的转写状态直至完成或超时
|
||||
const pollVideoUntilTranscribed = async (
|
||||
awemeId: string,
|
||||
{ intervalMs = 2000, maxAttempts = 30 }: PollOptions = {},
|
||||
): Promise<boolean> => {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
async function handleTranscribe(awemeId: string) {
|
||||
setSubmitting((prev) => new Set(prev).add(awemeId));
|
||||
try {
|
||||
const resp = await fetch("/api/admin/stt/videos");
|
||||
if (resp.ok) {
|
||||
const data: ApiResponse = await resp.json();
|
||||
setVideos(data.videos);
|
||||
const v = data.videos.find((x) => x.aweme_id === awemeId);
|
||||
if (v && v.transcript) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略网络错误,继续轮询
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleTranscribe = async (awemeId: string) => {
|
||||
setTranscribing((prev) => new Set(prev).add(awemeId));
|
||||
try {
|
||||
const response = await fetch(`/api/stt?awemeId=${awemeId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to transcribe video");
|
||||
}
|
||||
// 轮询直到该视频转写完成或超时,避免后端异步导致立即刷新拿不到最新状态
|
||||
const done = await pollVideoUntilTranscribed(awemeId);
|
||||
if (!done) {
|
||||
// 兜底刷新一次
|
||||
await fetchVideos();
|
||||
}
|
||||
const response = await fetch("/api/stt", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ awemeId }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "任务入队失败");
|
||||
setVideos((prev) =>
|
||||
prev.map((video) =>
|
||||
video.aweme_id === awemeId
|
||||
? { ...video, transcription: data }
|
||||
: video,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Transcription failed");
|
||||
setNotice(err instanceof Error ? err.message : "任务入队失败");
|
||||
} finally {
|
||||
setTranscribing((prev) => {
|
||||
setSubmitting((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(awemeId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchTranscribe = async () => {
|
||||
const pendingVideos = videos.filter((v) => v.transcript === null);
|
||||
if (pendingVideos.length === 0) {
|
||||
alert("没有待转写的视频");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!confirm(
|
||||
`确定要转写 ${pendingVideos.length} 个视频吗?这可能需要较长时间。`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBatchTranscribing(true);
|
||||
setBatchProgress({ current: 0, total: pendingVideos.length });
|
||||
|
||||
const pollers: Promise<boolean>[] = [];
|
||||
for (let i = 0; i < pendingVideos.length; i++) {
|
||||
const video = pendingVideos[i];
|
||||
setBatchProgress({ current: i + 1, total: pendingVideos.length });
|
||||
setTranscribing((prev) => new Set(prev).add(video.aweme_id));
|
||||
|
||||
async function handleBatch() {
|
||||
setBatchSubmitting(true);
|
||||
try {
|
||||
const response = await fetch(`/api/stt?awemeId=${video.aweme_id}`);
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to transcribe video ${video.aweme_id}`);
|
||||
}
|
||||
// 为该视频启动后台轮询,不阻塞批处理的顺序执行
|
||||
const p = pollVideoUntilTranscribed(video.aweme_id).finally(() => {
|
||||
setTranscribing((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(video.aweme_id);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
pollers.push(p);
|
||||
} catch (err) {
|
||||
console.error(`Error transcribing video ${video.aweme_id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 等待所有轮询结束(完成或超时),再进行最终刷新
|
||||
if (pollers.length > 0) {
|
||||
await Promise.allSettled(pollers);
|
||||
}
|
||||
const response = await fetch("/api/stt/batch", { method: "POST" });
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "批量入队失败");
|
||||
setNotice(`已加入 ${data.queued} 个转写任务;关闭页面后仍会继续处理。`);
|
||||
await fetchVideos();
|
||||
setBatchTranscribing(false);
|
||||
setBatchProgress({ current: 0, total: 0 });
|
||||
alert("批量转写完成!");
|
||||
};
|
||||
|
||||
const filteredVideos = videos.filter((v) => {
|
||||
if (filter === "transcribed") return v.transcript !== null;
|
||||
if (filter === "pending") return v.transcript === null;
|
||||
return true;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: videos.length,
|
||||
transcribed: videos.filter((v) => v.transcript !== null).length,
|
||||
pending: videos.filter((v) => v.transcript === null).length,
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
|
||||
<p className="mt-4 text-gray-600">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch (err) {
|
||||
setNotice(err instanceof Error ? err.message : "批量入队失败");
|
||||
} finally {
|
||||
setBatchSubmitting(false);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<p className="text-red-600">错误: {error}</p>
|
||||
<button
|
||||
onClick={fetchVideos}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const count = (status: TranscriptionStatus) =>
|
||||
videos.filter((v) => v.transcription.status === status).length;
|
||||
const eligible = videos.filter(
|
||||
(v) =>
|
||||
v.transcription.transcript === null && !isActive(v.transcription.status),
|
||||
).length;
|
||||
const shown = videos.filter(
|
||||
(v) => filter === "all" || v.transcription.status === filter,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<main className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-7xl space-y-4">
|
||||
<BackButton />
|
||||
<h1 className="text-3xl font-bold text-gray-900 mt-4">
|
||||
视频转写管理
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">管理视频的语音转写状态</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-sm text-gray-600">总视频数</div>
|
||||
<div className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{stats.total}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-sm text-gray-600">已转写</div>
|
||||
<div className="text-3xl font-bold text-green-600 mt-2">
|
||||
{stats.transcribed}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-sm text-gray-600">待转写</div>
|
||||
<div className="text-3xl font-bold text-orange-600 mt-2">
|
||||
{stats.pending}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setFilter("all")}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
filter === "all"
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
全部 ({stats.total})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter("transcribed")}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
filter === "transcribed"
|
||||
? "bg-green-600 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
已转写 ({stats.transcribed})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter("pending")}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
filter === "pending"
|
||||
? "bg-orange-600 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
待转写 ({stats.pending})
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{batchTranscribing && (
|
||||
<div className="text-sm text-gray-600">
|
||||
正在转写: {batchProgress.current} / {batchProgress.total}
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">视频文字转写</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
已完成 {count("SUCCEEDED")} · 处理中 {count("PROCESSING")} · 排队中{" "}
|
||||
{count("QUEUED")} · 失败 {count("FAILED")} · 待转写 {count("PENDING")}
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={handleBatchTranscribe}
|
||||
disabled={batchTranscribing || stats.pending === 0}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{batchTranscribing ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
|
||||
批量转写中...
|
||||
</span>
|
||||
) : (
|
||||
"全部转写"
|
||||
{notice && (
|
||||
<output className="block text-sm text-gray-700">{notice}</output>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filters.map((value) => (
|
||||
<button
|
||||
type="button"
|
||||
key={value}
|
||||
onClick={() => setFilter(value)}
|
||||
className={`rounded px-3 py-2 text-sm ${filter === value ? "bg-blue-600 text-white" : "bg-white text-gray-700"}`}
|
||||
>
|
||||
{value === "all"
|
||||
? `全部 (${videos.length})`
|
||||
: `${statusLabels[value]} (${count(value)})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBatch}
|
||||
disabled={batchSubmitting || !eligible}
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{batchSubmitting ? "正在加入队列…" : `批量补转 (${eligible})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video List */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<div className="overflow-x-auto rounded border border-gray-200 bg-white">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-gray-100 text-gray-600">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
视频
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
时长
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
转写状态
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
<th className="p-3">视频</th>
|
||||
<th className="p-3">时长</th>
|
||||
<th className="p-3">状态与文字</th>
|
||||
<th className="p-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredVideos.map((video) => (
|
||||
<tr key={video.aweme_id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
<tbody>
|
||||
{shown.map((video) => {
|
||||
const state = video.transcription;
|
||||
const active =
|
||||
isActive(state.status) || submitting.has(video.aweme_id);
|
||||
return (
|
||||
<tr
|
||||
key={video.aweme_id}
|
||||
className="border-t border-gray-100 align-top"
|
||||
>
|
||||
<td className="p-3">
|
||||
<div className="flex max-w-sm gap-3">
|
||||
{video.cover_url && (
|
||||
<Image
|
||||
src={video.cover_url}
|
||||
alt=""
|
||||
className="w-20 h-28 object-cover rounded"
|
||||
width={56}
|
||||
height={80}
|
||||
unoptimized
|
||||
className="h-20 w-14 rounded object-cover"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
)}
|
||||
<div>
|
||||
<Link
|
||||
className="line-clamp-2 text-blue-700"
|
||||
href={`/aweme/${video.aweme_id}`}
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-800 line-clamp-2"
|
||||
>
|
||||
{video.desc || "无描述"}
|
||||
</Link>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
<p className="mt-1 text-gray-500">
|
||||
@{video.author.nickname}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{Math.floor(video.duration_ms / 1000)}s
|
||||
<td className="whitespace-nowrap p-3 text-gray-700">
|
||||
{Math.floor(video.duration_ms / 1000)} 秒
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{video.transcript ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
已转写
|
||||
</span>
|
||||
{video.transcript.language && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{video.transcript.language}
|
||||
</span>
|
||||
<td className="p-3">
|
||||
<p
|
||||
className={
|
||||
state.status === "FAILED"
|
||||
? "text-red-700"
|
||||
: "text-gray-700"
|
||||
}
|
||||
>
|
||||
{statusLabels[state.status]}
|
||||
</p>
|
||||
{state.error && (
|
||||
<p className="mt-1 max-w-lg text-xs text-red-700">
|
||||
{state.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{video.transcript.speech_detected ? (
|
||||
<div className="text-xs text-gray-600 line-clamp-5 max-w-md">
|
||||
{video.transcript.transcript}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500">
|
||||
<span className="font-medium">
|
||||
{video.transcript.audio_type || "非语音"}
|
||||
</span>
|
||||
{video.transcript.non_speech_summary && (
|
||||
<span className="ml-1">
|
||||
- {video.transcript.non_speech_summary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
|
||||
待转写
|
||||
</span>
|
||||
{state.transcript !== null && (
|
||||
<p className="mt-1 max-w-lg whitespace-pre-wrap text-gray-600">
|
||||
{state.transcript.join("\n") || "未识别到文字"}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{video.transcript ? (
|
||||
<td className="whitespace-nowrap p-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={active}
|
||||
onClick={() => handleTranscribe(video.aweme_id)}
|
||||
disabled={transcribing.has(video.aweme_id)}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
className="rounded bg-gray-100 px-3 py-2 text-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{transcribing.has(video.aweme_id) ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
|
||||
重新转写...
|
||||
</span>
|
||||
) : (
|
||||
"重新转写"
|
||||
)}
|
||||
{active
|
||||
? "处理中…"
|
||||
: state.status === "FAILED"
|
||||
? "重试"
|
||||
: state.transcript !== null
|
||||
? "重新转写"
|
||||
: "开始转写"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleTranscribe(video.aweme_id)}
|
||||
disabled={transcribing.has(video.aweme_id)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{transcribing.has(video.aweme_id) ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></span>
|
||||
转写中...
|
||||
</span>
|
||||
) : (
|
||||
"开始转写"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredVideos.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">没有找到视频</div>
|
||||
{!shown.length && (
|
||||
<p className="p-8 text-center text-gray-500">
|
||||
{loading ? "加载中…" : "没有找到视频"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { transcriptionState } from "@/lib/stt/types";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const videos = await prisma.video.findMany({
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 1000,
|
||||
include: {
|
||||
author: {
|
||||
select: {
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
transcriptionTask: true,
|
||||
transcript: {
|
||||
select: {
|
||||
id: true,
|
||||
speech_detected: true,
|
||||
language: true,
|
||||
audio_type: true,
|
||||
transcript: true,
|
||||
non_speech_summary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -29,21 +26,12 @@ export async function GET() {
|
||||
const formattedVideos = videos.map((v) => ({
|
||||
aweme_id: v.aweme_id,
|
||||
desc: v.desc,
|
||||
cover_url: getFileUrl(v.cover_url ?? ""),
|
||||
cover_url: v.cover_url ? getFileUrl(v.cover_url) : "",
|
||||
duration_ms: v.duration_ms,
|
||||
author: {
|
||||
nickname: v.author.nickname,
|
||||
},
|
||||
transcript: v.transcript
|
||||
? {
|
||||
id: v.transcript.id,
|
||||
speech_detected: v.transcript.speech_detected,
|
||||
language: v.transcript.language,
|
||||
audio_type: v.transcript.audio_type,
|
||||
transcript: v.transcript.transcript,
|
||||
non_speech_summary: v.transcript.non_speech_summary,
|
||||
}
|
||||
: null,
|
||||
transcription: transcriptionState(v.transcript, v.transcriptionTask),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
releaseIsolatedContext,
|
||||
} from "@/app/api/fetcher/browser";
|
||||
import { extractFirstFrame } from "@/app/api/media";
|
||||
import { transcriptAweme } from "../stt";
|
||||
import { enqueueTranscription } from "@/lib/stt/tasks";
|
||||
|
||||
const DETAIL_PATH = "/aweme/v1/web/aweme/detail/";
|
||||
const COMMENT_PATH = "/aweme/v1/web/comment/list/";
|
||||
@@ -429,7 +429,11 @@ export async function scrapeDouyin(url: string) {
|
||||
fps ?? undefined,
|
||||
);
|
||||
console.log(chalk.green.bold("✓ 视频作品保存成功"));
|
||||
transcriptAweme(detail.aweme_detail.aweme_id).catch((e) => {}); // 异步转写,不阻塞主流程
|
||||
try {
|
||||
await enqueueTranscription(detail.aweme_detail.aweme_id);
|
||||
} catch {
|
||||
console.error("视频已保存,但转写任务入队失败,可在后台批量补转");
|
||||
}
|
||||
return { type: "video", ...saved };
|
||||
} else {
|
||||
throw new ScrapeError(
|
||||
|
||||
+16
-6
@@ -111,17 +111,17 @@ export async function getVideoDuration(
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 ffmpeg 将视频二进制转换为音频,返回音频 Buffer
|
||||
* 使用 ffmpeg 将视频 Buffer 或本地文件转换为音频,返回音频 Buffer
|
||||
*
|
||||
* 默认输出 mp3,可通过 opts.format 指定 'mp3' | 'aac' | 'wav'
|
||||
* - mp3: 使用 libmp3lame,contentType 为 'audio/mpeg'
|
||||
* - mp3: 使用 libmp3lame,16 kHz 单声道,contentType 为 'audio/mpeg'
|
||||
* - aac: 输出 m4a 容器(AAC 编码),contentType 为 'audio/mp4'
|
||||
* - wav: PCM 16-bit,44100Hz,双声道,contentType 为 'audio/wav'
|
||||
*
|
||||
* 若系统未安装 ffmpeg 或未在 PATH 中,返回 null 并给出提示。
|
||||
*/
|
||||
export async function extractAudio(
|
||||
videoBuffer: Buffer,
|
||||
videoBuffer: Buffer | string,
|
||||
opts?: { format?: "mp3" | "aac" | "wav"; bitrateKbps?: number },
|
||||
): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
|
||||
const ffmpegCmd = process.env.FFMPEG_PATH || "ffmpeg";
|
||||
@@ -129,6 +129,7 @@ export async function extractAudio(
|
||||
const base = `dy_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const inPath = path.join(tmpDir, `${base}.mp4`);
|
||||
|
||||
const inputPath = typeof videoBuffer === "string" ? videoBuffer : inPath;
|
||||
const format = opts?.format ?? "mp3";
|
||||
const bitrate = Math.max(32, Math.min(512, opts?.bitrateKbps ?? 192)); // 安全范围 32~512 kbps
|
||||
|
||||
@@ -174,19 +175,28 @@ export async function extractAudio(
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.writeFile(inPath, videoBuffer);
|
||||
if (typeof videoBuffer !== "string") await fs.writeFile(inPath, videoBuffer);
|
||||
const args = [
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
inPath,
|
||||
inputPath,
|
||||
"-map",
|
||||
"0:a:0",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-vn", // 丢弃视频流
|
||||
...codecArgs,
|
||||
"-y",
|
||||
outPath,
|
||||
];
|
||||
await execFileAsync(ffmpegCmd, args, { windowsHide: true });
|
||||
await execFileAsync(ffmpegCmd, args, {
|
||||
windowsHide: true,
|
||||
timeout: 60 * 60 * 1000,
|
||||
});
|
||||
const audio = await fs.readFile(outPath);
|
||||
return { buffer: audio, contentType, ext };
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAsrConfig } from "@/lib/stt/dashscope";
|
||||
import { enqueueMissingTranscriptions } from "@/lib/stt/tasks";
|
||||
export const runtime = "nodejs";
|
||||
export async function POST() {
|
||||
try {
|
||||
getAsrConfig();
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(
|
||||
{ queued: await enqueueMissingTranscriptions() },
|
||||
{ status: 202 },
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "批量任务入队失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import fs from "fs";
|
||||
import OpenAI from "openai";
|
||||
import prompt from "./prompt.md";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { downloadFile } from "@/lib/minio";
|
||||
import { extractAudio } from "../media";
|
||||
import { z } from "zod";
|
||||
import { zodResponseFormat } from "openai/helpers/zod";
|
||||
import { ProxyAgent, setGlobalDispatcher } from "undici";
|
||||
|
||||
const proxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
|
||||
if (proxy) {
|
||||
setGlobalDispatcher(new ProxyAgent(proxy));
|
||||
}
|
||||
|
||||
const client = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
baseURL: process.env.OPENAI_API_BASE_URL,
|
||||
});
|
||||
|
||||
// Zod schema aligned with prompt.md
|
||||
export const SttSchema = z
|
||||
.object({
|
||||
speech_detected: z.boolean(),
|
||||
language: z.string().min(1).nullable(),
|
||||
audio_type: z.string().nullable(),
|
||||
transcript: z.array(z.string()),
|
||||
non_speech_summary: z.string().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type SttResult = z.infer<typeof SttSchema>;
|
||||
|
||||
async function transcriptAudio(audio: Buffer | string) {
|
||||
if (typeof audio === "string") {
|
||||
audio = fs.readFileSync(audio);
|
||||
}
|
||||
const base64Audio = Buffer.from(audio).toString("base64");
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model: "gemini-2.5-flash",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: prompt },
|
||||
{
|
||||
type: "input_audio",
|
||||
input_audio: { data: base64Audio, format: "mp3" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
response_format: zodResponseFormat(SttSchema, "stt_result"),
|
||||
});
|
||||
|
||||
const data = completion.choices?.[0]?.message;
|
||||
console.log("转写结果", data.content);
|
||||
|
||||
if (!data || !data.content) {
|
||||
throw new Error("No STT result returned from model");
|
||||
}
|
||||
try {
|
||||
return JSON.parse(data.content) as SttResult;
|
||||
} catch (e) {
|
||||
throw new Error("Failed to parse STT result JSON");
|
||||
}
|
||||
}
|
||||
|
||||
export async function transcriptAweme(awemeId: string): Promise<SttResult> {
|
||||
const aweme = await prisma.video.findUnique({
|
||||
where: { aweme_id: awemeId },
|
||||
});
|
||||
if (!aweme) {
|
||||
throw new Error("Aweme not found or aweme is not a video post");
|
||||
}
|
||||
const vPath = aweme.video_url;
|
||||
const buffer = await downloadFile(vPath);
|
||||
|
||||
const audioDat = await extractAudio(buffer, {
|
||||
format: "mp3",
|
||||
bitrateKbps: 128,
|
||||
});
|
||||
|
||||
if (!audioDat || !audioDat.buffer) {
|
||||
throw new Error("Failed to extract audio from video");
|
||||
}
|
||||
|
||||
// 调用大模型生成转写结果
|
||||
const result = await transcriptAudio(audioDat.buffer);
|
||||
|
||||
// 将转写结果持久化到数据库
|
||||
await prisma.videoTranscript.upsert({
|
||||
where: { videoId: awemeId },
|
||||
update: {
|
||||
speech_detected: result.speech_detected,
|
||||
language: result.language,
|
||||
audio_type: result.audio_type,
|
||||
transcript: result.transcript,
|
||||
non_speech_summary: result.non_speech_summary,
|
||||
},
|
||||
create: {
|
||||
videoId: awemeId,
|
||||
speech_detected: result.speech_detected,
|
||||
language: result.language,
|
||||
audio_type: result.audio_type,
|
||||
transcript: result.transcript,
|
||||
non_speech_summary: result.non_speech_summary,
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
你将接收一段音频。请完成:你将接收一段音频。语音活动检测(VAD)与声源分类。
|
||||
|
||||
**输出 JSON Schema(示例)**
|
||||
|
||||
```json
|
||||
{
|
||||
"speech_detected": true,
|
||||
"language": "zh-CN",
|
||||
"audio_type": null,
|
||||
"transcript": ["大家好,我是xxx", "欢迎来到今天的视频", "今天我们来聊聊AI的未来"],
|
||||
"non_speech_summary": null,
|
||||
}
|
||||
```
|
||||
|
||||
**当无发言时返回:**
|
||||
|
||||
```json
|
||||
{
|
||||
"speech_detected": false,
|
||||
"language": null,
|
||||
"audio_type": "music | ambience | animal | mechanical | other",
|
||||
"transcript": [],
|
||||
"non_speech_summary": "示例:纯音乐-钢琴独奏,节奏舒缓;或 环境声-雨声伴随雷鸣。",
|
||||
}
|
||||
```
|
||||
|
||||
** 规则补充 **
|
||||
|
||||
* 允许`language` 为多标签(如 "zh-CN,en")或为 `null`(无发言时)。
|
||||
+29
-19
@@ -1,23 +1,33 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { enqueueTranscription, readTranscription } from "@/lib/stt/tasks";
|
||||
import { isActive } from "@/lib/stt/types";
|
||||
export const runtime = "nodejs";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { transcriptAweme } from ".";
|
||||
|
||||
// Contract
|
||||
// Inputs: search params { before?: ISOString, limit?: number }
|
||||
// Output: { items: FeedItem[], nextCursor: ISOString | null }
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
const validId = (id: unknown): id is string =>
|
||||
typeof id === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(id);
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const awemeId = searchParams.get("awemeId");
|
||||
if (!awemeId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing awemeId parameter" },
|
||||
{ status: 400 },
|
||||
);
|
||||
const id = req.nextUrl.searchParams.get("awemeId");
|
||||
if (!validId(id))
|
||||
return NextResponse.json({ error: "需要有效的 awemeId" }, { status: 400 });
|
||||
try {
|
||||
const state = await readTranscription(id);
|
||||
return state
|
||||
? NextResponse.json(state)
|
||||
: NextResponse.json({ error: "视频不存在" }, { status: 404 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "读取转写状态失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!validId(body?.awemeId))
|
||||
return NextResponse.json({ error: "需要有效的 awemeId" }, { status: 400 });
|
||||
try {
|
||||
const state = await enqueueTranscription(body.awemeId, true);
|
||||
return state
|
||||
? NextResponse.json(state, { status: isActive(state.status) ? 202 : 200 })
|
||||
: NextResponse.json({ error: "视频不存在" }, { status: 404 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "转写任务入队失败" }, { status: 500 });
|
||||
}
|
||||
const script = await transcriptAweme(awemeId);
|
||||
return NextResponse.json(script);
|
||||
}
|
||||
|
||||
@@ -556,7 +556,7 @@ export default function AwemeDetailClient({
|
||||
segmentProgress={imageCarouselState.segProgress}
|
||||
musicUrl={isVideo ? undefined : (data as ImageData).music_url}
|
||||
audioRef={audioRef}
|
||||
hasTranscript={isVideo && !!transcript?.speech_detected}
|
||||
hasTranscript={isVideo && !!transcript}
|
||||
onShowTranscript={() => setTranscriptOpen(true)}
|
||||
onTogglePlay={togglePlay}
|
||||
onSeek={handleControlSeek}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function TranscriptPanel({
|
||||
});
|
||||
};
|
||||
|
||||
if (!transcript?.speech_detected || !transcript.transcript?.length) {
|
||||
if (!transcript?.transcript?.length) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@@ -60,7 +60,7 @@ export function TranscriptPanel({
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="p-8 text-center text-white/60">
|
||||
<p>该视频暂无语音转录内容</p>
|
||||
<p>{transcript ? "未识别到文字" : "该视频尚未转写"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,11 +80,7 @@ export function TranscriptPanel({
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10 flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">语音转录</h2>
|
||||
{transcript.language && (
|
||||
<p className="text-xs text-white/50 mt-1">
|
||||
语言: {transcript.language}
|
||||
</p>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
|
||||
@@ -53,8 +53,6 @@ export type ImageData = {
|
||||
export type AwemeData = VideoData | ImageData;
|
||||
|
||||
export type VideoTranscript = {
|
||||
speech_detected: boolean;
|
||||
language: string | null;
|
||||
transcript: string[];
|
||||
};
|
||||
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "douyin-archive",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.3",
|
||||
"chalk": "^5.6.2",
|
||||
"dotenv": "^16.6.1",
|
||||
"lucide-react": "^0.546.0",
|
||||
"minio": "^8.0.6",
|
||||
"next": "15.5.7",
|
||||
"openai": "^6.7.0",
|
||||
"playwright": "1.56.1",
|
||||
"playwright-extra": "^4.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"undici": "^7.16.0",
|
||||
"zod": "^4.1.12",
|
||||
"tsx": "^4.20.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.0",
|
||||
@@ -25,7 +25,6 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"prisma": "^6.16.3",
|
||||
"raw-loader": "^4.0.2",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
},
|
||||
@@ -54,6 +53,58 @@
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA=="],
|
||||
@@ -108,8 +159,6 @@
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
@@ -182,14 +231,6 @@
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
|
||||
|
||||
"@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@20.19.22", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ=="],
|
||||
@@ -198,52 +239,8 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="],
|
||||
|
||||
"@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="],
|
||||
|
||||
"@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="],
|
||||
|
||||
"@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="],
|
||||
|
||||
"@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="],
|
||||
|
||||
"@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="],
|
||||
|
||||
"@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="],
|
||||
|
||||
"@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="],
|
||||
|
||||
"@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="],
|
||||
|
||||
"@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="],
|
||||
|
||||
"@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="],
|
||||
|
||||
"@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="],
|
||||
|
||||
"@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="],
|
||||
|
||||
"@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="],
|
||||
|
||||
"@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="],
|
||||
|
||||
"@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="],
|
||||
|
||||
"@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="],
|
||||
|
||||
"@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],
|
||||
|
||||
"@zxing/text-encoding": ["@zxing/text-encoding@0.9.0", "", {}, "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="],
|
||||
|
||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
|
||||
|
||||
"ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="],
|
||||
|
||||
"arr-union": ["arr-union@3.1.0", "", {}, "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
@@ -252,22 +249,14 @@
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ=="],
|
||||
|
||||
"big.js": ["big.js@5.2.2", "", {}, "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ=="],
|
||||
|
||||
"block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="],
|
||||
|
||||
"browserslist": ["browserslist@4.27.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", "electron-to-chromium": "^1.5.238", "node-releases": "^2.0.26", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw=="],
|
||||
|
||||
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
|
||||
|
||||
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
||||
@@ -284,16 +273,12 @@
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
||||
"chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="],
|
||||
|
||||
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"clone-deep": ["clone-deep@0.2.4", "", { "dependencies": { "for-own": "^0.1.3", "is-plain-object": "^2.0.1", "kind-of": "^3.0.2", "lazy-cache": "^1.0.3", "shallow-clone": "^0.1.2" } }, "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg=="],
|
||||
|
||||
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
|
||||
@@ -324,10 +309,6 @@
|
||||
|
||||
"effect": ["effect@3.16.12", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.240", "", {}, "sha512-OBwbZjWgrCOH+g6uJsA2/7Twpas2OlepS9uvByJjR2datRDuKGYeD+nP8lBBks2qnB7bGJNHDUx7c/YLaT3QMQ=="],
|
||||
|
||||
"emojis-list": ["emojis-list@3.0.0", "", {}, "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q=="],
|
||||
|
||||
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
||||
@@ -336,32 +317,16 @@
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],
|
||||
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
||||
|
||||
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
||||
|
||||
"exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="],
|
||||
|
||||
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="],
|
||||
|
||||
"filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="],
|
||||
@@ -390,14 +355,10 @@
|
||||
|
||||
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
@@ -430,16 +391,8 @@
|
||||
|
||||
"isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="],
|
||||
|
||||
"jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
||||
|
||||
"kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
|
||||
@@ -468,10 +421,6 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
|
||||
|
||||
"loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="],
|
||||
|
||||
"loader-utils": ["loader-utils@2.0.4", "", { "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", "json5": "^2.1.2" } }, "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw=="],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
|
||||
@@ -482,8 +431,6 @@
|
||||
|
||||
"merge-deep": ["merge-deep@3.0.3", "", { "dependencies": { "arr-union": "^3.1.0", "clone-deep": "^0.2.4", "kind-of": "^3.0.2" } }, "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA=="],
|
||||
|
||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
@@ -502,22 +449,16 @@
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
|
||||
|
||||
"next": ["next@15.5.7", "https://registry.npmmirror.com/next/-/next-15.5.7.tgz", { "dependencies": { "@next/env": "15.5.7", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.7", "@next/swc-darwin-x64": "15.5.7", "@next/swc-linux-arm64-gnu": "15.5.7", "@next/swc-linux-arm64-musl": "15.5.7", "@next/swc-linux-x64-gnu": "15.5.7", "@next/swc-linux-x64-musl": "15.5.7", "@next/swc-win32-arm64-msvc": "15.5.7", "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ=="],
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.26", "", {}, "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA=="],
|
||||
|
||||
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
|
||||
|
||||
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"openai": ["openai@6.7.0", "https://registry.npmmirror.com/openai/-/openai-6.7.0.tgz", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-mgSQXa3O/UXTbA8qFzoa7aydbXBJR5dbLQXCRapAOtoNT+v69sLdKMZzgiakpqhclRnhPggPAXoniVGn2kMY2A=="],
|
||||
|
||||
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
@@ -540,8 +481,6 @@
|
||||
|
||||
"prisma": ["prisma@6.17.1", "", { "dependencies": { "@prisma/config": "6.17.1", "@prisma/engines": "6.17.1" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-ac6h0sM1Tg3zu8NInY+qhP/S9KhENVaw9n1BrGKQVFu05JT5yT5Qqqmb8tMRIE3ZXvVj4xcRA5yfrsy4X7Yy5g=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"puppeteer-extra-plugin": ["puppeteer-extra-plugin@3.2.3", "", { "dependencies": { "@types/debug": "^4.1.0", "debug": "^4.1.1", "merge-deep": "^3.0.1" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "optionalPeers": ["playwright-extra", "puppeteer-extra"] }, "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q=="],
|
||||
|
||||
"puppeteer-extra-plugin-stealth": ["puppeteer-extra-plugin-stealth@2.11.2", "", { "dependencies": { "debug": "^4.1.1", "puppeteer-extra-plugin": "^3.2.3", "puppeteer-extra-plugin-user-preferences": "^2.4.1" }, "peerDependencies": { "playwright-extra": "*", "puppeteer-extra": "*" }, "optionalPeers": ["playwright-extra", "puppeteer-extra"] }, "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ=="],
|
||||
@@ -554,10 +493,6 @@
|
||||
|
||||
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
|
||||
|
||||
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
|
||||
|
||||
"raw-loader": ["raw-loader@4.0.2", "", { "dependencies": { "loader-utils": "^2.0.0", "schema-utils": "^3.0.0" }, "peerDependencies": { "webpack": "^4.0.0 || ^5.0.0" } }, "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA=="],
|
||||
|
||||
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
|
||||
|
||||
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
||||
@@ -568,8 +503,6 @@
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
@@ -580,24 +513,16 @@
|
||||
|
||||
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||
|
||||
"schema-utils": ["schema-utils@3.3.0", "", { "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", "ajv-keywords": "^3.5.2" } }, "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg=="],
|
||||
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="],
|
||||
|
||||
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
||||
|
||||
"shallow-clone": ["shallow-clone@0.1.2", "", { "dependencies": { "is-extendable": "^0.1.1", "kind-of": "^2.0.1", "lazy-cache": "^0.2.3", "mixin-object": "^2.0.1" } }, "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw=="],
|
||||
|
||||
"sharp": ["sharp@0.34.4", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.0", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.4", "@img/sharp-darwin-x64": "0.34.4", "@img/sharp-libvips-darwin-arm64": "1.2.3", "@img/sharp-libvips-darwin-x64": "1.2.3", "@img/sharp-libvips-linux-arm": "1.2.3", "@img/sharp-libvips-linux-arm64": "1.2.3", "@img/sharp-libvips-linux-ppc64": "1.2.3", "@img/sharp-libvips-linux-s390x": "1.2.3", "@img/sharp-libvips-linux-x64": "1.2.3", "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", "@img/sharp-libvips-linuxmusl-x64": "1.2.3", "@img/sharp-linux-arm": "0.34.4", "@img/sharp-linux-arm64": "0.34.4", "@img/sharp-linux-ppc64": "0.34.4", "@img/sharp-linux-s390x": "0.34.4", "@img/sharp-linux-x64": "0.34.4", "@img/sharp-linuxmusl-arm64": "0.34.4", "@img/sharp-linuxmusl-x64": "0.34.4", "@img/sharp-wasm32": "0.34.4", "@img/sharp-win32-arm64": "0.34.4", "@img/sharp-win32-ia32": "0.34.4", "@img/sharp-win32-x64": "0.34.4" } }, "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
|
||||
|
||||
"stream-chain": ["stream-chain@2.2.5", "", {}, "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA=="],
|
||||
@@ -612,48 +537,32 @@
|
||||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="],
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"tar": ["tar@7.5.1", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g=="],
|
||||
|
||||
"terser": ["terser@5.44.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w=="],
|
||||
|
||||
"terser-webpack-plugin": ["terser-webpack-plugin@5.3.14", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw=="],
|
||||
|
||||
"through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
"tsx": ["tsx@4.23.15", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw=="],
|
||||
|
||||
"undici": ["undici@7.16.0", "https://registry.npmmirror.com/undici/-/undici-7.16.0.tgz", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="],
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"watchpack": ["watchpack@2.4.4", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA=="],
|
||||
|
||||
"web-encoding": ["web-encoding@1.1.5", "", { "dependencies": { "util": "^0.12.3" }, "optionalDependencies": { "@zxing/text-encoding": "0.9.0" } }, "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA=="],
|
||||
|
||||
"webpack": ["webpack@5.102.1", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.26.3", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.17.3", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ=="],
|
||||
|
||||
"webpack-sources": ["webpack-sources@3.3.3", "", {}, "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg=="],
|
||||
|
||||
"which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
@@ -664,8 +573,6 @@
|
||||
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
|
||||
@@ -678,10 +585,6 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"mixin-object/for-in": ["for-in@0.1.8", "", {}, "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
@@ -690,22 +593,6 @@
|
||||
|
||||
"shallow-clone/lazy-cache": ["lazy-cache@0.2.7", "", {}, "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ=="],
|
||||
|
||||
"terser-webpack-plugin/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
|
||||
|
||||
"webpack/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
|
||||
|
||||
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"terser-webpack-plugin/schema-utils/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
"terser-webpack-plugin/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="],
|
||||
|
||||
"webpack/schema-utils/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
"webpack/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="],
|
||||
|
||||
"terser-webpack-plugin/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"webpack/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
"tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
-9
@@ -1,9 +0,0 @@
|
||||
declare module "*.md" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "*.txt" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
@@ -338,3 +338,8 @@ export default {
|
||||
validateFileSize,
|
||||
formatFileSize,
|
||||
};
|
||||
|
||||
/** Upload derived ASR audio without changing bucket policy. */
|
||||
export async function uploadTranscriptionAudio(path: string, buffer: Buffer) {
|
||||
await minioClient.putObject(BUCKET_NAME, path, buffer, buffer.length, { "Content-Type": "audio/mpeg" });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { extractAudio } from "../../app/api/media";
|
||||
import { getFileStream, getFileUrl, uploadTranscriptionAudio } from "../minio";
|
||||
import { prisma } from "../prisma";
|
||||
import { AsrError, MAX_DURATION_MS, validateMedia } from "./dashscope";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Whole-file transcription: download to disk, extract once, upload only audio.
|
||||
export async function prepareAudio(videoId: string): Promise<string | null> {
|
||||
const video = await prisma.video.findUniqueOrThrow({
|
||||
where: { aweme_id: videoId },
|
||||
});
|
||||
if (video.duration_ms > MAX_DURATION_MS)
|
||||
throw new AsrError("视频超过 ASR 的 12 小时时长限制");
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "douyin-asr-"));
|
||||
try {
|
||||
const input = path.join(dir, "video.mp4");
|
||||
await pipeline(
|
||||
await getFileStream(video.video_url),
|
||||
createWriteStream(input),
|
||||
{ signal: AbortSignal.timeout(60 * 60 * 1000) },
|
||||
);
|
||||
const { stdout } = await execFileAsync(
|
||||
process.env.FFPROBE_PATH || "ffprobe",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"stream=index:format=duration",
|
||||
"-of",
|
||||
"json",
|
||||
input,
|
||||
],
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
const probe = JSON.parse(stdout) as {
|
||||
streams?: { index: number }[];
|
||||
format?: { duration?: string };
|
||||
};
|
||||
if (!Array.isArray(probe.streams)) throw new AsrError("无法读取视频音轨");
|
||||
if (!probe.streams.length) return null;
|
||||
const duration = Number(probe.format?.duration) * 1000;
|
||||
if (!Number.isFinite(duration) || duration <= 0)
|
||||
throw new AsrError("无法读取音频时长");
|
||||
if (duration > MAX_DURATION_MS)
|
||||
throw new AsrError("音频超过 ASR 的 12 小时时长限制");
|
||||
const audio = await extractAudio(input, { format: "mp3", bitrateKbps: 64 });
|
||||
if (!audio) throw new AsrError("未找到 ffmpeg,请安装或配置 FFMPEG_PATH");
|
||||
validateMedia(duration, audio.buffer.length);
|
||||
const objectPath = `transcription-audio/${encodeURIComponent(videoId)}/${Date.now()}.mp3`;
|
||||
await uploadTranscriptionAudio(objectPath, audio.buffer);
|
||||
const url = getFileUrl(objectPath);
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new AsrError("请配置公网可访问的 MINIO_PUBLIC_DOMAIN");
|
||||
}
|
||||
if (!["http:", "https:"].includes(parsed.protocol))
|
||||
throw new AsrError("音频 URL 协议无效");
|
||||
const response = await fetch(url, {
|
||||
method: "HEAD",
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new AsrError(`音频公共地址不可访问 (HTTP ${response.status})`);
|
||||
return url;
|
||||
} catch (error) {
|
||||
if (error instanceof AsrError) throw error;
|
||||
throw new AsrError(
|
||||
"音频准备失败,请检查 MinIO、ffmpeg、ffprobe 和文件可访问性后重试",
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
export class AsrError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public retryable = false,
|
||||
public definitiveRejection = false,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export const MAX_DURATION_MS = 12 * 60 * 60 * 1000;
|
||||
export const MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
export function validateMedia(durationMs: number, size: number) {
|
||||
if (durationMs > MAX_DURATION_MS)
|
||||
throw new AsrError("音频超过 ASR 的 12 小时时长限制");
|
||||
if (!Number.isFinite(size) || size <= 0)
|
||||
throw new AsrError("音频文件为空或大小未知");
|
||||
if (size > MAX_BYTES) throw new AsrError("音频超过 ASR 的 2 GB 大小限制");
|
||||
}
|
||||
export function getAsrConfig() {
|
||||
const apiKey = process.env.DASHSCOPE_API_KEY?.trim();
|
||||
if (!apiKey) throw new AsrError("未配置 DASHSCOPE_API_KEY,请配置后重试");
|
||||
const baseURL = (
|
||||
process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com/api/v1"
|
||||
).replace(/\/+$/, "");
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(baseURL);
|
||||
} catch {
|
||||
throw new AsrError("DASHSCOPE_BASE_URL 不是有效地址");
|
||||
}
|
||||
if (
|
||||
!["https:", "http:"].includes(url.protocol) ||
|
||||
!url.pathname.endsWith("/api/v1")
|
||||
) {
|
||||
throw new AsrError("DASHSCOPE_BASE_URL 应为原生 API 地址,以 /api/v1 结尾");
|
||||
}
|
||||
const model = process.env.DASHSCOPE_ASR_MODEL || "qwen3-asr-flash-filetrans";
|
||||
if (!/^qwen3-asr-flash-filetrans(?:-\d{4}-\d{2}-\d{2})?$/.test(model)) {
|
||||
throw new AsrError(
|
||||
"DASHSCOPE_ASR_MODEL 必须是 qwen3-asr-flash-filetrans 或其日期版本",
|
||||
);
|
||||
}
|
||||
return { apiKey, baseURL, model };
|
||||
}
|
||||
type ObjectValue = Record<string, unknown>;
|
||||
function object(value: unknown): ObjectValue {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||
throw new AsrError("ASR 返回格式异常");
|
||||
return value as ObjectValue;
|
||||
}
|
||||
function safeCode(value: unknown) {
|
||||
return typeof value === "string" && /^[A-Za-z0-9_.-]{1,100}$/.test(value)
|
||||
? value
|
||||
: "UNKNOWN";
|
||||
}
|
||||
async function request(url: string, init: RequestInit = {}): Promise<unknown> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch {
|
||||
throw new AsrError("ASR 网络请求失败或超时", true);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const raw = await response.json().catch(() => null);
|
||||
const body = raw && typeof raw === "object" ? (raw as ObjectValue) : {};
|
||||
throw new AsrError(
|
||||
`ASR HTTP ${response.status} (${safeCode(body.code)})`,
|
||||
response.status === 429 || response.status >= 500,
|
||||
response.status >= 400 &&
|
||||
response.status < 500 &&
|
||||
response.status !== 429,
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
throw new AsrError("ASR 返回非 JSON 数据", true);
|
||||
}
|
||||
}
|
||||
export async function submitTranscription(fileURL: string): Promise<string> {
|
||||
const { apiKey, baseURL, model } = getAsrConfig();
|
||||
const data = object(
|
||||
await request(`${baseURL}/services/audio/asr/transcription`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-DashScope-Async": "enable",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
input: { file_url: fileURL },
|
||||
parameters: { channel_id: [0], enable_itn: false, enable_words: false },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const output = object(data.output);
|
||||
if (typeof output.task_id !== "string" || !output.task_id)
|
||||
throw new AsrError("ASR 未返回任务 ID,提交结果不确定");
|
||||
return output.task_id;
|
||||
}
|
||||
export type AsrTask =
|
||||
| { status: "PENDING" | "RUNNING" }
|
||||
| { status: "SUCCEEDED"; resultURL: string };
|
||||
export async function queryTranscription(taskId: string): Promise<AsrTask> {
|
||||
const { apiKey, baseURL } = getAsrConfig();
|
||||
const data = object(
|
||||
await request(`${baseURL}/tasks/${encodeURIComponent(taskId)}`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
}),
|
||||
);
|
||||
const output = object(data.output);
|
||||
if (output.task_status === "PENDING" || output.task_status === "RUNNING")
|
||||
return { status: output.task_status };
|
||||
if (output.task_status === "SUCCEEDED") {
|
||||
const result = object(output.result);
|
||||
if (typeof result.transcription_url !== "string")
|
||||
throw new AsrError("ASR 缺少结果下载地址");
|
||||
return { status: "SUCCEEDED", resultURL: result.transcription_url };
|
||||
}
|
||||
throw new AsrError(
|
||||
`ASR 任务失败 (${safeCode(output.code || output.task_status)})`,
|
||||
);
|
||||
}
|
||||
export function extractTranscript(value: unknown): string[] {
|
||||
const data = object(value);
|
||||
if (!Array.isArray(data.transcripts))
|
||||
throw new AsrError("ASR 缺少 transcripts,不能作为空文字保存");
|
||||
return data.transcripts.flatMap((entry: unknown) => {
|
||||
const track = object(entry);
|
||||
if (track.sentences !== undefined && !Array.isArray(track.sentences))
|
||||
throw new AsrError("ASR 句子格式异常");
|
||||
const sentences = ((track.sentences ?? []) as unknown[])
|
||||
.map((sentence) => {
|
||||
const text = object(sentence).text;
|
||||
if (typeof text !== "string") throw new AsrError("ASR 句子缺少文字");
|
||||
return text.trim();
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (sentences.length) return sentences;
|
||||
if (typeof track.text !== "string") throw new AsrError("ASR 音轨缺少文字");
|
||||
return track.text.trim() ? [track.text.trim()] : [];
|
||||
});
|
||||
}
|
||||
export async function downloadTranscript(url: string): Promise<string[]> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new AsrError("ASR 结果地址无效");
|
||||
}
|
||||
if (!["http:", "https:"].includes(parsed.protocol))
|
||||
throw new AsrError("ASR 结果地址协议无效");
|
||||
// Result storage is not the API host: never forward the API key.
|
||||
return extractTranscript(await request(url));
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Prisma, type TranscriptionTask } from "@prisma/client";
|
||||
import { prisma } from "../prisma";
|
||||
import { getAsrConfig } from "./dashscope";
|
||||
import { isActive, transcriptionState } from "./types";
|
||||
|
||||
function configurationError(): string | null {
|
||||
try {
|
||||
getAsrConfig();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return (error as Error).message;
|
||||
}
|
||||
}
|
||||
export async function enqueueTranscription(videoId: string, force = false) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const videos = await tx.$queryRaw<
|
||||
{ aweme_id: string }[]
|
||||
>`SELECT aweme_id FROM "Video" WHERE aweme_id = ${videoId} FOR UPDATE`;
|
||||
if (!videos.length) return null;
|
||||
const [transcript, task] = await Promise.all([
|
||||
tx.videoTranscript.findUnique({ where: { videoId } }),
|
||||
tx.transcriptionTask.findUnique({ where: { videoId } }),
|
||||
]);
|
||||
if ((task && isActive(task.status)) || (!force && transcript))
|
||||
return transcriptionState(transcript, task);
|
||||
const lastError = configurationError();
|
||||
const data = {
|
||||
status: lastError ? "FAILED" : "QUEUED",
|
||||
lastError,
|
||||
providerTaskId: null,
|
||||
submittingAt: null,
|
||||
submittedAt: null,
|
||||
leaseUntil: null,
|
||||
leaseToken: null,
|
||||
attempts: 0,
|
||||
nextPollAt: new Date(),
|
||||
};
|
||||
const saved = await tx.transcriptionTask.upsert({
|
||||
where: { videoId },
|
||||
create: { videoId, ...data },
|
||||
update: data,
|
||||
});
|
||||
return transcriptionState(transcript, saved);
|
||||
});
|
||||
}
|
||||
export async function enqueueMissingTranscriptions() {
|
||||
const error = configurationError();
|
||||
if (error) throw new Error(error);
|
||||
// One durable, atomic batch. Completed historical transcripts are never requeued.
|
||||
return prisma.$executeRaw`
|
||||
INSERT INTO "TranscriptionTask" ("videoId", status, "updatedAt")
|
||||
SELECT v.aweme_id, 'QUEUED', (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') FROM "Video" v
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "VideoTranscript" t WHERE t."videoId" = v.aweme_id)
|
||||
ON CONFLICT ("videoId") DO UPDATE SET status = 'QUEUED', "providerTaskId" = NULL,
|
||||
"submittingAt" = NULL, "submittedAt" = NULL, "leaseUntil" = NULL, "leaseToken" = NULL,
|
||||
attempts = 0, "lastError" = NULL, "nextPollAt" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), "updatedAt" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
WHERE "TranscriptionTask".status = 'FAILED'
|
||||
`;
|
||||
}
|
||||
export async function readTranscription(videoId: string) {
|
||||
const video = await prisma.video.findUnique({
|
||||
where: { aweme_id: videoId },
|
||||
include: { transcript: true, transcriptionTask: true },
|
||||
});
|
||||
return video
|
||||
? transcriptionState(video.transcript, video.transcriptionTask)
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function claimTask(): Promise<TranscriptionTask | null> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// Serialize admission across all worker processes, including PM2 restarts.
|
||||
await tx.$queryRaw`SELECT pg_advisory_xact_lock(74196321)::text`;
|
||||
const running = await tx.transcriptionTask.count({
|
||||
where: { status: "PROCESSING" },
|
||||
});
|
||||
const eligible =
|
||||
running < 2
|
||||
? Prisma.sql`status IN ('QUEUED', 'PROCESSING')`
|
||||
: Prisma.sql`status = 'PROCESSING'`;
|
||||
const rows = await tx.$queryRaw<TranscriptionTask[]>`
|
||||
SELECT * FROM "TranscriptionTask" WHERE ${eligible}
|
||||
AND "nextPollAt" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AND ("leaseUntil" IS NULL OR "leaseUntil" < (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'))
|
||||
ORDER BY CASE WHEN status = 'PROCESSING' THEN 0 ELSE 1 END, "nextPollAt", "createdAt"
|
||||
LIMIT 1 FOR UPDATE SKIP LOCKED
|
||||
`;
|
||||
if (!rows.length) return null;
|
||||
return tx.transcriptionTask.update({
|
||||
where: { videoId: rows[0].videoId },
|
||||
data: {
|
||||
status: "PROCESSING",
|
||||
leaseToken: randomUUID(),
|
||||
leaseUntil: new Date(Date.now() + 180_000),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
export async function updateOwnedTask(
|
||||
task: TranscriptionTask,
|
||||
data: Prisma.TranscriptionTaskUpdateManyMutationInput,
|
||||
) {
|
||||
const result = await prisma.transcriptionTask.updateMany({
|
||||
where: {
|
||||
videoId: task.videoId,
|
||||
leaseToken: task.leaseToken,
|
||||
status: "PROCESSING",
|
||||
leaseUntil: { gt: new Date() },
|
||||
},
|
||||
data,
|
||||
});
|
||||
if (!result.count) throw new Error("转写任务租约已失效");
|
||||
}
|
||||
export async function completeTask(
|
||||
task: TranscriptionTask,
|
||||
transcript: string[],
|
||||
) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const result = await tx.transcriptionTask.updateMany({
|
||||
where: {
|
||||
videoId: task.videoId,
|
||||
leaseToken: task.leaseToken,
|
||||
status: "PROCESSING",
|
||||
leaseUntil: { gt: new Date() },
|
||||
},
|
||||
data: {
|
||||
status: "SUCCEEDED",
|
||||
lastError: null,
|
||||
leaseToken: null,
|
||||
leaseUntil: null,
|
||||
attempts: 0,
|
||||
},
|
||||
});
|
||||
if (!result.count) throw new Error("转写任务租约已失效");
|
||||
await tx.videoTranscript.upsert({
|
||||
where: { videoId: task.videoId },
|
||||
create: { videoId: task.videoId, transcript },
|
||||
update: { transcript },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export type TranscriptionStatus =
|
||||
| "PENDING"
|
||||
| "QUEUED"
|
||||
| "PROCESSING"
|
||||
| "SUCCEEDED"
|
||||
| "FAILED";
|
||||
export type Transcript = { transcript: string[] };
|
||||
export type TranscriptionState = {
|
||||
status: TranscriptionStatus;
|
||||
error: string | null;
|
||||
transcript: string[] | null;
|
||||
};
|
||||
export const statusLabels: Record<TranscriptionStatus, string> = {
|
||||
PENDING: "待转写",
|
||||
QUEUED: "排队中",
|
||||
PROCESSING: "处理中",
|
||||
SUCCEEDED: "已完成",
|
||||
FAILED: "失败",
|
||||
};
|
||||
export function isActive(status: string) {
|
||||
return status === "QUEUED" || status === "PROCESSING";
|
||||
}
|
||||
export function transcriptionState(
|
||||
transcript: Transcript | null,
|
||||
task: { status: string; lastError: string | null } | null,
|
||||
): TranscriptionState {
|
||||
return {
|
||||
status: (task?.status ??
|
||||
(transcript ? "SUCCEEDED" : "PENDING")) as TranscriptionStatus,
|
||||
error: task?.lastError ?? null,
|
||||
transcript: transcript?.transcript ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { TranscriptionTask } from "@prisma/client";
|
||||
import { prepareAudio } from "./audio";
|
||||
import {
|
||||
AsrError,
|
||||
downloadTranscript,
|
||||
getAsrConfig,
|
||||
queryTranscription,
|
||||
submitTranscription,
|
||||
} from "./dashscope";
|
||||
import { claimTask, completeTask, updateOwnedTask } from "./tasks";
|
||||
|
||||
export const defaultServices = {
|
||||
prepareAudio,
|
||||
submitTranscription,
|
||||
queryTranscription,
|
||||
downloadTranscript,
|
||||
};
|
||||
export async function processTask(
|
||||
task: TranscriptionTask,
|
||||
services = defaultServices,
|
||||
) {
|
||||
let submitting = false;
|
||||
const heartbeat = setInterval(() => {
|
||||
void updateOwnedTask(task, {
|
||||
leaseUntil: new Date(Date.now() + 180_000),
|
||||
}).catch(() => {});
|
||||
}, 30_000);
|
||||
try {
|
||||
getAsrConfig();
|
||||
if (!task.providerTaskId) {
|
||||
if (task.submittingAt)
|
||||
throw new AsrError(
|
||||
"上次提交结果不确定,请核对百炼任务后手动重试,避免重复计费",
|
||||
);
|
||||
const url = await services.prepareAudio(task.videoId);
|
||||
if (url === null) {
|
||||
await completeTask(task, []);
|
||||
return;
|
||||
}
|
||||
await updateOwnedTask(task, { submittingAt: new Date() });
|
||||
submitting = true;
|
||||
const providerTaskId = await services.submitTranscription(url);
|
||||
await updateOwnedTask(task, {
|
||||
providerTaskId,
|
||||
submittedAt: new Date(),
|
||||
nextPollAt: new Date(Date.now() + 10_000),
|
||||
leaseToken: null,
|
||||
leaseUntil: null,
|
||||
lastError: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Provider result URLs expire after 24 hours; do not poll forever.
|
||||
if (
|
||||
task.submittedAt &&
|
||||
Date.now() - task.submittedAt.getTime() > 24 * 60 * 60 * 1000
|
||||
) {
|
||||
throw new AsrError("ASR 任务超过 24 小时,请手动重试");
|
||||
}
|
||||
const result = await services.queryTranscription(task.providerTaskId);
|
||||
if (result.status === "SUCCEEDED") {
|
||||
const transcript = await services.downloadTranscript(result.resultURL);
|
||||
await completeTask(task, transcript);
|
||||
} else {
|
||||
await updateOwnedTask(task, {
|
||||
nextPollAt: new Date(Date.now() + 10_000),
|
||||
leaseToken: null,
|
||||
leaseUntil: null,
|
||||
lastError: null,
|
||||
attempts: 0,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Unknown exceptions (e.g. database outage) leave the lease and submitting marker
|
||||
// intact. Recovery can query a saved ID, but must never blindly submit again.
|
||||
if (!(error instanceof AsrError)) throw error;
|
||||
const retry = !submitting && !!task.providerTaskId && error.retryable;
|
||||
await updateOwnedTask(task, {
|
||||
status: retry ? "PROCESSING" : "FAILED",
|
||||
lastError:
|
||||
submitting && !error.definitiveRejection
|
||||
? `${error.message};提交结果不确定,请核对百炼任务后手动重试`
|
||||
: error.message,
|
||||
attempts: { increment: 1 },
|
||||
leaseToken: null,
|
||||
leaseUntil: null,
|
||||
nextPollAt: new Date(
|
||||
Date.now() +
|
||||
Math.min(300_000, 10_000 * 2 ** Math.min(task.attempts, 5)),
|
||||
),
|
||||
});
|
||||
console.warn(`[ASR] ${task.videoId}: ${retry ? "等待重试" : "失败"}`);
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
}
|
||||
export async function workerTick(services = defaultServices) {
|
||||
const tasks: TranscriptionTask[] = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const task = await claimTask();
|
||||
if (task) tasks.push(task);
|
||||
}
|
||||
await Promise.all(
|
||||
tasks.map(async (task) => {
|
||||
try {
|
||||
await processTask(task, services);
|
||||
} catch {
|
||||
console.error(`[ASR] ${task.videoId}: 执行中断,租约到期后恢复`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -6,19 +6,6 @@ const nextConfig: NextConfig = {
|
||||
"puppeteer-extra-plugin-stealth",
|
||||
"puppeteer-extra-plugin",
|
||||
],
|
||||
webpack: (config) => {
|
||||
config.module.rules.push({
|
||||
test: /\.(md|txt)$/i,
|
||||
type: "asset/source", // 让这些文件作为纯文本注入
|
||||
});
|
||||
return config;
|
||||
},
|
||||
turbopack: {
|
||||
rules: {
|
||||
"*.md": { loaders: ["raw-loader"], as: "*.js" },
|
||||
"*.txt": { loaders: ["raw-loader"], as: "*.js" },
|
||||
},
|
||||
},
|
||||
/* config options here */
|
||||
images: {
|
||||
remotePatterns: [
|
||||
|
||||
Generated
+538
-961
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -7,7 +7,10 @@
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write"
|
||||
"format": "biome format --write",
|
||||
"stt:worker": "tsx scripts/stt-worker.ts",
|
||||
"test:stt": "tsx --test tests/stt.test.ts tests/stt-media.test.ts",
|
||||
"test:stt:integration": "tsx --test tests/stt.integration.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.3",
|
||||
@@ -15,14 +18,13 @@
|
||||
"lucide-react": "^0.546.0",
|
||||
"minio": "^8.0.6",
|
||||
"next": "15.5.7",
|
||||
"openai": "^6.7.0",
|
||||
"playwright": "1.56.1",
|
||||
"playwright-extra": "^4.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"undici": "^7.16.0",
|
||||
"zod": "^4.1.12"
|
||||
"dotenv": "^16.6.1",
|
||||
"tsx": "^4.20.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.0",
|
||||
@@ -31,7 +33,6 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"prisma": "^6.16.3",
|
||||
"raw-loader": "^4.0.2",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,20 @@ const { parsed: envFromFile = {} } = dotenv.config({
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "DouyinArchive-ASR",
|
||||
script: "node_modules/tsx/dist/cli.mjs",
|
||||
args: "scripts/stt-worker.ts",
|
||||
cwd: __dirname,
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
autorestart: true,
|
||||
restart_delay: 4000,
|
||||
kill_timeout: 10000,
|
||||
watch: false,
|
||||
env: { ...envFromFile, NODE_ENV: "production" },
|
||||
time: true,
|
||||
},
|
||||
{
|
||||
name: "DouyinArchive",
|
||||
script: "npm",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
BEGIN;
|
||||
ALTER TABLE "VideoTranscript"
|
||||
DROP COLUMN IF EXISTS speech_detected,
|
||||
DROP COLUMN IF EXISTS language,
|
||||
DROP COLUMN IF EXISTS audio_type,
|
||||
DROP COLUMN IF EXISTS non_speech_summary,
|
||||
DROP COLUMN IF EXISTS content_tags;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "TranscriptionTask" (
|
||||
"videoId" TEXT PRIMARY KEY REFERENCES "Video"(aweme_id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'QUEUED' CHECK (status IN ('QUEUED', 'PROCESSING', 'SUCCEEDED', 'FAILED')),
|
||||
"providerTaskId" TEXT,
|
||||
"submittingAt" TIMESTAMP(3),
|
||||
"submittedAt" TIMESTAMP(3),
|
||||
"nextPollAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
"leaseUntil" TIMESTAMP(3),
|
||||
"leaseToken" TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
"lastError" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "TranscriptionTask_status_nextPollAt_idx" ON "TranscriptionTask"(status, "nextPollAt");
|
||||
|
||||
-- Preserve an existing Chinese parser configuration. Fresh databases use simple
|
||||
-- until the operator installs a Chinese parser and configures public.zhcfg.
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_ts_config c JOIN pg_namespace n ON n.oid = c.cfgnamespace
|
||||
WHERE n.nspname = 'public' AND c.cfgname = 'zhcfg') THEN
|
||||
CREATE TEXT SEARCH CONFIGURATION public.zhcfg (COPY = pg_catalog.simple);
|
||||
END IF;
|
||||
END $$;
|
||||
ALTER TABLE "VideoTranscript" ADD COLUMN IF NOT EXISTS transcript_zh_tsv tsvector;
|
||||
CREATE OR REPLACE FUNCTION public.refresh_video_transcript_search() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.transcript_zh_tsv := to_tsvector('public.zhcfg'::regconfig, COALESCE(array_to_string(NEW.transcript, ' '), ''));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
-- Replace the legacy live trigger as well as our own trigger on repeat deployment.
|
||||
DROP TRIGGER IF EXISTS trg_vt_transcript_zh_tsv ON "VideoTranscript";
|
||||
DROP TRIGGER IF EXISTS video_transcript_search_update ON "VideoTranscript";
|
||||
CREATE TRIGGER video_transcript_search_update BEFORE INSERT OR UPDATE OF transcript
|
||||
ON "VideoTranscript" FOR EACH ROW EXECUTE FUNCTION public.refresh_video_transcript_search();
|
||||
UPDATE "VideoTranscript" SET transcript_zh_tsv = to_tsvector('public.zhcfg'::regconfig, COALESCE(array_to_string(transcript, ' '), ''));
|
||||
CREATE INDEX IF NOT EXISTS video_transcript_zh_idx ON "VideoTranscript" USING GIN (transcript_zh_tsv);
|
||||
COMMIT;
|
||||
+19
-4
@@ -47,6 +47,7 @@ model Video {
|
||||
comments Comment[]
|
||||
author Author @relation(fields: [authorId], references: [sec_uid], onDelete: Cascade)
|
||||
transcript VideoTranscript?
|
||||
transcriptionTask TranscriptionTask?
|
||||
|
||||
@@index([authorId])
|
||||
@@index([created_at])
|
||||
@@ -140,11 +141,7 @@ model ImageFile {
|
||||
model VideoTranscript {
|
||||
id String @id @default(cuid())
|
||||
videoId String @unique
|
||||
speech_detected Boolean
|
||||
language String?
|
||||
audio_type String?
|
||||
transcript String[]
|
||||
non_speech_summary String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
transcript_zh_tsv Unsupported("tsvector")?
|
||||
@@ -153,3 +150,21 @@ model VideoTranscript {
|
||||
@@index([videoId])
|
||||
@@index([transcript_zh_tsv], map: "video_transcript_zh_idx", type: Gin)
|
||||
}
|
||||
|
||||
model TranscriptionTask {
|
||||
videoId String @id
|
||||
status String @default("QUEUED")
|
||||
providerTaskId String?
|
||||
submittingAt DateTime?
|
||||
submittedAt DateTime?
|
||||
nextPollAt DateTime @default(now())
|
||||
leaseUntil DateTime?
|
||||
leaseToken String?
|
||||
attempts Int @default(0)
|
||||
lastError String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
video Video @relation(fields: [videoId], references: [aweme_id], onDelete: Cascade)
|
||||
|
||||
@@index([status, nextPollAt])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import "dotenv/config";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import { prepareAudio } from "../lib/stt/audio";
|
||||
import {
|
||||
downloadTranscript,
|
||||
getAsrConfig,
|
||||
queryTranscription,
|
||||
submitTranscription,
|
||||
} from "../lib/stt/dashscope";
|
||||
|
||||
async function main() {
|
||||
const videoId = process.argv[2];
|
||||
if (!videoId || !/^[A-Za-z0-9_-]+$/.test(videoId))
|
||||
throw new Error("用法:npx tsx scripts/stt-smoke.ts <视频 ID>");
|
||||
getAsrConfig();
|
||||
const video = await prisma.video.findUniqueOrThrow({
|
||||
where: { aweme_id: videoId },
|
||||
select: { duration_ms: true },
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
videoId,
|
||||
durationSeconds: video.duration_ms / 1000,
|
||||
phase: "extracting_audio",
|
||||
}),
|
||||
);
|
||||
const audioURL = await prepareAudio(videoId);
|
||||
const resultPath = `/tmp/douyin-asr-${videoId}.json`;
|
||||
if (!audioURL) {
|
||||
await writeFile(
|
||||
resultPath,
|
||||
JSON.stringify({ videoId, transcript: [] }, null, 2),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({ phase: "complete", transcript: [], resultPath }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const taskId = await submitTranscription(audioURL);
|
||||
await writeFile(
|
||||
resultPath,
|
||||
JSON.stringify({ videoId, taskId, status: "submitted" }, null, 2),
|
||||
);
|
||||
console.log(JSON.stringify({ phase: "submitted", taskId }));
|
||||
for (let attempt = 0; attempt < 90; attempt++) {
|
||||
await sleep(10_000);
|
||||
const task = await queryTranscription(taskId);
|
||||
if (task.status === "SUCCEEDED") {
|
||||
const transcript = await downloadTranscript(task.resultURL);
|
||||
await writeFile(
|
||||
resultPath,
|
||||
JSON.stringify({ videoId, taskId, transcript }, null, 2),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({ phase: "complete", transcript, resultPath }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (attempt % 6 === 0) console.log(JSON.stringify({ phase: task.status }));
|
||||
}
|
||||
throw new Error(
|
||||
`15 分钟内未完成,任务 ID 已保存在 ${resultPath},请查询原任务而非重复提交`,
|
||||
);
|
||||
}
|
||||
main()
|
||||
.catch((error) => {
|
||||
// Do not print arbitrary SDK/network errors: they may contain signed URLs.
|
||||
console.error(
|
||||
error instanceof Error && !error.name.startsWith("Prisma")
|
||||
? error.message
|
||||
: "读取视频或转写失败",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,34 @@
|
||||
import "dotenv/config";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import { workerTick } from "../lib/stt/worker";
|
||||
|
||||
let stopping = false;
|
||||
const controller = new AbortController();
|
||||
for (const signal of ["SIGTERM", "SIGINT"] as const)
|
||||
process.on(signal, () => {
|
||||
stopping = true;
|
||||
controller.abort();
|
||||
});
|
||||
async function main() {
|
||||
console.log("[ASR] 后台执行器已启动,最大并发 2");
|
||||
try {
|
||||
while (!stopping) {
|
||||
try {
|
||||
await workerTick();
|
||||
} catch {
|
||||
console.error("[ASR] 数据库调度失败,稍后重试");
|
||||
}
|
||||
if (!stopping)
|
||||
await sleep(1000, undefined, { signal: controller.signal }).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
main().catch(() => {
|
||||
console.error("[ASR] 执行器退出");
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { extractAudio } from "../app/api/media";
|
||||
|
||||
test("extract a whole video longer than five minutes as compact mono MP3", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "asr-media-test-"));
|
||||
try {
|
||||
const video = path.join(dir, "long.mp4");
|
||||
execFileSync(process.env.FFMPEG_PATH || "ffmpeg", [
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=black:s=16x16:r=1",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:sample_rate=16000",
|
||||
"-t",
|
||||
"310",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-c:a",
|
||||
"aac",
|
||||
video,
|
||||
]);
|
||||
const result = await extractAudio(video, {
|
||||
format: "mp3",
|
||||
bitrateKbps: 64,
|
||||
});
|
||||
assert.ok(result);
|
||||
assert.ok(result.buffer.length < 3_000_000);
|
||||
const audio = path.join(dir, "audio.mp3");
|
||||
await writeFile(audio, result.buffer);
|
||||
const probe = JSON.parse(
|
||||
execFileSync(
|
||||
process.env.FFPROBE_PATH || "ffprobe",
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"stream=codec_name,sample_rate,channels:format=duration",
|
||||
"-of",
|
||||
"json",
|
||||
audio,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
),
|
||||
);
|
||||
assert.equal(probe.streams[0].codec_name, "mp3");
|
||||
assert.equal(probe.streams[0].sample_rate, "16000");
|
||||
assert.equal(probe.streams[0].channels, 1);
|
||||
assert.ok(Number(probe.format.duration) >= 310);
|
||||
assert.ok(Number(probe.format.duration) < 311);
|
||||
} finally {
|
||||
await rm(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
// Imported before Prisma so its engine cannot capture the project's live URL.
|
||||
const database = process.env.TEST_DATABASE_URL;
|
||||
if (database) {
|
||||
const url = new URL(database);
|
||||
if (
|
||||
!["localhost", "127.0.0.1"].includes(url.hostname) ||
|
||||
url.pathname !== "/douyin_asr_test"
|
||||
) {
|
||||
throw new Error("Only local douyin_asr_test database is permitted");
|
||||
}
|
||||
process.env.DATABASE_URL = database;
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import "./stt-test-env";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { after, before, beforeEach, describe, test } from "node:test";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { GET, POST } from "../app/api/stt/route";
|
||||
import { prisma } from "../lib/prisma";
|
||||
import { AsrError } from "../lib/stt/dashscope";
|
||||
import {
|
||||
claimTask,
|
||||
completeTask,
|
||||
enqueueMissingTranscriptions,
|
||||
enqueueTranscription,
|
||||
readTranscription,
|
||||
} from "../lib/stt/tasks";
|
||||
import { processTask } from "../lib/stt/worker";
|
||||
|
||||
const database = process.env.TEST_DATABASE_URL;
|
||||
const migration = readFileSync(
|
||||
"prisma/migrations/20261003000000_plain_transcription/migration.sql",
|
||||
"utf8",
|
||||
);
|
||||
const sql = (source: string) =>
|
||||
execFileSync("psql", [database ?? "", "-X", "-v", "ON_ERROR_STOP=1", "-q"], {
|
||||
input: source,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const fakeServices = {
|
||||
prepareAudio: async () => "https://storage.test/audio.mp3" as string | null,
|
||||
submitTranscription: async () => "provider-1",
|
||||
queryTranscription: async () => ({
|
||||
status: "SUCCEEDED" as const,
|
||||
resultURL: "https://result.test/file",
|
||||
}),
|
||||
downloadTranscript: async () => ["new searchable words"],
|
||||
};
|
||||
async function due(videoId: string) {
|
||||
await prisma.transcriptionTask.update({
|
||||
where: { videoId },
|
||||
data: { nextPollAt: new Date(0) },
|
||||
});
|
||||
}
|
||||
async function claim() {
|
||||
const task = await claimTask();
|
||||
assert.ok(task);
|
||||
return task;
|
||||
}
|
||||
function request(url: string, body?: unknown): NextRequest {
|
||||
return Object.assign(
|
||||
new Request(
|
||||
url,
|
||||
body === undefined
|
||||
? {}
|
||||
: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
),
|
||||
{ nextUrl: new URL(url) },
|
||||
) as NextRequest;
|
||||
}
|
||||
describe("PostgreSQL transcription lifecycle", { skip: !database }, () => {
|
||||
before(async () => {
|
||||
sql("DROP SCHEMA public CASCADE; CREATE SCHEMA public;");
|
||||
sql(
|
||||
readFileSync("prisma/migrations/20251025_baseline/migration.sql", "utf8"),
|
||||
);
|
||||
await prisma.author.create({
|
||||
data: {
|
||||
sec_uid: "test-author",
|
||||
uid: "uid",
|
||||
nickname: "测试作者",
|
||||
signature: "",
|
||||
avatar_url: "",
|
||||
follower_count: 0,
|
||||
total_favorited: 0,
|
||||
short_id: "test",
|
||||
},
|
||||
});
|
||||
for (const id of ["historical", "empty-history", "v1", "v2", "v3", "v4"]) {
|
||||
await prisma.video.create({
|
||||
data: {
|
||||
aweme_id: id,
|
||||
desc: "测试视频",
|
||||
duration_ms: 1000,
|
||||
created_at: new Date(),
|
||||
share_url: "",
|
||||
digg_count: 0,
|
||||
comment_count: 0,
|
||||
share_count: 0,
|
||||
collect_count: 0,
|
||||
authorId: "test-author",
|
||||
tags: [],
|
||||
video_url: "video.mp4",
|
||||
},
|
||||
});
|
||||
}
|
||||
sql(
|
||||
`INSERT INTO "VideoTranscript" (id,"videoId",speech_detected,transcript,"updatedAt") VALUES ('old','historical',true,ARRAY['original searchable words'],NOW()), ('empty','empty-history',false,ARRAY[]::text[],NOW());`,
|
||||
);
|
||||
sql(`ALTER TABLE "VideoTranscript" ADD COLUMN content_tags TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
CREATE TEXT SEARCH CONFIGURATION public.zhcfg (COPY = pg_catalog.simple);
|
||||
CREATE FUNCTION public.vt_update_transcript_zh_tsv() RETURNS trigger AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql;
|
||||
CREATE TRIGGER trg_vt_transcript_zh_tsv BEFORE INSERT OR UPDATE OF transcript ON "VideoTranscript" FOR EACH ROW EXECUTE FUNCTION public.vt_update_transcript_zh_tsv();`);
|
||||
sql(migration);
|
||||
sql(migration); // migration DDL is repeatable, historical text is unchanged
|
||||
});
|
||||
beforeEach(async () => {
|
||||
process.env.DASHSCOPE_API_KEY = "test-key";
|
||||
delete process.env.DASHSCOPE_BASE_URL;
|
||||
delete process.env.DASHSCOPE_ASR_MODEL;
|
||||
await prisma.transcriptionTask.deleteMany();
|
||||
await prisma.videoTranscript.deleteMany({
|
||||
where: { videoId: { in: ["v1", "v2", "v3", "v4"] } },
|
||||
});
|
||||
});
|
||||
after(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
test("migration preserves historical and empty text and rebuilds search", async () => {
|
||||
assert.equal(
|
||||
(await readTranscription("empty-history"))?.status,
|
||||
"SUCCEEDED",
|
||||
);
|
||||
assert.deepEqual((await readTranscription("historical"))?.transcript, [
|
||||
"original searchable words",
|
||||
]);
|
||||
const rows = await prisma.$queryRaw<
|
||||
{ count: bigint }[]
|
||||
>`SELECT COUNT(*) FROM "VideoTranscript" WHERE transcript_zh_tsv @@ websearch_to_tsquery('zhcfg', 'original')`;
|
||||
assert.equal(Number(rows[0].count), 1);
|
||||
});
|
||||
test("concurrent enqueue is idempotent; automatic enqueue never retranscribes history", async () => {
|
||||
await Promise.all(
|
||||
Array.from({ length: 10 }, () => enqueueTranscription("v1", true)),
|
||||
);
|
||||
assert.equal(await prisma.transcriptionTask.count(), 1);
|
||||
assert.equal(
|
||||
(await enqueueTranscription("historical"))?.status,
|
||||
"SUCCEEDED",
|
||||
);
|
||||
assert.equal(await prisma.transcriptionTask.count(), 1);
|
||||
});
|
||||
test("batch skips history and active tasks and retries failed missing transcripts", async () => {
|
||||
await enqueueTranscription("v1");
|
||||
await prisma.transcriptionTask.update({
|
||||
where: { videoId: "v1" },
|
||||
data: { status: "FAILED" },
|
||||
});
|
||||
await enqueueTranscription("v2");
|
||||
assert.equal(await enqueueMissingTranscriptions(), 3);
|
||||
assert.equal(await enqueueMissingTranscriptions(), 0);
|
||||
assert.equal(await prisma.transcriptionTask.count(), 4);
|
||||
});
|
||||
test("admission never exceeds two active provider jobs across workers", async () => {
|
||||
await enqueueMissingTranscriptions();
|
||||
await prisma.transcriptionTask.updateMany({
|
||||
data: { nextPollAt: new Date(0) },
|
||||
});
|
||||
const claims = await Promise.all([
|
||||
claimTask(),
|
||||
claimTask(),
|
||||
claimTask(),
|
||||
claimTask(),
|
||||
]);
|
||||
assert.equal(claims.filter(Boolean).length, 2);
|
||||
assert.equal(
|
||||
await prisma.transcriptionTask.count({ where: { status: "PROCESSING" } }),
|
||||
2,
|
||||
);
|
||||
});
|
||||
test("provider ID survives restart; completion saves text and search atomically", async () => {
|
||||
await enqueueTranscription("v1");
|
||||
let submits = 0;
|
||||
const services = {
|
||||
...fakeServices,
|
||||
submitTranscription: async () => {
|
||||
submits++;
|
||||
return "restart-task";
|
||||
},
|
||||
};
|
||||
await processTask(await claim(), services);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.transcriptionTask.findUniqueOrThrow({
|
||||
where: { videoId: "v1" },
|
||||
})
|
||||
).providerTaskId,
|
||||
"restart-task",
|
||||
);
|
||||
await due("v1");
|
||||
await processTask(await claim(), services);
|
||||
assert.equal(submits, 1);
|
||||
assert.deepEqual(await readTranscription("v1"), {
|
||||
status: "SUCCEEDED",
|
||||
error: null,
|
||||
transcript: ["new searchable words"],
|
||||
});
|
||||
const hits = await prisma.$queryRaw<
|
||||
{ count: bigint }[]
|
||||
>`SELECT COUNT(*) FROM "VideoTranscript" WHERE "videoId" = 'v1' AND transcript_zh_tsv @@ websearch_to_tsquery('zhcfg', 'new')`;
|
||||
assert.equal(Number(hits[0].count), 1);
|
||||
});
|
||||
test("failed retranscription retains old text; success replaces text and clears stale search", async () => {
|
||||
await prisma.videoTranscript.create({
|
||||
data: { videoId: "v1", transcript: ["oldword"] },
|
||||
});
|
||||
await enqueueTranscription("v1", true);
|
||||
await processTask(await claim(), fakeServices);
|
||||
await due("v1");
|
||||
await processTask(await claim(), {
|
||||
...fakeServices,
|
||||
queryTranscription: async () => {
|
||||
throw new AsrError("FILE_403_FORBIDDEN");
|
||||
},
|
||||
});
|
||||
assert.equal((await readTranscription("v1"))?.status, "FAILED");
|
||||
assert.deepEqual((await readTranscription("v1"))?.transcript, ["oldword"]);
|
||||
await enqueueTranscription("v1", true);
|
||||
await processTask(await claim(), fakeServices);
|
||||
await due("v1");
|
||||
await processTask(await claim(), {
|
||||
...fakeServices,
|
||||
downloadTranscript: async () => [],
|
||||
});
|
||||
assert.deepEqual((await readTranscription("v1"))?.transcript, []);
|
||||
const hits = await prisma.$queryRaw<
|
||||
{ count: bigint }[]
|
||||
>`SELECT COUNT(*) FROM "VideoTranscript" WHERE "videoId" = 'v1' AND transcript_zh_tsv @@ websearch_to_tsquery('zhcfg', 'oldword')`;
|
||||
assert.equal(Number(hits[0].count), 0);
|
||||
});
|
||||
test("crashed/ambiguous submission is never automatically resubmitted", async () => {
|
||||
await enqueueTranscription("v1");
|
||||
const task = await claim();
|
||||
await prisma.transcriptionTask.update({
|
||||
where: { videoId: "v1" },
|
||||
data: { submittingAt: new Date(), leaseUntil: new Date(0) },
|
||||
});
|
||||
let submits = 0;
|
||||
await processTask(await claim(), {
|
||||
...fakeServices,
|
||||
submitTranscription: async () => {
|
||||
submits++;
|
||||
return "bad";
|
||||
},
|
||||
});
|
||||
assert.equal(submits, 0);
|
||||
assert.equal((await readTranscription("v1"))?.status, "FAILED");
|
||||
await assert.rejects(completeTask(task, ["stale result"]), /租约/);
|
||||
assert.equal((await readTranscription("v1"))?.transcript, null);
|
||||
});
|
||||
test("temporary query errors back off and keep provider ID", async () => {
|
||||
await enqueueTranscription("v1");
|
||||
await processTask(await claim(), fakeServices);
|
||||
await due("v1");
|
||||
await processTask(await claim(), {
|
||||
...fakeServices,
|
||||
queryTranscription: async () => {
|
||||
throw new AsrError("网络超时", true);
|
||||
},
|
||||
});
|
||||
const task = await prisma.transcriptionTask.findUniqueOrThrow({
|
||||
where: { videoId: "v1" },
|
||||
});
|
||||
assert.equal(task.status, "PROCESSING");
|
||||
assert.equal(task.providerTaskId, "provider-1");
|
||||
assert.ok(task.nextPollAt.getTime() > Date.now());
|
||||
assert.equal(task.attempts, 1);
|
||||
});
|
||||
test("video with no audio track completes with no ASR submission", async () => {
|
||||
await enqueueTranscription("v1");
|
||||
await processTask(await claim(), {
|
||||
...fakeServices,
|
||||
prepareAudio: async () => null,
|
||||
submitTranscription: async () => {
|
||||
throw new Error("must not submit");
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await readTranscription("v1"), {
|
||||
status: "SUCCEEDED",
|
||||
error: null,
|
||||
transcript: [],
|
||||
});
|
||||
});
|
||||
test("missing key is visible; GET has no side effect; POST validates input", async () => {
|
||||
delete process.env.DASHSCOPE_API_KEY;
|
||||
const state = await enqueueTranscription("v1");
|
||||
assert.equal(state?.status, "FAILED");
|
||||
assert.match(state?.error ?? "", /DASHSCOPE_API_KEY/);
|
||||
assert.equal(
|
||||
(await GET(request("http://local/api/stt?awemeId=v2"))).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(await prisma.transcriptionTask.count(), 1);
|
||||
assert.equal(
|
||||
(await GET(request("http://local/api/stt?awemeId=missing"))).status,
|
||||
404,
|
||||
);
|
||||
assert.equal((await POST(request("http://local/api/stt", {}))).status, 400);
|
||||
process.env.DASHSCOPE_API_KEY = "test-key";
|
||||
assert.equal(
|
||||
(await POST(request("http://local/api/stt", { awemeId: "v2" }))).status,
|
||||
202,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { once } from "node:events";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { after, before, test } from "node:test";
|
||||
import {
|
||||
AsrError,
|
||||
downloadTranscript,
|
||||
extractTranscript,
|
||||
getAsrConfig,
|
||||
MAX_BYTES,
|
||||
MAX_DURATION_MS,
|
||||
queryTranscription,
|
||||
submitTranscription,
|
||||
validateMedia,
|
||||
} from "../lib/stt/dashscope";
|
||||
import { transcriptionState } from "../lib/stt/types";
|
||||
|
||||
let server: Server;
|
||||
let base: string;
|
||||
let response: unknown = { output: { task_id: "task-1" } };
|
||||
let status = 200;
|
||||
const calls: {
|
||||
url?: string;
|
||||
headers: Record<string, unknown>;
|
||||
body: string;
|
||||
}[] = [];
|
||||
const savedEnv = { ...process.env };
|
||||
before(async () => {
|
||||
server = createServer(async (req, res) => {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
calls.push({ url: req.url, headers: req.headers, body });
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(response));
|
||||
}).listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
process.env.DASHSCOPE_API_KEY = "test-key";
|
||||
process.env.DASHSCOPE_BASE_URL = `${base}/api/v1`;
|
||||
delete process.env.DASHSCOPE_ASR_MODEL;
|
||||
});
|
||||
after(async () => {
|
||||
process.env = savedEnv;
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
test("native ASR submission sends only audio URL and recognition options", async () => {
|
||||
assert.equal(
|
||||
await submitTranscription("https://storage.test/audio.mp3"),
|
||||
"task-1",
|
||||
);
|
||||
const call = calls.at(-1);
|
||||
assert.ok(call);
|
||||
assert.equal(call.url, "/api/v1/services/audio/asr/transcription");
|
||||
assert.equal(call.headers.authorization, "Bearer test-key");
|
||||
assert.equal(call.headers["x-dashscope-async"], "enable");
|
||||
assert.deepEqual(JSON.parse(call.body), {
|
||||
model: "qwen3-asr-flash-filetrans",
|
||||
input: { file_url: "https://storage.test/audio.mp3" },
|
||||
parameters: { channel_id: [0], enable_itn: false, enable_words: false },
|
||||
});
|
||||
});
|
||||
test("query pending, running, success, failure and unknown", async () => {
|
||||
for (const state of ["PENDING", "RUNNING"] as const) {
|
||||
response = { output: { task_status: state } };
|
||||
assert.deepEqual(await queryTranscription("task-1"), { status: state });
|
||||
}
|
||||
response = {
|
||||
output: {
|
||||
task_status: "SUCCEEDED",
|
||||
result: { transcription_url: `${base}/result` },
|
||||
},
|
||||
};
|
||||
assert.deepEqual(await queryTranscription("task-1"), {
|
||||
status: "SUCCEEDED",
|
||||
resultURL: `${base}/result`,
|
||||
});
|
||||
response = {
|
||||
output: {
|
||||
task_status: "FAILED",
|
||||
code: "FILE_403_FORBIDDEN",
|
||||
message: "secret-url",
|
||||
},
|
||||
};
|
||||
await assert.rejects(queryTranscription("task-1"), /FILE_403_FORBIDDEN/);
|
||||
response = { output: { task_status: "UNKNOWN" } };
|
||||
await assert.rejects(queryTranscription("task-1"), /UNKNOWN/);
|
||||
});
|
||||
test("extract only text, retain order, fall back to full text", async () => {
|
||||
response = {
|
||||
transcripts: [
|
||||
{
|
||||
sentences: [
|
||||
{ text: " 第一段 ", emotion: "happy", language: "zh" },
|
||||
{ text: "第二段" },
|
||||
],
|
||||
text: "全文",
|
||||
},
|
||||
{ sentences: [], text: "备用全文" },
|
||||
],
|
||||
};
|
||||
assert.deepEqual(await downloadTranscript(`${base}/result`), [
|
||||
"第一段",
|
||||
"第二段",
|
||||
"备用全文",
|
||||
]);
|
||||
assert.equal(calls.at(-1)?.headers.authorization, undefined);
|
||||
assert.deepEqual(extractTranscript({ transcripts: [] }), []);
|
||||
assert.deepEqual(
|
||||
extractTranscript({ transcripts: [{ text: " ", sentences: [] }] }),
|
||||
[],
|
||||
);
|
||||
assert.throws(() => extractTranscript({}), /transcripts/);
|
||||
assert.throws(
|
||||
() =>
|
||||
extractTranscript({
|
||||
transcripts: [{ sentences: [{ emotion: "happy" }] }],
|
||||
}),
|
||||
/文字/,
|
||||
);
|
||||
});
|
||||
test("limits are inclusive and oversized media fails without truncation", () => {
|
||||
validateMedia(MAX_DURATION_MS, MAX_BYTES);
|
||||
assert.throws(() => validateMedia(MAX_DURATION_MS + 1, 1), /12 小时/);
|
||||
assert.throws(() => validateMedia(1, MAX_BYTES + 1), /2 GB/);
|
||||
assert.throws(() => validateMedia(1, 0), /为空/);
|
||||
});
|
||||
test("HTTP throttling can retry; authentication cannot; no provider error leakage", async () => {
|
||||
response = { code: "InvalidApiKey", message: "sensitive" };
|
||||
status = 401;
|
||||
await assert.rejects(
|
||||
queryTranscription("task-1"),
|
||||
(e) =>
|
||||
e instanceof AsrError && !e.retryable && !e.message.includes("sensitive"),
|
||||
);
|
||||
status = 429;
|
||||
await assert.rejects(
|
||||
queryTranscription("task-1"),
|
||||
(e) => e instanceof AsrError && e.retryable,
|
||||
);
|
||||
status = 200;
|
||||
});
|
||||
test("missing key and incompatible model produce explicit errors", () => {
|
||||
delete process.env.DASHSCOPE_API_KEY;
|
||||
assert.throws(getAsrConfig, /DASHSCOPE_API_KEY/);
|
||||
process.env.DASHSCOPE_API_KEY = "test-key";
|
||||
process.env.DASHSCOPE_ASR_MODEL = "gemini-2.5-flash";
|
||||
assert.throws(getAsrConfig, /qwen3-asr-flash-filetrans/);
|
||||
delete process.env.DASHSCOPE_ASR_MODEL;
|
||||
});
|
||||
test("historical empty transcript is complete; failed retry retains old text", () => {
|
||||
assert.equal(
|
||||
transcriptionState({ transcript: [] }, null).status,
|
||||
"SUCCEEDED",
|
||||
);
|
||||
assert.deepEqual(
|
||||
transcriptionState(
|
||||
{ transcript: ["旧文字"] },
|
||||
{ status: "FAILED", lastError: "失败" },
|
||||
),
|
||||
{
|
||||
status: "FAILED",
|
||||
error: "失败",
|
||||
transcript: ["旧文字"],
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user