package pipeline import ( "crypto/sha1" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "regexp" "sort" "strings" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" ) const ( maxMusicStemLen = 16 musicCreditsHeader = "# NWN Music Pack Credits\n\nOriginal rights remain with the respective composers and licensors.\n\n## Processed Files\n\n| Artist | Title | Output File | Original File | Album | Date | License / Copyright | Notes |\n|---|---|---|---|---|---|---|---|\n" ) var ( musicConvertExtensions = map[string]struct{}{ ".mp3": {}, ".ogg": {}, } musicPassthroughExtensions = map[string]struct{}{ ".bmu": {}, ".wav": {}, } musicDropWords = map[string]struct{}{ "mp3": {}, "wav": {}, "flac": {}, "ogg": {}, "m4a": {}, "music": {}, "track": {}, "audio": {}, "song": {}, "soundtrack": {}, "final": {}, "version": {}, "ver": {}, "v": {}, "mix": {}, "master": {}, "remaster": {}, "free": {}, "royalty": {}, "copyright": {}, "background": {}, "bgm": {}, "loop": {}, "loops": {}, "loopable": {}, "seamless": {}, "official": {}, "download": {}, "preview": {}, "the": {}, "a": {}, "an": {}, "and": {}, "of": {}, "to": {}, "in": {}, "for": {}, "with": {}, "by": {}, } musicBracketRE = regexp.MustCompile(`\[[^\]]*\]|\([^)]*\)|\{[^}]*\}`) musicSplitRE = regexp.MustCompile(`[^A-Za-z0-9]+`) musicYearRE = regexp.MustCompile(`^\d{4,}$`) musicVowelRE = regexp.MustCompile(`[aeiou]`) ) type preparedMusicAssets struct { Generated []assetResource SkipSourceRel map[string]struct{} Artifacts []string TempRoots []string } type musicMetadata struct { Title string Artist string Album string Date string Rights string Notes string Comment string Copyright string License string } type creditsEntry struct { Artist string `json:"artist"` Title string `json:"title"` OutputFile string `json:"output_file"` OriginalFile string `json:"original_file"` Album string `json:"album"` Date string `json:"date"` Rights string `json:"rights"` Notes string `json:"notes"` } type creditsSource struct { Path string `json:"path"` Kind string `json:"kind"` Entries []creditsEntry `json:"entries"` } type creditsInventory struct { Sources []creditsSource `json:"sources"` } type creditsOverlay struct { ByOriginal map[string]creditsEntry ByOutput map[string]creditsEntry Entries []creditsEntry } func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) { usedNames := make(map[string]struct{}) dirSources := make(map[string][]string) for _, rel := range p.Inventory.AssetFiles { rel = filepath.ToSlash(rel) ext := strings.ToLower(filepath.Ext(rel)) name := strings.ToLower(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel))) if _, convertible := musicConvertExtensions[ext]; convertible && isMusicAssetPath(rel) { dir := filepath.ToSlash(filepath.Dir(rel)) dirSources[dir] = append(dirSources[dir], rel) continue } if name != "" { usedNames[name] = struct{}{} } } result := &preparedMusicAssets{ SkipSourceRel: make(map[string]struct{}), } if len(dirSources) == 0 { artifactPaths, err := writeCreditsArtifacts(p, nil) if err != nil { return nil, err } result.Artifacts = artifactPaths return result, nil } dirs := make([]string, 0, len(dirSources)) for dir := range dirSources { dirs = append(dirs, dir) } sort.Strings(dirs) generatedByDir := make(map[string][]creditsEntry) stageRoot := filepath.Join(p.Root, ".cache", "music") result.TempRoots = append(result.TempRoots, stageRoot) ffmpegPath, err := resolveFFmpegBinary() if err != nil { return nil, err } ffprobePath, err := resolveFFprobeBinary() if err != nil { return nil, err } for _, dir := range dirs { sources := append([]string(nil), dirSources[dir]...) sort.Strings(sources) prefix := musicPrefixForDir(p, dir) overlayPath := filepath.Join(p.AssetsDir(), filepath.FromSlash(dir), "CREDITS.md") overlay, err := parseCreditsOverlay(overlayPath) if err != nil { return nil, fmt.Errorf("parse credits overlay %s: %w", overlayPath, err) } entries := make([]creditsEntry, 0, len(sources)) for _, rel := range sources { base := filepath.Base(rel) manual := overlay.match(base, "") outputFile := "" switch { case manual != nil && strings.TrimSpace(manual.OutputFile) != "": outputFile = strings.ToLower(strings.TrimSpace(manual.OutputFile)) if err := validateManualOutputFile(outputFile); err != nil { return nil, fmt.Errorf("manual credits output for %s: %w", rel, err) } if err := reserveMusicName(strings.TrimSuffix(outputFile, filepath.Ext(outputFile)), usedNames); err != nil { return nil, fmt.Errorf("manual credits output for %s: %w", rel, err) } default: stem, err := generateMusicStem(base, prefix, usedNames) if err != nil { return nil, fmt.Errorf("generate music name for %s: %w", rel, err) } outputFile = stem + ".bmu" } outputRel := filepath.ToSlash(filepath.Join(dir, outputFile)) metadata, err := probeMusicMetadata(ffprobePath, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel))) if err != nil { return nil, fmt.Errorf("probe music metadata for %s: %w", rel, err) } entry := creditsEntry{ Artist: metadata.Artist, Title: firstNonEmpty(metadata.Title, strings.TrimSuffix(base, filepath.Ext(base))), OutputFile: outputFile, OriginalFile: base, Album: metadata.Album, Date: metadata.Date, Rights: firstNonEmpty(metadata.Rights, joinNonEmpty("; ", metadata.License, metadata.Copyright)), Notes: firstNonEmpty(metadata.Notes, metadata.Comment), } applyCreditsOverlay(&entry, manual) stagePath := filepath.Join(stageRoot, filepath.FromSlash(outputRel)) if err := os.MkdirAll(filepath.Dir(stagePath), 0o755); err != nil { return nil, fmt.Errorf("create music stage dir: %w", err) } if err := convertMusicSource(ffmpegPath, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel)), stagePath); err != nil { return nil, fmt.Errorf("convert music %s: %w", rel, err) } resourceInfo, err := assetResourceFromPath(stagePath, outputRel, lfsAssetInfo{}, false) if err != nil { return nil, fmt.Errorf("load converted music %s: %w", outputRel, err) } info, err := os.Stat(stagePath) if err != nil { return nil, fmt.Errorf("stat converted music %s: %w", stagePath, err) } result.Generated = append(result.Generated, assetResource{ Rel: outputRel, Resource: resourceInfo.Resource, Size: erf.ArchiveSize([]erf.Resource{resourceInfo.Resource}), CreatedAt: info.ModTime(), ContentID: resourceInfo.ContentID, }) result.SkipSourceRel[rel] = struct{}{} entries = append(entries, entry) } if err := validateOverlayEntries(dir, overlay, entries); err != nil { return nil, err } generatedByDir[dir] = entries } artifactPaths, err := writeCreditsArtifacts(p, generatedByDir) if err != nil { return nil, err } result.Artifacts = artifactPaths sort.Slice(result.Generated, func(i, j int) bool { return compareResourceKeys(result.Generated[i].Resource, result.Generated[j].Resource) < 0 }) return result, nil } func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]creditsEntry) ([]string, error) { creditsRoot := filepath.Join(p.Root, ".cache", "credits") if err := os.RemoveAll(creditsRoot); err != nil { return nil, fmt.Errorf("reset credits dir: %w", err) } if err := os.MkdirAll(creditsRoot, 0o755); err != nil { return nil, fmt.Errorf("create credits dir: %w", err) } sources := make([]creditsSource, 0) artifacts := make([]string, 0) if generatedByDir != nil { dirs := make([]string, 0, len(generatedByDir)) for dir := range generatedByDir { dirs = append(dirs, dir) } sort.Strings(dirs) for _, dir := range dirs { entries := append([]creditsEntry(nil), generatedByDir[dir]...) sort.Slice(entries, func(i, j int) bool { if strings.ToLower(entries[i].Artist) != strings.ToLower(entries[j].Artist) { return strings.ToLower(entries[i].Artist) < strings.ToLower(entries[j].Artist) } if strings.ToLower(entries[i].Title) != strings.ToLower(entries[j].Title) { return strings.ToLower(entries[i].Title) < strings.ToLower(entries[j].Title) } return strings.ToLower(entries[i].OutputFile) < strings.ToLower(entries[j].OutputFile) }) outputPath := filepath.Join(creditsRoot, filepath.FromSlash(dir), "CREDITS.md") if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { return nil, fmt.Errorf("create generated credits dir: %w", err) } if err := os.WriteFile(outputPath, []byte(renderCreditsMarkdown(entries)), 0o644); err != nil { return nil, fmt.Errorf("write generated credits %s: %w", outputPath, err) } artifacts = append(artifacts, outputPath) sources = append(sources, creditsSource{ Path: filepath.ToSlash(outputPath), Kind: "generated_music", Entries: entries, }) } } err := filepath.WalkDir(p.AssetsDir(), func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if d.IsDir() { return nil } if strings.ToLower(d.Name()) != "credits.md" { return nil } entries, err := parseCreditsMarkdown(path) if err != nil { return fmt.Errorf("parse credits %s: %w", path, err) } rel, err := filepath.Rel(p.Root, path) if err != nil { return err } sources = append(sources, creditsSource{ Path: filepath.ToSlash(rel), Kind: "authored", Entries: entries, }) return nil }) if err != nil { return nil, err } sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path }) inventoryPath := filepath.Join(creditsRoot, "credits.json") payload, err := json.MarshalIndent(creditsInventory{Sources: sources}, "", " ") if err != nil { return nil, fmt.Errorf("marshal credits inventory: %w", err) } payload = append(payload, '\n') if err := os.WriteFile(inventoryPath, payload, 0o644); err != nil { return nil, fmt.Errorf("write credits inventory: %w", err) } artifacts = append(artifacts, inventoryPath) return artifacts, nil } func cleanupPreparedMusicAssets(prepared *preparedMusicAssets) error { if prepared == nil { return nil } for _, root := range prepared.TempRoots { if strings.TrimSpace(root) == "" { continue } if err := os.RemoveAll(root); err != nil { return fmt.Errorf("remove temporary music artifact root %s: %w", root, err) } } return nil } func parseCreditsOverlay(path string) (creditsOverlay, error) { entries, err := parseCreditsMarkdown(path) if err != nil { return creditsOverlay{}, err } overlay := creditsOverlay{ ByOriginal: make(map[string]creditsEntry), ByOutput: make(map[string]creditsEntry), Entries: entries, } for _, entry := range entries { if entry.OriginalFile != "" { key := strings.ToLower(strings.TrimSpace(entry.OriginalFile)) if _, exists := overlay.ByOriginal[key]; exists { return creditsOverlay{}, fmt.Errorf("duplicate manual credits row for original file %s", entry.OriginalFile) } overlay.ByOriginal[key] = entry } if entry.OutputFile != "" { key := strings.ToLower(strings.TrimSpace(entry.OutputFile)) if _, exists := overlay.ByOutput[key]; exists { return creditsOverlay{}, fmt.Errorf("duplicate manual credits row for output file %s", entry.OutputFile) } overlay.ByOutput[key] = entry } } return overlay, nil } func (o creditsOverlay) match(originalFile, outputFile string) *creditsEntry { if entry, ok := o.ByOutput[strings.ToLower(strings.TrimSpace(outputFile))]; ok { entryCopy := entry return &entryCopy } if entry, ok := o.ByOriginal[strings.ToLower(strings.TrimSpace(originalFile))]; ok { entryCopy := entry return &entryCopy } return nil } func validateOverlayEntries(dir string, overlay creditsOverlay, generated []creditsEntry) error { if len(overlay.Entries) == 0 { return nil } validOriginal := make(map[string]struct{}, len(generated)) validOutput := make(map[string]struct{}, len(generated)) for _, entry := range generated { validOriginal[strings.ToLower(entry.OriginalFile)] = struct{}{} validOutput[strings.ToLower(entry.OutputFile)] = struct{}{} } for _, entry := range overlay.Entries { if entry.OriginalFile != "" { if _, ok := validOriginal[strings.ToLower(entry.OriginalFile)]; !ok { return fmt.Errorf("manual credits entry in %s references unknown original file %s", dir, entry.OriginalFile) } } if entry.OutputFile != "" { if _, ok := validOutput[strings.ToLower(entry.OutputFile)]; !ok { return fmt.Errorf("manual credits entry in %s references unknown output file %s", dir, entry.OutputFile) } } } return nil } func parseCreditsMarkdown(path string) ([]creditsEntry, error) { raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } lines := strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n") header := []string(nil) entries := make([]creditsEntry, 0) for _, line := range lines { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "|") { continue } cells := parseMarkdownTableRow(line) if len(cells) == 0 { continue } if header == nil { header = make([]string, len(cells)) for i, cell := range cells { header[i] = normalizeCreditsHeader(cell) } continue } if isMarkdownSeparatorRow(cells) { continue } entry := creditsEntry{} for i, key := range header { if i >= len(cells) { continue } value := cleanCreditsCell(cells[i]) switch key { case "artist": entry.Artist = value case "title": entry.Title = value case "output_file": entry.OutputFile = value case "original_file": entry.OriginalFile = value case "album": entry.Album = value case "date": entry.Date = value case "rights": entry.Rights = value case "notes": entry.Notes = value } } if entry.Artist == "" && entry.Title == "" && entry.OutputFile == "" && entry.OriginalFile == "" { continue } entries = append(entries, entry) } return entries, nil } func parseMarkdownTableRow(line string) []string { trimmed := strings.TrimSpace(line) trimmed = strings.TrimPrefix(trimmed, "|") trimmed = strings.TrimSuffix(trimmed, "|") parts := strings.Split(trimmed, "|") cells := make([]string, 0, len(parts)) for _, part := range parts { cells = append(cells, strings.TrimSpace(strings.ReplaceAll(part, `\|`, "|"))) } return cells } func isMarkdownSeparatorRow(cells []string) bool { if len(cells) == 0 { return false } for _, cell := range cells { cell = strings.TrimSpace(cell) cell = strings.ReplaceAll(cell, "-", "") cell = strings.ReplaceAll(cell, ":", "") if cell != "" { return false } } return true } func normalizeCreditsHeader(value string) string { switch strings.ToLower(cleanCreditsCell(value)) { case "artist": return "artist" case "title": return "title" case "output file": return "output_file" case "original file": return "original_file" case "album": return "album" case "date": return "date" case "license / copyright": return "rights" case "notes": return "notes" default: return "" } } func cleanCreditsCell(value string) string { value = strings.TrimSpace(value) value = strings.Trim(value, "`") value = strings.ReplaceAll(value, "
", "\n") return strings.TrimSpace(value) } func renderCreditsMarkdown(entries []creditsEntry) string { var builder strings.Builder builder.WriteString(musicCreditsHeader) for _, entry := range entries { builder.WriteString("| ") builder.WriteString(escapeMarkdownCell(entry.Artist)) builder.WriteString(" | ") builder.WriteString(escapeMarkdownCell(entry.Title)) builder.WriteString(" | ") builder.WriteString(escapeMarkdownCell(wrapCodeCell(entry.OutputFile))) builder.WriteString(" | ") builder.WriteString(escapeMarkdownCell(wrapCodeCell(entry.OriginalFile))) builder.WriteString(" | ") builder.WriteString(escapeMarkdownCell(entry.Album)) builder.WriteString(" | ") builder.WriteString(escapeMarkdownCell(entry.Date)) builder.WriteString(" | ") builder.WriteString(escapeMarkdownCell(entry.Rights)) builder.WriteString(" | ") builder.WriteString(escapeMarkdownCell(entry.Notes)) builder.WriteString(" |\n") } return builder.String() } func wrapCodeCell(value string) string { if strings.TrimSpace(value) == "" { return "" } return "`" + value + "`" } func escapeMarkdownCell(value string) string { value = strings.ReplaceAll(value, `\`, `\\`) value = strings.ReplaceAll(value, "|", `\|`) value = strings.ReplaceAll(value, "\n", "
") return value } func applyCreditsOverlay(entry *creditsEntry, overlay *creditsEntry) { if overlay == nil { return } entry.Artist = firstNonEmpty(overlay.Artist, entry.Artist) entry.Title = firstNonEmpty(overlay.Title, entry.Title) entry.Album = firstNonEmpty(overlay.Album, entry.Album) entry.Date = firstNonEmpty(overlay.Date, entry.Date) entry.Rights = firstNonEmpty(overlay.Rights, entry.Rights) entry.Notes = firstNonEmpty(overlay.Notes, entry.Notes) } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func joinNonEmpty(sep string, values ...string) string { parts := make([]string, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value != "" { parts = append(parts, value) } } return strings.Join(parts, sep) } func resolveFFmpegBinary() (string, error) { if configured := strings.TrimSpace(os.Getenv("SOW_FFMPEG")); configured != "" { return configured, nil } path, err := exec.LookPath("ffmpeg") if err != nil { return "", fmt.Errorf("ffmpeg not found; install ffmpeg or set SOW_FFMPEG") } return path, nil } func resolveFFprobeBinary() (string, error) { if configured := strings.TrimSpace(os.Getenv("SOW_FFPROBE")); configured != "" { return configured, nil } path, err := exec.LookPath("ffprobe") if err != nil { return "", fmt.Errorf("ffprobe not found; install ffprobe or set SOW_FFPROBE") } return path, nil } func probeMusicMetadata(ffprobePath, sourcePath string) (musicMetadata, error) { cmd := exec.Command(ffprobePath, "-v", "quiet", "-print_format", "json", "-show_format", sourcePath) output, err := cmd.Output() if err != nil { return musicMetadata{}, err } var payload struct { Format struct { Tags map[string]string `json:"tags"` } `json:"format"` } if err := json.Unmarshal(output, &payload); err != nil { return musicMetadata{}, err } tags := make(map[string]string, len(payload.Format.Tags)) for key, value := range payload.Format.Tags { tags[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value) } return musicMetadata{ Title: firstNonEmpty(tags["title"]), Artist: firstNonEmpty(tags["artist"], tags["album_artist"], tags["composer"]), Album: firstNonEmpty(tags["album"]), Date: firstNonEmpty(tags["date"], tags["year"]), Comment: firstNonEmpty(tags["comment"], tags["description"]), Copyright: firstNonEmpty(tags["copyright"]), License: firstNonEmpty(tags["license"], tags["license_url"]), }, nil } func convertMusicSource(ffmpegPath, sourcePath, outputPath string) error { cmd := exec.Command( ffmpegPath, "-y", "-i", sourcePath, "-vn", "-map_metadata", "-1", "-ar", "44100", "-ac", "2", "-b:a", "192k", "-codec:a", "libmp3lame", "-write_xing", "0", "-f", "mp3", outputPath, ) output, err := cmd.CombinedOutput() if err != nil { message := strings.TrimSpace(string(output)) if message == "" { message = err.Error() } return fmt.Errorf("%s", message) } return nil } func isMusicAssetPath(rel string) bool { rel = filepath.ToSlash(strings.TrimSpace(rel)) return rel == "envi/music" || strings.HasPrefix(rel, "envi/music/") } func musicPrefixForDir(p *project.Project, dir string) string { dir = trimSlashes(filepath.ToSlash(dir)) bestPrefix := "" bestLen := -1 for key, value := range p.Config.Music.Prefixes { normalizedKey := trimSlashes(filepath.ToSlash(key)) if normalizedKey == "" { continue } if dir != normalizedKey && !strings.HasPrefix(dir, normalizedKey+"/") { continue } if len(normalizedKey) > bestLen { bestLen = len(normalizedKey) bestPrefix = sanitizePrefix(value) } } return bestPrefix } func sanitizePrefix(prefix string) string { prefix = strings.ToLower(strings.TrimSpace(prefix)) var builder strings.Builder for _, r := range prefix { switch { case r >= 'a' && r <= 'z': builder.WriteRune(r) case r >= '0' && r <= '9': builder.WriteRune(r) case r == '_': builder.WriteRune(r) } } result := builder.String() if strings.TrimSpace(prefix) != "" && strings.HasSuffix(strings.TrimSpace(prefix), "_") && !strings.HasSuffix(result, "_") { result += "_" } return result } func trimSlashes(value string) string { return strings.Trim(strings.TrimSpace(value), "/") } func generateMusicStem(fileName, prefix string, used map[string]struct{}) (string, error) { maxCore := maxMusicStemLen - len(prefix) if maxCore < 3 { return "", fmt.Errorf("prefix too long: %q", prefix) } base := strings.TrimSuffix(fileName, filepath.Ext(fileName)) words := slugWords(base) core := makeMusicCore(words, maxCore) if core == "" { sum := sha1.Sum([]byte(fileName)) core = fmt.Sprintf("%x", sum[:])[:maxCore] } stem := strings.TrimRight((prefix + core)[:min(maxMusicStemLen, len(prefix)+len(core))], "_") if stem == "" { return "", fmt.Errorf("could not derive music stem for %s", fileName) } return uniqueMusicName(stem, used), nil } func slugWords(value string) []string { value = musicBracketRE.ReplaceAllString(value, " ") value = strings.ReplaceAll(value, "&", " and ") value = strings.ReplaceAll(value, "'", "") value = strings.ReplaceAll(value, "’", "") value = strings.ToLower(value) var ascii strings.Builder for _, r := range value { switch { case r >= 'a' && r <= 'z': ascii.WriteRune(r) case r >= '0' && r <= '9': ascii.WriteRune(r) default: ascii.WriteRune(' ') } } rawWords := musicSplitRE.Split(ascii.String(), -1) words := make([]string, 0, len(rawWords)) for _, word := range rawWords { if word == "" { continue } if _, drop := musicDropWords[word]; drop { continue } if musicYearRE.MatchString(word) { continue } words = append(words, word) } return words } func makeMusicCore(words []string, maxCore int) string { core := "" for _, word := range words { candidate := word if core != "" { candidate = core + "_" + word } if len(candidate) <= maxCore { core = candidate continue } break } if core != "" { return core } abbrev := make([]string, 0, min(4, len(words))) for _, word := range words { if len(abbrev) == 4 { break } abbrev = append(abbrev, abbreviateMusicWord(word, 5)) } for _, word := range abbrev { candidate := word if core != "" { candidate = core + "_" + word } if len(candidate) <= maxCore { core = candidate continue } break } if core != "" { return core } if len(words) > 0 { return strings.Trim(words[0][:min(maxCore, len(words[0]))], "_") } return "" } func abbreviateMusicWord(word string, maxLen int) string { if len(word) <= maxLen { return word } consonants := musicVowelRE.ReplaceAllString(word, "") candidate := word[:1] if len(consonants) > 1 { candidate += consonants[1:] } if len(candidate) >= 3 { return candidate[:min(maxLen, len(candidate))] } return word[:min(maxLen, len(word))] } func uniqueMusicName(stem string, used map[string]struct{}) string { if _, exists := used[stem]; !exists { used[stem] = struct{}{} return stem } for index := 1; ; index++ { suffix := fmt.Sprintf("_%d", index) prefixLen := min(len(stem), max(0, maxMusicStemLen-len(suffix))) candidate := strings.TrimRight(stem[:prefixLen], "_") + suffix if _, exists := used[candidate]; exists { continue } used[candidate] = struct{}{} return candidate } } func reserveMusicName(stem string, used map[string]struct{}) error { stem = strings.ToLower(strings.TrimSpace(stem)) if stem == "" { return fmt.Errorf("output filename is empty") } if _, exists := used[stem]; exists { return fmt.Errorf("output filename %s collides with an existing asset", stem) } used[stem] = struct{}{} return nil } func validateManualOutputFile(name string) error { name = strings.ToLower(strings.TrimSpace(name)) if !strings.HasSuffix(name, ".bmu") { return fmt.Errorf("output file must end with .bmu") } stem := strings.TrimSuffix(name, ".bmu") if stem == "" { return fmt.Errorf("output file stem is empty") } if len(stem) > maxMusicStemLen { return fmt.Errorf("output file stem %q exceeds %d characters", stem, maxMusicStemLen) } for _, r := range stem { switch { case r >= 'a' && r <= 'z': case r >= '0' && r <= '9': case r == '_': default: return fmt.Errorf("output file stem %q contains unsupported character %q", stem, string(r)) } } return nil } func min(a, b int) int { if a < b { return a } return b } func max(a, b int) int { if a > b { return a } return b }