|
|
@@ -0,0 +1,393 @@
|
|
|
+package com.video.script.admin.service.impl;
|
|
|
+
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import com.video.script.admin.common.BusinessException;
|
|
|
+import com.video.script.admin.config.AsrProperties;
|
|
|
+import com.video.script.admin.service.VideoTranscriptionClient;
|
|
|
+import java.net.URI;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import org.apache.commons.lang3.StringUtils;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.http.HttpEntity;
|
|
|
+import org.springframework.http.HttpHeaders;
|
|
|
+import org.springframework.http.HttpMethod;
|
|
|
+import org.springframework.http.MediaType;
|
|
|
+import org.springframework.http.ResponseEntity;
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
+import org.springframework.web.client.RestClientException;
|
|
|
+import org.springframework.web.client.RestTemplate;
|
|
|
+
|
|
|
+@Component
|
|
|
+public class AliyunAsrClient implements VideoTranscriptionClient {
|
|
|
+
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(AliyunAsrClient.class);
|
|
|
+
|
|
|
+ private final RestTemplate restTemplate;
|
|
|
+ private final AsrProperties properties;
|
|
|
+ private final ObjectMapper objectMapper;
|
|
|
+
|
|
|
+ public AliyunAsrClient(RestTemplate restTemplate, AsrProperties properties, ObjectMapper objectMapper) {
|
|
|
+ this.restTemplate = restTemplate;
|
|
|
+ this.properties = properties;
|
|
|
+ this.objectMapper = objectMapper;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public TranscriptionResult transcribe(String videoUrl) {
|
|
|
+ if (!properties.isEnabled()) {
|
|
|
+ throw new BusinessException("ASR 未启用");
|
|
|
+ }
|
|
|
+ if (StringUtils.isAnyBlank(properties.getBaseUrl(), properties.getSubmitPath(), properties.getApiKey(), properties.getModel())) {
|
|
|
+ throw new BusinessException("ASR 配置不完整");
|
|
|
+ }
|
|
|
+ String taskId = submit(videoUrl);
|
|
|
+ return pollTranscript(taskId, videoUrl);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String submit(String videoUrl) {
|
|
|
+ Map<String, Object> body = new LinkedHashMap<>();
|
|
|
+ body.put("model", properties.getModel());
|
|
|
+ body.put("input", Collections.singletonMap("file_urls", Collections.singletonList(videoUrl)));
|
|
|
+ body.put("parameters", new LinkedHashMap<>());
|
|
|
+
|
|
|
+ HttpHeaders headers = buildHeaders();
|
|
|
+ headers.setContentType(MediaType.APPLICATION_JSON);
|
|
|
+ headers.set("X-DashScope-Async", "enable");
|
|
|
+
|
|
|
+ String url = normalizeBaseUrl(properties.getBaseUrl()) + normalizePath(properties.getSubmitPath());
|
|
|
+ try {
|
|
|
+ String raw = restTemplate.postForObject(url, new HttpEntity<>(body, headers), String.class);
|
|
|
+ JsonNode root = objectMapper.readTree(raw);
|
|
|
+ String taskId = text(root, "task_id", "taskId");
|
|
|
+ if (StringUtils.isBlank(taskId)) {
|
|
|
+ taskId = text(root.path("output"), "task_id", "taskId");
|
|
|
+ }
|
|
|
+ if (StringUtils.isBlank(taskId)) {
|
|
|
+ throw new BusinessException("ASR 提交成功但未返回 task_id");
|
|
|
+ }
|
|
|
+ log.info("ASR submit success, taskId={}, videoUrl={}", taskId, videoUrl);
|
|
|
+ return taskId;
|
|
|
+ } catch (BusinessException ex) {
|
|
|
+ throw ex;
|
|
|
+ } catch (Exception ex) {
|
|
|
+ throw new BusinessException("ASR 提交失败: " + ex.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private TranscriptionResult pollTranscript(String taskId, String videoUrl) {
|
|
|
+ String taskPath = properties.getTaskPathTemplate().replace("{taskId}", taskId);
|
|
|
+ String url = normalizeBaseUrl(properties.getBaseUrl()) + normalizePath(taskPath);
|
|
|
+ HttpHeaders headers = buildHeaders();
|
|
|
+ HttpEntity<Void> entity = new HttpEntity<>(headers);
|
|
|
+ for (int i = 0; i < properties.getMaxPolls(); i++) {
|
|
|
+ try {
|
|
|
+ ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
|
|
|
+ JsonNode root = objectMapper.readTree(response.getBody());
|
|
|
+ String status = text(root, "task_status", "status");
|
|
|
+ if (StringUtils.isBlank(status)) {
|
|
|
+ status = text(root.path("output"), "task_status", "status");
|
|
|
+ }
|
|
|
+ if ("SUCCEEDED".equalsIgnoreCase(status)) {
|
|
|
+ log.info("ASR task succeeded, taskId={}, summary={}", taskId, summarizeTaskResult(root));
|
|
|
+ TranscriptionResult result = extractTranscript(root, videoUrl);
|
|
|
+ if (result == null || StringUtils.isBlank(result.getTranscript())) {
|
|
|
+ log.warn("ASR task has no inline transcript, taskId={}, rawResult={}", taskId, compressJson(root));
|
|
|
+ throw new BusinessException("ASR 已完成但未返回转写文本");
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ if ("FAILED".equalsIgnoreCase(status)) {
|
|
|
+ throw new BusinessException("ASR 转写失败: " + firstNonBlank(
|
|
|
+ text(root, "message", "error_message"),
|
|
|
+ text(root.path("output"), "message", "error_message")
|
|
|
+ ));
|
|
|
+ }
|
|
|
+ Thread.sleep(properties.getPollIntervalMs());
|
|
|
+ } catch (BusinessException ex) {
|
|
|
+ throw ex;
|
|
|
+ } catch (InterruptedException ex) {
|
|
|
+ Thread.currentThread().interrupt();
|
|
|
+ throw new BusinessException("ASR 轮询被中断");
|
|
|
+ } catch (RestClientException ex) {
|
|
|
+ throw new BusinessException("ASR 查询失败: " + ex.getMessage());
|
|
|
+ } catch (Exception ex) {
|
|
|
+ throw new BusinessException("ASR 结果解析失败: " + ex.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ throw new BusinessException("ASR 转写超时");
|
|
|
+ }
|
|
|
+
|
|
|
+ private TranscriptionResult extractTranscript(JsonNode root, String videoUrl) {
|
|
|
+ JsonNode output = root.path("output");
|
|
|
+ String transcript = text(output, "text", "transcript");
|
|
|
+ if (StringUtils.isNotBlank(transcript)) {
|
|
|
+ return new TranscriptionResult(transcript, buildStructuredContent(root, videoUrl, transcript));
|
|
|
+ }
|
|
|
+ JsonNode results = output.path("results");
|
|
|
+ if (results.isArray() && results.size() > 0) {
|
|
|
+ JsonNode first = results.get(0);
|
|
|
+ transcript = text(first, "text", "transcript");
|
|
|
+ if (StringUtils.isNotBlank(transcript)) {
|
|
|
+ return new TranscriptionResult(transcript, buildStructuredContent(root, videoUrl, transcript));
|
|
|
+ }
|
|
|
+ String transcriptionUrl = text(first, "transcription_url", "transcriptionUrl");
|
|
|
+ if (StringUtils.isNotBlank(transcriptionUrl)) {
|
|
|
+ TranscriptionResult result = downloadTranscript(transcriptionUrl, videoUrl);
|
|
|
+ if (result != null && StringUtils.isNotBlank(result.getTranscript())) {
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ JsonNode sentences = first.path("sentences");
|
|
|
+ transcript = joinSentences(sentences);
|
|
|
+ if (StringUtils.isNotBlank(transcript)) {
|
|
|
+ return new TranscriptionResult(transcript, buildStructuredContent(root, videoUrl, transcript));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private TranscriptionResult downloadTranscript(String transcriptionUrl, String videoUrl) {
|
|
|
+ try {
|
|
|
+ log.info("Downloading ASR transcript file, url={}", transcriptionUrl);
|
|
|
+ ResponseEntity<String> response = restTemplate.exchange(URI.create(transcriptionUrl), HttpMethod.GET, HttpEntity.EMPTY, String.class);
|
|
|
+ String raw = response.getBody();
|
|
|
+ if (StringUtils.isBlank(raw)) {
|
|
|
+ log.warn("ASR transcript file is empty, url={}", transcriptionUrl);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ JsonNode root = objectMapper.readTree(raw);
|
|
|
+ log.info("ASR transcript file downloaded, summary={}", summarizeTranscriptResult(root));
|
|
|
+ String transcript = text(root, "text", "transcript");
|
|
|
+ if (StringUtils.isNotBlank(transcript)) {
|
|
|
+ return new TranscriptionResult(transcript, buildStructuredContent(root, videoUrl, transcript));
|
|
|
+ }
|
|
|
+ JsonNode results = root.path("results");
|
|
|
+ if (results.isArray() && results.size() > 0) {
|
|
|
+ JsonNode first = results.get(0);
|
|
|
+ transcript = text(first, "text", "transcript");
|
|
|
+ if (StringUtils.isNotBlank(transcript)) {
|
|
|
+ return new TranscriptionResult(transcript, buildStructuredContent(root, videoUrl, transcript));
|
|
|
+ }
|
|
|
+ transcript = joinSentences(first.path("sentences"));
|
|
|
+ if (StringUtils.isNotBlank(transcript)) {
|
|
|
+ return new TranscriptionResult(transcript, buildStructuredContent(root, videoUrl, transcript));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ JsonNode transcripts = root.path("transcripts");
|
|
|
+ if (transcripts.isArray() && transcripts.size() > 0) {
|
|
|
+ transcript = extractFromTranscriptItems(transcripts);
|
|
|
+ if (StringUtils.isNotBlank(transcript)) {
|
|
|
+ return new TranscriptionResult(transcript, buildStructuredContent(root, videoUrl, transcript));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ transcript = joinSentences(root.path("sentences"));
|
|
|
+ if (StringUtils.isNotBlank(transcript)) {
|
|
|
+ return new TranscriptionResult(transcript, buildStructuredContent(root, videoUrl, transcript));
|
|
|
+ }
|
|
|
+ JsonNode paragraphs = root.path("paragraphs");
|
|
|
+ if (paragraphs.isArray()) {
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ for (JsonNode paragraph : paragraphs) {
|
|
|
+ String paragraphText = text(paragraph, "text", "transcript");
|
|
|
+ if (StringUtils.isNotBlank(paragraphText)) {
|
|
|
+ if (sb.length() > 0) {
|
|
|
+ sb.append('\n');
|
|
|
+ }
|
|
|
+ sb.append(paragraphText);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (sb.length() > 0) {
|
|
|
+ transcript = sb.toString();
|
|
|
+ return new TranscriptionResult(transcript, buildStructuredContent(root, videoUrl, transcript));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ } catch (Exception ex) {
|
|
|
+ log.error("ASR transcript file download failed, url={}, message={}", transcriptionUrl, ex.getMessage());
|
|
|
+ throw new BusinessException("ASR 结果文件下载失败: " + ex.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String joinSentences(JsonNode sentences) {
|
|
|
+ if (!sentences.isArray()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ for (JsonNode sentence : sentences) {
|
|
|
+ String sentenceText = text(sentence, "text");
|
|
|
+ if (StringUtils.isNotBlank(sentenceText)) {
|
|
|
+ if (sb.length() > 0) {
|
|
|
+ sb.append('\n');
|
|
|
+ }
|
|
|
+ sb.append(sentenceText);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return sb.length() > 0 ? sb.toString() : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String extractFromTranscriptItems(JsonNode transcripts) {
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ for (JsonNode item : transcripts) {
|
|
|
+ String text = firstNonBlank(
|
|
|
+ this.text(item, "text", "transcript", "content"),
|
|
|
+ joinSentences(item.path("sentences"))
|
|
|
+ );
|
|
|
+ if (StringUtils.isNotBlank(text)) {
|
|
|
+ if (sb.length() > 0) {
|
|
|
+ sb.append('\n');
|
|
|
+ }
|
|
|
+ sb.append(text);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return sb.length() > 0 ? sb.toString() : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String buildStructuredContent(JsonNode root, String videoUrl, String transcript) {
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ JsonNode propertiesNode = root.path("properties");
|
|
|
+ String durationMs = text(propertiesNode, "original_duration_in_milliseconds", "content_duration_in_milliseconds");
|
|
|
+ if (StringUtils.isNotBlank(durationMs)) {
|
|
|
+ sb.append("时长毫秒: ").append(durationMs).append('\n');
|
|
|
+ }
|
|
|
+ String audioFormat = text(propertiesNode, "audio_format");
|
|
|
+ if (StringUtils.isNotBlank(audioFormat)) {
|
|
|
+ sb.append("音频格式: ").append(audioFormat).append('\n');
|
|
|
+ }
|
|
|
+ sb.append("全文转写: ").append(defaultText(transcript, "无")).append('\n');
|
|
|
+
|
|
|
+ List<String> sentenceLines = collectSentenceLines(root);
|
|
|
+ if (!sentenceLines.isEmpty()) {
|
|
|
+ sb.append("句子时间轴:\n");
|
|
|
+ for (String line : sentenceLines) {
|
|
|
+ sb.append(line).append('\n');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return sb.toString().trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<String> collectSentenceLines(JsonNode root) {
|
|
|
+ List<String> lines = new ArrayList<>();
|
|
|
+ appendSentenceLines(root.path("sentences"), lines);
|
|
|
+ JsonNode results = root.path("results");
|
|
|
+ if (results.isArray()) {
|
|
|
+ for (JsonNode item : results) {
|
|
|
+ appendSentenceLines(item.path("sentences"), lines);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ JsonNode transcripts = root.path("transcripts");
|
|
|
+ if (transcripts.isArray()) {
|
|
|
+ for (JsonNode item : transcripts) {
|
|
|
+ appendSentenceLines(item.path("sentences"), lines);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return lines;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void appendSentenceLines(JsonNode sentences, List<String> lines) {
|
|
|
+ if (!sentences.isArray()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ for (JsonNode sentence : sentences) {
|
|
|
+ String text = text(sentence, "text", "transcript");
|
|
|
+ if (StringUtils.isBlank(text)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String begin = formatMs(sentence.path("begin_time").asLong(-1));
|
|
|
+ String end = formatMs(sentence.path("end_time").asLong(-1));
|
|
|
+ if (begin != null && end != null) {
|
|
|
+ lines.add("[" + begin + "-" + end + "] " + text);
|
|
|
+ } else {
|
|
|
+ lines.add(text);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String formatMs(long ms) {
|
|
|
+ if (ms < 0) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ long totalSeconds = ms / 1000;
|
|
|
+ long minutes = totalSeconds / 60;
|
|
|
+ long seconds = totalSeconds % 60;
|
|
|
+ long millis = ms % 1000;
|
|
|
+ return String.format("%02d:%02d.%03d", minutes, seconds, millis);
|
|
|
+ }
|
|
|
+
|
|
|
+ private HttpHeaders buildHeaders() {
|
|
|
+ HttpHeaders headers = new HttpHeaders();
|
|
|
+ headers.set(properties.getAuthHeader(), buildAuthorizationValue(properties.getAuthPrefix(), properties.getApiKey()));
|
|
|
+ return headers;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String normalizeBaseUrl(String baseUrl) {
|
|
|
+ return StringUtils.removeEnd(baseUrl, "/");
|
|
|
+ }
|
|
|
+
|
|
|
+ private String normalizePath(String path) {
|
|
|
+ return path.startsWith("/") ? path : "/" + path;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String text(JsonNode node, String... names) {
|
|
|
+ for (String name : names) {
|
|
|
+ JsonNode value = node.get(name);
|
|
|
+ if (value != null && !value.isNull() && StringUtils.isNotBlank(value.asText())) {
|
|
|
+ return value.asText();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String firstNonBlank(String... texts) {
|
|
|
+ for (String text : texts) {
|
|
|
+ if (StringUtils.isNotBlank(text)) {
|
|
|
+ return text;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return "unknown error";
|
|
|
+ }
|
|
|
+
|
|
|
+ private String defaultText(String primary, String fallback) {
|
|
|
+ return StringUtils.defaultIfBlank(primary, fallback);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String buildAuthorizationValue(String prefix, String apiKey) {
|
|
|
+ String normalizedPrefix = StringUtils.defaultIfBlank(prefix, "Bearer").trim();
|
|
|
+ return normalizedPrefix + " " + StringUtils.trim(apiKey);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String summarizeTaskResult(JsonNode root) {
|
|
|
+ JsonNode output = root.path("output");
|
|
|
+ JsonNode results = output.path("results");
|
|
|
+ if (results.isArray() && results.size() > 0) {
|
|
|
+ JsonNode first = results.get(0);
|
|
|
+ return "status=" + firstNonBlank(text(root, "task_status", "status"), text(output, "task_status", "status"))
|
|
|
+ + ", transcriptionUrl=" + firstNonBlank(text(first, "transcription_url", "transcriptionUrl"), "-")
|
|
|
+ + ", hasSentences=" + first.path("sentences").isArray();
|
|
|
+ }
|
|
|
+ return "status=" + firstNonBlank(text(root, "task_status", "status"), text(output, "task_status", "status"))
|
|
|
+ + ", outputKeys=" + output.fieldNames().toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private String summarizeTranscriptResult(JsonNode root) {
|
|
|
+ return "hasText=" + StringUtils.isNotBlank(text(root, "text", "transcript"))
|
|
|
+ + ", hasSentences=" + root.path("sentences").isArray()
|
|
|
+ + ", hasResults=" + root.path("results").isArray()
|
|
|
+ + ", hasTranscripts=" + root.path("transcripts").isArray()
|
|
|
+ + ", hasParagraphs=" + root.path("paragraphs").isArray();
|
|
|
+ }
|
|
|
+
|
|
|
+ private String compressJson(JsonNode root) {
|
|
|
+ try {
|
|
|
+ String json = objectMapper.writeValueAsString(root);
|
|
|
+ return json.length() > 1000 ? json.substring(0, 1000) + "..." : json;
|
|
|
+ } catch (Exception ignored) {
|
|
|
+ return "{unserializable}";
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|