Files
36Wisdom/cmd/gentss/tasks.go
T
2026-08-14 13:45:02 +08:00

297 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// 任务枚举与回填:扫描待合成内容 → 逐任务合成 mp3 → 写 ui-src/static/generated/audio/ → 回填各 audio 字段。
// 幂等:文件已存在且字段已回填 → 跳过;--force 重生成覆盖;文件在但字段空(半途崩溃)→ 只补回填。
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
"36wisdom/biz/consts"
)
var ctx = context.Background()
// task 一条合成任务。kind 决定文件路径与回填字段。
type task struct {
kind string // script | content | scene | option | teach
levelID int64 // scene/script/content/option 用
nodeID int64 // script/content/option 用
optionID int64
strategyID int64 // teach 用
text string
relPath string // staticDir 相对路径,如 audio/1/2/content.mp3
scriptJSON string // kind=script 时整列 script JSON
force bool
}
// scriptLine 台词行(回填 audio 时保持原字段)
type scriptLine struct {
Emotion string `json:"emotion"`
Pinyin string `json:"pinyin"`
Speaker string `json:"speaker"`
Text string `json:"text"`
Audio string `json:"audio,omitempty"`
}
// ---------- 枚举 ----------
func enumerateTasks(fl genFlags) []task {
var tasks []task
only := map[string]bool{}
if fl.only == "" {
only["script"], only["content"], only["scene"], only["option"], only["teach"] = true, true, true, true, true
} else {
only[fl.only] = true
}
levelIDs, nodeIDs := strategyScope(fl)
scopeLevel := func(m *gdb.Model) *gdb.Model {
if fl.strategy > 0 {
return m.WhereIn("level_id", levelIDs)
}
return m
}
if only["script"] {
m := g.DB().Model(consts.TableSceneNode).Fields("id, level_id, script").Where("script IS NOT NULL AND script != ''")
for _, r := range all(scopeLevel(m)) {
tasks = append(tasks, task{
kind: "script", levelID: r["level_id"].Int64(), nodeID: r["id"].Int64(),
scriptJSON: r["script"].String(),
})
}
}
if only["content"] {
m := g.DB().Model(consts.TableSceneNode).Fields("id, level_id, content").Where("content != '' AND (audio IS NULL OR audio = '')")
for _, r := range all(scopeLevel(m)) {
nid := r["id"].Int64()
tasks = append(tasks, task{
kind: "content", levelID: r["level_id"].Int64(), nodeID: nid, text: r["content"].String(),
relPath: fmt.Sprintf("audio/%d/%d/content.mp3", r["level_id"].Int64(), nid),
})
}
}
if only["scene"] {
m := g.DB().Model(consts.TableLevel).Fields("id, scene_content").Where("scene_content != '' AND (scene_audio IS NULL OR scene_audio = '')")
if fl.strategy > 0 {
m = m.Where("strategy_id", fl.strategy)
}
for _, r := range all(m) {
lid := r["id"].Int64()
tasks = append(tasks, task{
kind: "scene", levelID: lid, text: r["scene_content"].String(),
relPath: fmt.Sprintf("audio/%d/scene.mp3", lid),
})
}
}
if only["option"] {
m := g.DB().Model(consts.TableNodeOption).Fields("id, node_id, text").Where("text != '' AND (audio IS NULL OR audio = '')")
if fl.strategy > 0 {
m = m.WhereIn("node_id", nodeIDs)
}
rows := all(m)
levelOf := nodeLevelMap(rows)
for _, r := range rows {
oid, nid := r["id"].Int64(), r["node_id"].Int64()
tasks = append(tasks, task{
kind: "option", levelID: levelOf[nid], nodeID: nid, optionID: oid, text: r["text"].String(),
relPath: fmt.Sprintf("audio/%d/%d/opt_%d.mp3", levelOf[nid], nid, oid),
})
}
}
if only["teach"] {
m := g.DB().Model(consts.TableStrategy).Fields("id, teach_content").Where("teach_content != '' AND (teach_audio IS NULL OR teach_audio = '')")
if fl.strategy > 0 {
m = m.Where("id", fl.strategy)
}
for _, r := range all(m) {
sid := r["id"].Int64()
tasks = append(tasks, task{
kind: "teach", strategyID: sid, text: r["teach_content"].String(),
relPath: fmt.Sprintf("audio/teach/%d.mp3", sid),
})
}
}
return tasks
}
// strategyScope 返回某计策涉及的 level_ids 与 node_idsstrategy<=0 时均 nil
func strategyScope(fl genFlags) ([]int64, []int64) {
if fl.strategy <= 0 {
return nil, nil
}
var levelIDs []int64
for _, v := range arr(g.DB().Model(consts.TableLevel).Where("strategy_id", fl.strategy), "id") {
levelIDs = append(levelIDs, v.Int64())
}
var nodeIDs []int64
for _, v := range arr(g.DB().Model(consts.TableSceneNode).WhereIn("level_id", levelIDs), "id") {
nodeIDs = append(nodeIDs, v.Int64())
}
return levelIDs, nodeIDs
}
// nodeLevelMap 选项行 → 所属关卡(跨表组装:先取 node_ids 再对 scene_node 批量查)
func nodeLevelMap(optRows []gdb.Record) map[int64]int64 {
nodeIDs := make([]int64, 0, len(optRows))
for _, r := range optRows {
nodeIDs = append(nodeIDs, r["node_id"].Int64())
}
out := map[int64]int64{}
if len(nodeIDs) == 0 {
return out
}
for _, r := range all(g.DB().Model(consts.TableSceneNode).Fields("id, level_id").WhereIn("id", nodeIDs)) {
out[r["id"].Int64()] = r["level_id"].Int64()
}
return out
}
// ---------- 执行 ----------
func runTask(c *ttsClient, t task) error {
if t.kind == "script" {
return runScriptTask(c, t)
}
if strings.TrimSpace(t.text) == "" {
return nil
}
if !t.force && fileExists(filepath.Join(staticDir, t.relPath)) {
backfill(t) // 幂等补回填:防上次写文件后未回填
return nil
}
audio, err := c.synthesize(ctx, t.text)
if err != nil {
return err
}
if err := writeFile(filepath.Join(staticDir, t.relPath), audio); err != nil {
return err
}
backfill(t)
fmt.Printf("[gentss] ok %-7s %s\n", t.kind, t.relPath)
return nil
}
// runScriptTask 整节点台词:逐行合成(无 audio 的行),全部完成后整列重写 script JSON。
func runScriptTask(c *ttsClient, t task) error {
var lines []scriptLine
if err := json.Unmarshal([]byte(t.scriptJSON), &lines); err != nil {
return fmt.Errorf("script JSON 解析失败: %v", err)
}
changed := false
for i := range lines {
if strings.TrimSpace(lines[i].Text) == "" {
continue
}
rel := fmt.Sprintf("audio/%d/%d/%d.mp3", t.levelID, t.nodeID, i)
file := filepath.Join(staticDir, rel)
if t.force || !fileExists(file) {
audio, err := c.synthesize(ctx, lines[i].Text)
if err != nil {
return fmt.Errorf("第 %d 句合成失败: %w", i, err)
}
if err := writeFile(file, audio); err != nil {
return fmt.Errorf("第 %d 句写文件失败: %w", i, err)
}
}
url := "/static/generated/" + rel
if lines[i].Audio != url {
lines[i].Audio = url
changed = true
}
}
if changed {
b, err := json.Marshal(lines)
if err != nil {
return err
}
if _, err := g.DB().Model(consts.TableSceneNode).Ctx(ctx).Data(g.Map{"script": string(b)}).Where("id", t.nodeID).Update(); err != nil {
return err
}
}
fmt.Printf("[gentss] ok %-7s node %d%d 句)\n", t.kind, t.nodeID, len(lines))
return nil
}
// backfill 回填 audio 字段(URL 前缀 /static/generated/ 与 PNG 素材同挂载)
func backfill(t task) {
url := "/static/generated/" + t.relPath
var err error
switch t.kind {
case "content":
_, err = g.DB().Model(consts.TableSceneNode).Ctx(ctx).Data(g.Map{"audio": url}).Where("id", t.nodeID).Update()
case "scene":
_, err = g.DB().Model(consts.TableLevel).Ctx(ctx).Data(g.Map{"scene_audio": url}).Where("id", t.levelID).Update()
case "option":
_, err = g.DB().Model(consts.TableNodeOption).Ctx(ctx).Data(g.Map{"audio": url}).Where("id", t.optionID).Update()
case "teach":
_, err = g.DB().Model(consts.TableStrategy).Ctx(ctx).Data(g.Map{"teach_audio": url}).Where("id", t.strategyID).Update()
}
if err != nil {
glog.Errorf(ctx, "[gentss] 回填 %s 失败: %v", t.relPath, err)
}
}
// ---------- 清单 ----------
func printInventory(fl genFlags) {
tasks := enumerateTasks(fl)
if len(tasks) == 0 {
fmt.Println("[gentss] 无待生成任务")
return
}
counts := map[string]int{}
for _, t := range tasks {
counts[t.kind]++
}
fmt.Printf("待生成任务:script 节点 %d、内容 %d、开场 %d、选项 %d、学堂 %d\n",
counts["script"], counts["content"], counts["scene"], counts["option"], counts["teach"])
for _, t := range tasks {
if t.kind == "script" {
fmt.Printf(" script node %d (level %d)\n", t.nodeID, t.levelID)
} else {
fmt.Printf(" %-7s %s\n", t.kind, t.relPath)
}
}
}
// ---------- 工具 ----------
// all 查询并取全部行(出错即退出,与 genasset 一致)
func all(m *gdb.Model) gdb.Result {
rows, err := m.Ctx(ctx).All()
if err != nil {
glog.Fatal(ctx, err)
}
return rows
}
// arr 查询单列值
func arr(m *gdb.Model, field string) gdb.Array {
vals, err := m.Ctx(ctx).Array(field)
if err != nil {
glog.Fatal(ctx, err)
}
return vals
}
func writeFile(p string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
return os.WriteFile(p, data, 0o644)
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}