feat: 将视频转写重构为纯文字异步语音识别
This commit is contained in:
@@ -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