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 {