diff --git a/internal/app/app.go b/internal/app/app.go
index a0dffd6..95aa0c3 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -304,6 +304,9 @@ func runBuildHAKs(ctx context) error {
if result.Manifest != "" {
fmt.Fprintf(ctx.stdout, "hak manifest: %s\n", result.Manifest)
}
+ if len(result.CreditsArtifactPaths) > 0 {
+ fmt.Fprintf(ctx.stdout, "credits artifacts: %d\n", len(result.CreditsArtifactPaths))
+ }
if len(result.AutogenManifestPaths) > 0 {
fmt.Fprintf(ctx.stdout, "autogen manifests: %d\n", len(result.AutogenManifestPaths))
}
diff --git a/internal/erf/erf.go b/internal/erf/erf.go
index 622865e..711af01 100644
--- a/internal/erf/erf.go
+++ b/internal/erf/erf.go
@@ -67,6 +67,7 @@ var extensionTypes = map[string]uint16{
"wav": 0x0004,
"plt": 0x0006,
"ini": 0x0007,
+ "bmu": 0x0008,
"txt": 0x000A,
"mdl": 0x07D2,
"nss": 0x07D9,
@@ -130,6 +131,7 @@ var typeExtensions = map[uint16]string{
0x0004: "wav",
0x0006: "plt",
0x0007: "ini",
+ 0x0008: "bmu",
0x000A: "txt",
0x07D2: "mdl",
0x07D9: "nss",
diff --git a/internal/pipeline/build.go b/internal/pipeline/build.go
index e383cc6..90a14ab 100644
--- a/internal/pipeline/build.go
+++ b/internal/pipeline/build.go
@@ -27,6 +27,7 @@ type BuildResult struct {
HAKPaths []string
Manifest string
AutogenManifestPaths []string
+ CreditsArtifactPaths []string
Resources int
HAKAssets int
TopPackageHAK string
@@ -192,7 +193,11 @@ func planOrBuildHAKs(p *project.Project, progress ProgressFunc, writeArchives bo
if err != nil {
return BuildResult{}, err
}
- assetResources, err := collectAssetResources(p, false, allowedAssets)
+ musicAssets, err := prepareMusicAssets(p)
+ if err != nil {
+ return BuildResult{}, err
+ }
+ assetResources, err := collectAssetResources(p, false, allowedAssets, musicAssets)
if err != nil {
return BuildResult{}, err
}
@@ -209,6 +214,7 @@ func planOrBuildHAKs(p *project.Project, progress ProgressFunc, writeArchives bo
}
result := BuildResult{HAKAssets: len(assetResources)}
+ result.CreditsArtifactPaths = append(result.CreditsArtifactPaths, musicAssets.Artifacts...)
if err := writeAutogenManifestOutputs(progress, autogenManifests); err != nil {
return BuildResult{}, err
}
@@ -657,7 +663,7 @@ func fieldStringValueForScripts(value gff.Value) (string, bool) {
}
}
-func collectAssetResources(p *project.Project, requireContent bool, allowed map[string]struct{}) ([]assetResource, error) {
+func collectAssetResources(p *project.Project, requireContent bool, allowed map[string]struct{}, musicAssets *preparedMusicAssets) ([]assetResource, error) {
var hakResources []assetResource
assetCreatedAt, err := collectGitAssetCreationTimes(p)
if err != nil {
@@ -674,6 +680,11 @@ func collectAssetResources(p *project.Project, requireContent bool, allowed map[
assetsRelPath = filepath.ToSlash(assetsRelPath)
for _, rel := range p.Inventory.AssetFiles {
+ if musicAssets != nil {
+ if _, skip := musicAssets.SkipSourceRel[filepath.ToSlash(rel)]; skip {
+ continue
+ }
+ }
if len(allowed) > 0 {
if _, ok := allowed[filepath.ToSlash(rel)]; !ok {
continue
@@ -701,6 +712,16 @@ func collectAssetResources(p *project.Project, requireContent bool, allowed map[
ContentID: resourceInfo.ContentID,
})
}
+ if musicAssets != nil {
+ for _, asset := range musicAssets.Generated {
+ if len(allowed) > 0 {
+ if _, ok := allowed[filepath.ToSlash(asset.Rel)]; !ok {
+ continue
+ }
+ }
+ hakResources = append(hakResources, asset)
+ }
+ }
slices.SortFunc(hakResources, func(a, b assetResource) int {
return compareResourceKeys(a.Resource, b.Resource)
@@ -1572,7 +1593,11 @@ func plannedModuleHAKOrder(p *project.Project) ([]string, error) {
return append([]string(nil), manifest.ModuleHAKs...), nil
}
- assetResources, err := collectAssetResources(p, false, nil)
+ musicAssets, err := prepareMusicAssets(p)
+ if err != nil {
+ return nil, err
+ }
+ assetResources, err := collectAssetResources(p, false, nil, musicAssets)
if err != nil {
return nil, err
}
diff --git a/internal/pipeline/music.go b/internal/pipeline/music.go
new file mode 100644
index 0000000..ef60f97
--- /dev/null
+++ b/internal/pipeline/music.go
@@ -0,0 +1,870 @@
+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
+}
+
+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.BuildDir(), ".cache", "music")
+
+ 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.BuildDir(), "credits")
+ 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 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)
+ candidate := strings.TrimRight(stem[:max(0, maxMusicStemLen-len(suffix))], "_") + 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
+}
diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go
index 14ca75b..915fcb2 100644
--- a/internal/pipeline/pipeline_test.go
+++ b/internal/pipeline/pipeline_test.go
@@ -2083,7 +2083,7 @@ func TestPlanHAKChunksPutsNewestAssetsInLastChunk(t *testing.T) {
t.Fatalf("scan: %v", err)
}
- assets, err := collectAssetResources(p, false, nil)
+ assets, err := collectAssetResources(p, false, nil, nil)
if err != nil {
t.Fatalf("collect asset resources: %v", err)
}
@@ -2579,3 +2579,107 @@ func TestBuildHAKsFailsForDuplicateResourcesInSameHAK(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
}
+
+func TestBuildHAKsConvertsMusicSourcesAndWritesCreditsArtifacts(t *testing.T) {
+ root := t.TempDir()
+ mustMkdir(t, filepath.Join(root, "assets", "envi", "music", "westgate"))
+ mustMkdir(t, filepath.Join(root, "build"))
+
+ mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{
+ "module": {
+ "name": "Test Assets",
+ "resref": "testassets"
+ },
+ "paths": {
+ "assets": "assets",
+ "build": "build"
+ },
+ "music": {
+ "prefixes": {
+ "envi/music/westgate": "mus_wg_"
+ }
+ },
+ "haks": [
+ {
+ "name": "envi",
+ "priority": 1,
+ "max_bytes": 1048576,
+ "split": false,
+ "include": ["envi/**"]
+ }
+ ]
+}
+`)
+
+ mustWriteFile(t, filepath.Join(root, "assets", "envi", "music", "westgate", "AleandAnecdotes.mp3"), "source-mp3")
+ mustWriteFile(t, filepath.Join(root, "assets", "envi", "music", "westgate", "CREDITS.md"), "# NWN Music Pack Credits\n\n| Artist | Title | Output File | Original File | Album | Date | License / Copyright | Notes |\n|---|---|---|---|---|---|---|---|\n| Darren Curtis | Ale and Anecdotes | `mus_wg_andnc.bmu` | `AleandAnecdotes.mp3` | Darren's Commercially Free Music | | Darren Curtis Music | |\n")
+
+ ffprobePath := filepath.Join(root, "ffprobe")
+ mustWriteFile(t, ffprobePath, "#!/bin/sh\nprintf '%s\\n' '{\"format\":{\"tags\":{\"artist\":\"Unknown\",\"title\":\"Broken Metadata\"}}}'\n")
+ if err := os.Chmod(ffprobePath, 0o755); err != nil {
+ t.Fatalf("chmod ffprobe: %v", err)
+ }
+
+ ffmpegPath := filepath.Join(root, "ffmpeg")
+ mustWriteFile(t, ffmpegPath, "#!/bin/sh\nfor last; do :; done\nprintf 'bmu-data' > \"$last\"\n")
+ if err := os.Chmod(ffmpegPath, 0o755); err != nil {
+ t.Fatalf("chmod ffmpeg: %v", err)
+ }
+
+ t.Setenv("SOW_FFMPEG", ffmpegPath)
+ t.Setenv("SOW_FFPROBE", ffprobePath)
+
+ p, err := project.Load(root)
+ if err != nil {
+ t.Fatalf("load project: %v", err)
+ }
+ if err := p.ValidateLayout(); err != nil {
+ t.Fatalf("validate layout: %v", err)
+ }
+ if err := p.Scan(); err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+
+ result, err := BuildHAKs(p)
+ if err != nil {
+ t.Fatalf("build haks: %v", err)
+ }
+ if len(result.CreditsArtifactPaths) == 0 {
+ t.Fatal("expected credits artifacts to be written")
+ }
+
+ manifestRaw, err := os.ReadFile(filepath.Join(root, "build", "haks.json"))
+ if err != nil {
+ t.Fatalf("read manifest: %v", err)
+ }
+ if !strings.Contains(string(manifestRaw), "envi/music/westgate/mus_wg_andnc.bmu") {
+ t.Fatalf("expected converted bmu in manifest, got %s", string(manifestRaw))
+ }
+ if strings.Contains(string(manifestRaw), "AleandAnecdotes.mp3") {
+ t.Fatalf("did not expect source mp3 in manifest, got %s", string(manifestRaw))
+ }
+
+ creditsRaw, err := os.ReadFile(filepath.Join(root, "build", "credits", "envi", "music", "westgate", "CREDITS.md"))
+ if err != nil {
+ t.Fatalf("read generated credits: %v", err)
+ }
+ creditsText := string(creditsRaw)
+ if !strings.Contains(creditsText, "Darren Curtis") || !strings.Contains(creditsText, "mus_wg_andnc.bmu") {
+ t.Fatalf("unexpected generated credits contents: %s", creditsText)
+ }
+ if strings.Contains(creditsText, "Broken Metadata") {
+ t.Fatalf("manual credits overlay should override bad metadata: %s", creditsText)
+ }
+
+ inventoryRaw, err := os.ReadFile(filepath.Join(root, "build", "credits", "credits.json"))
+ if err != nil {
+ t.Fatalf("read credits inventory: %v", err)
+ }
+ inventoryText := string(inventoryRaw)
+ if !strings.Contains(inventoryText, "assets/envi/music/westgate/CREDITS.md") {
+ t.Fatalf("expected authored credits source in inventory: %s", inventoryText)
+ }
+ if !strings.Contains(inventoryText, "build/credits/envi/music/westgate/CREDITS.md") {
+ t.Fatalf("expected generated credits source in inventory: %s", inventoryText)
+ }
+}
diff --git a/internal/project/project.go b/internal/project/project.go
index 4e41195..ac3e6a7 100644
--- a/internal/project/project.go
+++ b/internal/project/project.go
@@ -19,7 +19,7 @@ var SourceExtensions = []string{
}
var AssetExtensions = []string{
- ".2da", ".bik", ".bmp", ".dds", ".dwk", ".gr2", ".itp", ".jpg", ".lod", ".lyt", ".mdb", ".mdl", ".mdx", ".mtr", ".plt", ".png", ".pwk", ".set", ".shd", ".tga", ".txi", ".uti", ".vis", ".wav", ".wlk", ".wok", ".xml",
+ ".2da", ".bik", ".bmp", ".bmu", ".dds", ".dwk", ".gr2", ".itp", ".jpg", ".lod", ".lyt", ".mdb", ".mdl", ".mdx", ".mp3", ".mtr", ".ogg", ".plt", ".png", ".pwk", ".set", ".shd", ".tga", ".txi", ".uti", ".vis", ".wav", ".wlk", ".wok", ".xml",
}
type Project struct {
@@ -32,6 +32,7 @@ type Config struct {
Module ModuleConfig `json:"module"`
Paths PathConfig `json:"paths"`
HAKs []HAKConfig `json:"haks"`
+ Music MusicConfig `json:"music"`
TopData TopDataConfig `json:"topdata"`
Extract ExtractConfig `json:"extract"`
Autogen AutogenConfig `json:"autogen"`
@@ -59,6 +60,10 @@ type HAKConfig struct {
Include []string `json:"include"`
}
+type MusicConfig struct {
+ Prefixes map[string]string `json:"prefixes"`
+}
+
type TopDataConfig struct {
Source string `json:"source"`
Build string `json:"build"`
diff --git a/internal/validator/validator.go b/internal/validator/validator.go
index 0e8a4b9..d27f80c 100644
--- a/internal/validator/validator.go
+++ b/internal/validator/validator.go
@@ -117,7 +117,7 @@ func ValidateProject(p *project.Project) Report {
}
for _, rel := range p.Inventory.AssetFiles {
- if hasUppercaseResourceName(rel) {
+ if hasUppercaseResourceName(rel) && !isMusicSourceAsset(rel) {
report.add(rel, "resource filenames should be lowercase", SeverityWarning)
}
base := strings.ToLower(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel)))
@@ -519,6 +519,19 @@ func hasAsset(name string, extensions []string, assets map[string]string) bool {
return false
}
+func isMusicSourceAsset(rel string) bool {
+ rel = filepath.ToSlash(strings.TrimSpace(rel))
+ if rel != "envi/music" && !strings.HasPrefix(rel, "envi/music/") {
+ return false
+ }
+ switch strings.ToLower(filepath.Ext(rel)) {
+ case ".mp3", ".ogg":
+ return true
+ default:
+ return false
+ }
+}
+
func isBuiltinScript(name string) bool {
lower := strings.ToLower(name)
for _, prefix := range builtinScriptPrefixes {