fix: 修复工作流执行记录创建与静音模式约束

- 执行记录仅在合法 execId 时写入,查询失败时返回错误
- 字幕构建改为词级精确对齐并增加比例兜底,避免整句被吞
- 静音模式下从转写与段级 prompt 双重杜绝口播/字幕/口型
- 清理静音事件描述中的说话类动词
This commit is contained in:
2026-08-21 19:38:25 +08:00
parent a06766db6d
commit 5b93a7a507
7 changed files with 202 additions and 71 deletions
+114 -37
View File
@@ -10,6 +10,7 @@ import (
"regexp"
"strings"
"sync"
"unicode/utf8"
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
"gitea.redpowerfuture.com/red-future/common/utils"
@@ -297,55 +298,56 @@ func prependFilePathPrefix(prefix string, v any) any {
}
}
// punctRe 切分/剥离用的中文标点(含顿号、)
var punctRe = regexp.MustCompile(`[,。;!?、]`)
// BuildSubtitles 核心工具:单个sentence生成多条subtitle
func BuildSubtitles(sents *[]flowDto.Sentence) ([]flowDto.Subtitle, error) {
var subtitles []flowDto.Subtitle
for _, sent := range *sents {
// 1. 先按标点把文本拆成多个片段(保留标点)
// 1. 先按标点把文本拆成多个片段
segList := splitTextByPunct(sent.Text)
if len(segList) == 0 {
continue
}
wordIdx := 0
allWords := sent.Words
// 2. 遍历每个文本片段,匹配对应的Words
// 去标点后得到纯净片段(纯空白/纯标点片段跳过)
var cleans []string
for _, seg := range segList {
// 去除文本片段的标点,方便和Word.Word拼接内容匹配
segClean := strings.ReplaceAll(seg, "", "")
segClean = strings.ReplaceAll(segClean, "。", "")
segClean = strings.ReplaceAll(segClean, "", "")
segClean = strings.ReplaceAll(segClean, "", "")
segClean = strings.ReplaceAll(segClean, "", "")
var collectWords []flowDto.Word
var currentText strings.Builder
// 收集Word直到拼接内容覆盖当前分段
for wordIdx < len(allWords) {
word := allWords[wordIdx]
currentText.WriteString(word.Word)
collectWords = append(collectWords, word)
wordIdx++
// 当拼接的文本包含当前分段的纯文本时,停止收集
if strings.Contains(currentText.String(), segClean) {
break
}
c := strings.TrimSpace(cleanPunct(seg))
if c != "" {
cleans = append(cleans, c)
}
}
if len(cleans) == 0 || len(sent.Words) == 0 {
continue
}
if len(collectWords) == 0 {
// 2. 词级文本与句子文本一致时,按词精确对齐取首尾词时间(最准)
if spans, ok := alignAllSegments(sent.Words, cleans); ok {
for i, span := range spans {
subtitles = append(subtitles, flowDto.Subtitle{
Start: sent.Words[span[0]].StartTime,
End: sent.Words[span[1]].EndTime,
Text: cleans[i],
})
}
continue
}
// 3. ASR 词级转写与句子文本不一致时(如 血→谑、数字写法不一),
// 整句回退为按片段字符占比分配时间,避免整句被吞成一条字幕
segWords := allocWordsByProportion(sent.Words, cleans)
for i, ws := range segWords {
if len(ws) == 0 {
continue
}
// 3. 生成字幕(时间戳取首尾Word的时间)
sub := flowDto.Subtitle{
Start: collectWords[0].StartTime,
End: collectWords[len(collectWords)-1].EndTime,
Text: segClean,
}
subtitles = append(subtitles, sub)
subtitles = append(subtitles, flowDto.Subtitle{
Start: ws[0].StartTime,
End: ws[len(ws)-1].EndTime,
Text: cleans[i],
})
}
}
@@ -357,9 +359,7 @@ func BuildSubtitles(sents *[]flowDto.Sentence) ([]flowDto.Subtitle, error) {
// 会变成:["这个叫高血压调理方,", "注意是根源调理不是临时缓解,"]
func splitTextByPunct(raw string) []string {
// 匹配中文标点并保留在文本中,按标点位置切分
re := regexp.MustCompile(`[,。;!?]`)
// 先找到所有标点的位置
indexes := re.FindAllStringIndex(raw, -1)
indexes := punctRe.FindAllStringIndex(raw, -1)
if len(indexes) == 0 {
return []string{raw}
}
@@ -378,3 +378,80 @@ func splitTextByPunct(raw string) []string {
}
return res
}
// cleanPunct 去掉中文标点,得到纯净文本
func cleanPunct(raw string) string {
return punctRe.ReplaceAllString(raw, "")
}
// alignAllSegments 按顺序把各纯净片段与词级文本逐字符对齐(允许个别字符不一致)。
// 全部片段对齐成功且词被完整覆盖时返回各片段对应的词区间,否则 ok=false,
// 由调用方回退到时间占比分配。
func alignAllSegments(words []flowDto.Word, cleans []string) ([][2]int, bool) {
spans := make([][2]int, len(cleans))
wordIdx := 0
for i, seg := range cleans {
start := wordIdx
segRunes := []rune(seg)
s := 0
for wordIdx < len(words) && s < len(segRunes) {
for _, r := range []rune(words[wordIdx].Word) {
if s < len(segRunes) && r == segRunes[s] {
s++
}
}
wordIdx++
}
// 片段文本没被完整匹配,或该片段没吃到任何词 → 无法精确对齐
if s < len(segRunes) || start == wordIdx {
return nil, false
}
spans[i] = [2]int{start, wordIdx - 1}
}
// 有剩余词未被任何片段覆盖,说明对齐失败,避免吞掉剩余时间
if wordIdx < len(words) {
return nil, false
}
return spans, true
}
// allocWordsByProportion 按纯净片段字符占比把整句时间区间切成段,再按时间中点把
// 每个 word 归属到所属片段(对词级转写与句子文本不一致的情况兜底)。
func allocWordsByProportion(words []flowDto.Word, cleans []string) [][]flowDto.Word {
runes := make([]int, len(cleans))
totalChars := 0
for i, c := range cleans {
runes[i] = utf8.RuneCountInString(c)
totalChars += runes[i]
}
sentStart := words[0].StartTime
sentEnd := words[len(words)-1].EndTime
duration := sentEnd - sentStart
if duration < 0 {
duration = 0
}
bounds := make([]float64, len(cleans)+1)
bounds[0] = sentStart
accum := 0.0
for i := range cleans {
if totalChars > 0 {
accum += float64(runes[i]) / float64(totalChars)
}
bounds[i+1] = sentStart + accum*duration
}
segWords := make([][]flowDto.Word, len(cleans))
for _, w := range words {
mid := (w.StartTime + w.EndTime) / 2
idx := 0
for b := 0; b < len(bounds)-1; b++ {
if mid >= bounds[b+1] {
idx = b + 1
}
}
segWords[idx] = append(segWords[idx], w)
}
return segWords
}