diff --git a/cmd/gentss/edge.go b/cmd/gentss/edge.go new file mode 100644 index 0000000..f0f41f4 --- /dev/null +++ b/cmd/gentss/edge.go @@ -0,0 +1,309 @@ +package main + +// Edge TTS 客户端:微软 Edge 朗读私有 WebSocket 协议。 +// 实现对照 edge-tts 7.2.8(drm.py / constants.py / communicate.py)逐行校验: +// Sec-MS-GEC 纯算法生成(无 HTTP 预请求),403 时读响应 Date 头校正时钟偏移重试。 + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "math" + "net/http" + "strings" + "time" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +const ( + trustedClientToken = "6A5AA1D4EAFF4E9FB37E23D68491D6F4" + winEpoch = 11644473600 // Unix 与 Windows 文件时间纪元的秒差(1601-01-01) + originHeader = "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold" + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0" + audioURL = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1" + audioFormat = "audio-24khz-48kbitrate-mono-mp3" + maxChunkBytes = 4096 // 单请求文本字节上限(与 edge-tts 一致,超长分块多轮连接) +) + +type ttsClient struct { + voice string + rate string + version string + timeout time.Duration + retries int + clockSkew float64 // 403 时按服务端 Date 头累加校正 +} + +// permanentErr 协议类错误,重试无意义(不重试)。 +type permanentErr struct{ err error } + +func (e *permanentErr) Error() string { return e.err.Error() } +func (e *permanentErr) Unwrap() error { return e.err } + +// synthesize 合成一段文本,返回 mp3 字节;超长文本按字节上限分块、逐块独立连接后拼接。 +func (c *ttsClient) synthesize(ctx context.Context, text string) ([]byte, error) { + text = escapeXML(cleanText(text)) + var buf []byte + for _, chunk := range splitChunks(text, maxChunkBytes) { + audio, err := c.synthesizeChunk(ctx, chunk) + if err != nil { + return nil, err + } + buf = append(buf, audio...) + } + if len(buf) == 0 { + return nil, gerror.New("合成结果为空") + } + return buf, nil +} + +func (c *ttsClient) synthesizeChunk(ctx context.Context, text string) ([]byte, error) { + var lastErr error + for attempt := 0; attempt <= c.retries; attempt++ { + if attempt > 0 { + select { + case <-time.After(time.Duration(attempt) * time.Second): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + audio, err := c.turn(ctx, text) + if err == nil { + return audio, nil + } + lastErr = err + var pe *permanentErr + if errors.As(err, &pe) { + return nil, err + } + } + return nil, fmt.Errorf("重试 %d 次仍失败: %w", c.retries, lastErr) +} + +// turn 一次连接完成一帧文本的合成,返回 mp3 字节。 +func (c *ttsClient) turn(ctx context.Context, text string) ([]byte, error) { + conn, err := c.dial(ctx) + if err != nil { + return nil, err + } + defer conn.Close() + + // 帧 1:speech.config(输出格式配置) + configFrame := fmt.Sprintf( + "X-Timestamp:%s\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n"+ + `{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":"true","wordBoundaryEnabled":"false"},"outputFormat":"%s"}}}}`+"\r\n", + dateString(), audioFormat) + // 帧 2:ssml(xml:lang 固定 en-US,与 edge-tts 一致;X-Timestamp 尾缀 Z 是微软端约定) + ssml := fmt.Sprintf( + ""+ + "%s", + c.voice, c.rate, text) + ssmlFrame := fmt.Sprintf( + "X-RequestId:%s\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:%sZ\r\nPath:ssml\r\n\r\n%s", + strings.ReplaceAll(uuid.NewString(), "-", ""), dateString(), ssml) + + conn.SetWriteDeadline(time.Now().Add(c.timeout)) + if err := conn.WriteMessage(websocket.TextMessage, []byte(configFrame)); err != nil { + return nil, fmt.Errorf("发送 speech.config 失败: %w", err) + } + if err := conn.WriteMessage(websocket.TextMessage, []byte(ssmlFrame)); err != nil { + return nil, fmt.Errorf("发送 ssml 失败: %w", err) + } + + var audio []byte + for { + conn.SetReadDeadline(time.Now().Add(c.timeout)) + mt, payload, err := conn.ReadMessage() + if err != nil { + return nil, fmt.Errorf("读帧失败: %w", err) + } + switch mt { + case websocket.TextMessage: + path, err := textFramePath(payload) + if err != nil { + return nil, &permanentErr{err} + } + switch path { + case "turn.end": + if len(audio) == 0 { + return nil, &permanentErr{gerror.New("无音频返回(文本或参数可能不被支持)")} + } + return audio, nil + case "turn.start", "response", "audio.metadata": + // 忽略 + default: + return nil, &permanentErr{fmt.Errorf("未知响应 Path: %s", path)} + } + case websocket.BinaryMessage: + data, err := audioFrameData(payload) + if err != nil { + return nil, &permanentErr{err} + } + audio = append(audio, data...) + } + } +} + +func (c *ttsClient) dial(ctx context.Context) (*websocket.Conn, error) { + connID := strings.ReplaceAll(uuid.NewString(), "-", "") + wsURL := fmt.Sprintf("%s?TrustedClientToken=%s&ConnectionId=%s&Sec-MS-GEC=%s&Sec-MS-GEC-Version=%s", + audioURL, trustedClientToken, connID, c.secMsGec(), c.version) + header := http.Header{ + "Pragma": {"no-cache"}, + "Cache-Control": {"no-cache"}, + "Origin": {originHeader}, + "User-Agent": {userAgent}, + "Accept-Language": {"en-US,en;q=0.9"}, + "Cookie": {"muid=" + muid() + ";"}, + } + dialer := websocket.Dialer{ + EnableCompression: true, + HandshakeTimeout: c.timeout, + } + conn, resp, err := dialer.DialContext(ctx, wsURL, header) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusForbidden { + // 403 多为时钟偏差:读 Date 头校正偏移,下次握手即有效 + if serverTS, ok := parseRFC2616Date(resp.Header.Get("Date")); ok { + c.clockSkew += serverTS - float64(time.Now().Unix()) + } + } + return nil, fmt.Errorf("握手失败: %w", err) + } + return conn, nil +} + +// secMsGec 生成 Sec-MS-GEC 令牌(edge-tts drm.py generate_sec_ms_gec): +// (UTC秒 + 时钟偏差 + 11644473600) 取整到 5 分钟窗 → ×1e9/100 转 Windows 100ns 刻度 → +// 拼接 TrustedClientToken → SHA256 大写 hex。 +func (c *ttsClient) secMsGec() string { + ticks := float64(time.Now().Unix()) + c.clockSkew + ticks += winEpoch + ticks -= math.Mod(ticks, 300) + ticks *= 1e9 / 100 + sum := sha256.Sum256([]byte(fmt.Sprintf("%.0f%s", ticks, trustedClientToken))) + return strings.ToUpper(hex.EncodeToString(sum[:])) +} + +// muid 32 位大写 hex 随机串(Cookie: muid=...;) +func muid() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return strings.ToUpper(hex.EncodeToString(b)) +} + +// dateString JavaScript 风格 UTC 时间串(speech.config / ssml 帧头) +func dateString() string { + return time.Now().UTC().Format("Mon Jan 02 2006 15:04:05 GMT+0000 (Coordinated Universal Time)") +} + +// parseRFC2616Date 解析 HTTP Date 头(如 Thu, 14 Aug 2026 12:34:56 GMT) +func parseRFC2616Date(s string) (float64, bool) { + t, err := time.Parse("Mon, 02 Jan 2006 15:04:05 MST", s) + if err != nil { + return 0, false + } + return float64(t.Unix()), true +} + +// cleanText 剔除服务端不支持的 ASCII 控制字符(0-8、11-12、14-31 → 空格) +func cleanText(s string) string { + rs := []rune(s) + for i, r := range rs { + if (r >= 0 && r <= 8) || (r >= 11 && r <= 12) || (r >= 14 && r <= 31) { + rs[i] = ' ' + } + } + return string(rs) +} + +// escapeXML 转义 XML 特殊字符(与 saxutils.escape 默认一致:& < >) +func escapeXML(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">") + return r.Replace(s) +} + +// splitChunks 按字节上限在 rune 边界切分(超长文本分块,避免单请求过大) +func splitChunks(s string, maxBytes int) []string { + if len(s) <= maxBytes { + return []string{s} + } + var chunks []string + var buf []byte + for _, r := range s { + rb := string(r) + if len(buf) > 0 && len(buf)+len(rb) > maxBytes { + chunks = append(chunks, string(buf)) + buf = nil + } + buf = append(buf, rb...) + } + if len(buf) > 0 { + chunks = append(chunks, string(buf)) + } + return chunks +} + +// textFramePath 取文本帧的 Path 头(帧结构:头\r\n\r\n载荷) +func textFramePath(payload []byte) (string, error) { + idx := bytes.Index(payload, []byte("\r\n\r\n")) + if idx < 0 { + return "", gerror.New("文本帧缺头尾分隔") + } + return framePath(payload[:idx]) +} + +// audioFrameData 解析二进制音频帧:前 2 字节大端头长度,头块(自身含尾部 \r\n)后即 mp3 载荷。 +// 终止帧:无 Content-Type 且载荷为空(实测头文本含尾部 \r\n,与 Python 的 headerLen+2 偏移等价)。 +func audioFrameData(payload []byte) ([]byte, error) { + if len(payload) < 2 { + return nil, gerror.New("音频帧缺头长度") + } + headerLen := int(binary.BigEndian.Uint16(payload[:2])) + if 2+headerLen > len(payload) { + return nil, gerror.New("音频帧头长度越界") + } + path, err := framePath(payload[2 : 2+headerLen]) + if err != nil { + return nil, err + } + if path != "audio" { + return nil, fmt.Errorf("二进制帧 Path 非 audio: %s", path) + } + data := payload[2+headerLen:] + contentType, _ := frameHeader(payload[2:2+headerLen], "Content-Type") + switch { + case contentType == "": + if len(data) == 0 { + return nil, nil // 终止帧 + } + return nil, gerror.New("无 Content-Type 却有数据") + case contentType != "audio/mpeg": + return nil, fmt.Errorf("意外的 Content-Type: %s", contentType) + case len(data) == 0: + return nil, gerror.New("audio/mpeg 帧缺音频数据") + } + return data, nil +} + +func framePath(headers []byte) (string, error) { + return frameHeader(headers, "Path") +} + +func frameHeader(headers []byte, key string) (string, error) { + for _, line := range bytes.Split(headers, []byte("\r\n")) { + k, v, ok := bytes.Cut(line, []byte(":")) + if ok && string(k) == key { + return string(v), nil + } + } + return "", fmt.Errorf("帧缺 %s 头", key) +} diff --git a/cmd/gentss/main.go b/cmd/gentss/main.go new file mode 100644 index 0000000..202f126 --- /dev/null +++ b/cmd/gentss/main.go @@ -0,0 +1,107 @@ +package main + +// gentss:离线生成 TTS 朗读音频(Edge TTS 神经网络语音)并回填各 audio 字段。 +// 用法: +// +// go run ./cmd/gentss --list # 打印待生成清单 +// go run ./cmd/gentss --strategy=1 # 只生成第 1 计(8 节点剧本已导入) +// go run ./cmd/gentss --only=script|content|scene|option|teach # 只生成指定内容源 +// go run ./cmd/gentss --force # 覆盖已生成 mp3 重新合成 +// +// 幂等:mp3 文件已存在且字段已回填 → 跳过(--force 覆盖);文件在但字段空 → 只补回填。 +// 配置在 config.yml 的 tts 段;产物落 ui-src/static/generated/audio/,URL /static/generated/audio/...(与 PNG 素材同挂载)。 + +import ( + "flag" + "fmt" + "sync" + "time" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" +) + +var staticDir = "ui-src/static/generated" // 打包入客户端的音频目录 + +type genFlags struct { + listOnly bool + only string // script | content | scene | option | teach + strategy int // 0 = 全部计策 + force bool +} + +type ttsConfig struct { + voice string + rate string + version string + concurrency int + timeout int + retries int +} + +func main() { + fl := parseFlags() + cfg := loadTTSConfig() + if fl.listOnly { + printInventory(fl) + return + } + client := &ttsClient{ + voice: cfg.voice, + rate: cfg.rate, + version: cfg.version, + timeout: time.Duration(cfg.timeout) * time.Second, + retries: cfg.retries, + } + tasks := enumerateTasks(fl) + if len(tasks) == 0 { + fmt.Println("[gentss] 无待生成任务") + return + } + sem := make(chan struct{}, cfg.concurrency) + var wg sync.WaitGroup + for _, t := range tasks { + t.force = fl.force + wg.Add(1) + sem <- struct{}{} + go func(t task) { + defer wg.Done() + defer func() { <-sem }() + if err := runTask(client, t); err != nil { + glog.Errorf(ctx, "[gentss] %s 失败: %v", taskDesc(t), err) + } + }(t) + } + wg.Wait() + fmt.Println("[gentss] 完成") +} + +func parseFlags() genFlags { + var fl genFlags + flag.BoolVar(&fl.listOnly, "list", false, "只打印待生成清单") + flag.StringVar(&fl.only, "only", "", "script | content | scene | option | teach,默认全部") + flag.IntVar(&fl.strategy, "strategy", 0, "只处理指定计策(strategy_id),0=全部") + flag.BoolVar(&fl.force, "force", false, "覆盖已生成音频重新合成") + flag.Parse() + return fl +} + +func loadTTSConfig() ttsConfig { + return ttsConfig{ + voice: g.Cfg().MustGet(ctx, "tts.voice", "zh-CN-XiaoxiaoNeural").String(), + rate: g.Cfg().MustGet(ctx, "tts.rate", "+0%").String(), + version: g.Cfg().MustGet(ctx, "tts.version", "1-143.0.3650.75").String(), + concurrency: g.Cfg().MustGet(ctx, "tts.concurrency", 2).Int(), + timeout: g.Cfg().MustGet(ctx, "tts.timeout", 60).Int(), + retries: g.Cfg().MustGet(ctx, "tts.retries", 3).Int(), + } +} + +func taskDesc(t task) string { + if t.kind == "script" { + return fmt.Sprintf("node %d", t.nodeID) + } + return t.relPath +} diff --git a/cmd/gentss/tasks.go b/cmd/gentss/tasks.go new file mode 100644 index 0000000..8cd0c7f --- /dev/null +++ b/cmd/gentss/tasks.go @@ -0,0 +1,296 @@ +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_ids(strategy<=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 +}