Music Pipeline Configuration Hardening
This commit is contained in:
@@ -114,7 +114,7 @@ music:
|
|||||||
max_stem_length: 16
|
max_stem_length: 16
|
||||||
naming_scheme: nwn_bmu
|
naming_scheme: nwn_bmu
|
||||||
output_extension: .bmu
|
output_extension: .bmu
|
||||||
convert_extensions: [.mp3, .ogg]
|
convert_extensions: [.flac, .m4a, .mp3, .ogg, .wav]
|
||||||
datasets:
|
datasets:
|
||||||
westgate:
|
westgate:
|
||||||
source: envi/music/westgate
|
source: envi/music/westgate
|
||||||
@@ -259,7 +259,7 @@ music:
|
|||||||
max_stem_length: 16
|
max_stem_length: 16
|
||||||
naming_scheme: nwn_bmu
|
naming_scheme: nwn_bmu
|
||||||
output_extension: .bmu
|
output_extension: .bmu
|
||||||
convert_extensions: [.mp3, .ogg]
|
convert_extensions: [.flac, .m4a, .mp3, .ogg, .wav]
|
||||||
```
|
```
|
||||||
|
|
||||||
Music datasets define individual music collections with independent sources and
|
Music datasets define individual music collections with independent sources and
|
||||||
@@ -269,6 +269,12 @@ naming prefixes. `source` is the authored input root under `paths.assets`;
|
|||||||
configured source extensions into generated HAK resources; `package_mode: none`
|
configured source extensions into generated HAK resources; `package_mode: none`
|
||||||
scans and writes credits without adding generated HAK resources. The legacy
|
scans and writes credits without adding generated HAK resources. The legacy
|
||||||
`music.prefixes` shorthand is automatically normalized into datasets.
|
`music.prefixes` shorthand is automatically normalized into datasets.
|
||||||
|
`convert_extensions` is configuration-driven; the built-in default set is
|
||||||
|
`.flac`, `.m4a`, `.mp3`, `.ogg`, and `.wav`.
|
||||||
|
Those extensions are discovered from YAML within configured music dataset source
|
||||||
|
roots, so setting them in `music.defaults.convert_extensions` or a dataset
|
||||||
|
override is sufficient; you do not need a parallel
|
||||||
|
`inventory.asset_extensions` override just to make music scanning see them.
|
||||||
|
|
||||||
Music can also be run directly:
|
Music can also be run directly:
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -15,9 +15,19 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
defaultConvertExtensions = []string{
|
||||||
|
".flac",
|
||||||
|
".m4a",
|
||||||
|
".mp3",
|
||||||
|
".ogg",
|
||||||
|
".wav",
|
||||||
|
}
|
||||||
ConvertExtensions = map[string]struct{}{
|
ConvertExtensions = map[string]struct{}{
|
||||||
".mp3": {},
|
".flac": {},
|
||||||
".ogg": {},
|
".m4a": {},
|
||||||
|
".mp3": {},
|
||||||
|
".ogg": {},
|
||||||
|
".wav": {},
|
||||||
}
|
}
|
||||||
PassthroughExtensions = map[string]struct{}{
|
PassthroughExtensions = map[string]struct{}{
|
||||||
".bmu": {},
|
".bmu": {},
|
||||||
@@ -38,6 +48,10 @@ var (
|
|||||||
VowelRE = regexp.MustCompile(`[aeiou]`)
|
VowelRE = regexp.MustCompile(`[aeiou]`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func DefaultConvertExtensions() []string {
|
||||||
|
return append([]string(nil), defaultConvertExtensions...)
|
||||||
|
}
|
||||||
|
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
|
|||||||
@@ -3447,6 +3447,146 @@ haks:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildHAKsConvertsFLACMusicSourcesByDefault(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mustMkdir(t, filepath.Join(root, "assets", "audio", "westgate"))
|
||||||
|
mustMkdir(t, filepath.Join(root, "build"))
|
||||||
|
|
||||||
|
ffprobePath := filepath.Join(root, "tools", "ffprobe")
|
||||||
|
ffmpegPath := filepath.Join(root, "tools", "ffmpeg")
|
||||||
|
mustMkdir(t, filepath.Dir(ffprobePath))
|
||||||
|
mustWriteFile(t, ffprobePath, "#!/bin/sh\nprintf '%s\\n' '{\"format\":{\"tags\":{\"artist\":\"FLAC Artist\",\"title\":\"FLAC Title\"}}}'\n")
|
||||||
|
mustWriteFile(t, ffmpegPath, "#!/bin/sh\nfor last; do :; done\nprintf 'dataset-bmu' > \"$last\"\n")
|
||||||
|
if err := os.Chmod(ffprobePath, 0o755); err != nil {
|
||||||
|
t.Fatalf("chmod ffprobe: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(ffmpegPath, 0o755); err != nil {
|
||||||
|
t.Fatalf("chmod ffmpeg: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), `
|
||||||
|
module:
|
||||||
|
name: Test Assets
|
||||||
|
resref: testassets
|
||||||
|
paths:
|
||||||
|
assets: assets
|
||||||
|
build: build
|
||||||
|
music:
|
||||||
|
tools:
|
||||||
|
ffmpeg: tools/ffmpeg
|
||||||
|
ffprobe: tools/ffprobe
|
||||||
|
datasets:
|
||||||
|
westgate_audio:
|
||||||
|
source: audio/westgate
|
||||||
|
output: generated/music
|
||||||
|
prefix: wg_
|
||||||
|
haks:
|
||||||
|
- name: music
|
||||||
|
priority: 1
|
||||||
|
max_bytes: 1048576
|
||||||
|
split: false
|
||||||
|
include:
|
||||||
|
- generated/music/**
|
||||||
|
`)
|
||||||
|
mustWriteFile(t, filepath.Join(root, "assets", "audio", "westgate", "Theme Song.flac"), "source-flac")
|
||||||
|
|
||||||
|
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 result.CreditsSummary.TrackCount != 1 {
|
||||||
|
t.Fatalf("expected one music track, got %#v", result.CreditsSummary)
|
||||||
|
}
|
||||||
|
manifestRaw, err := os.ReadFile(filepath.Join(root, "build", "haks.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read manifest: %v", err)
|
||||||
|
}
|
||||||
|
manifestText := string(manifestRaw)
|
||||||
|
if !strings.Contains(manifestText, "generated/music/wg_theme.bmu") {
|
||||||
|
t.Fatalf("expected generated FLAC music in manifest, got:\n%s", manifestText)
|
||||||
|
}
|
||||||
|
if strings.Contains(manifestText, "audio/westgate/Theme Song.flac") {
|
||||||
|
t.Fatalf("did not expect source flac in manifest, got:\n%s", manifestText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHAKsUsesYAMLMusicConvertExtensionsForDiscovery(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mustMkdir(t, filepath.Join(root, "assets", "audio", "westgate"))
|
||||||
|
mustMkdir(t, filepath.Join(root, "build"))
|
||||||
|
|
||||||
|
ffprobePath := filepath.Join(root, "tools", "ffprobe")
|
||||||
|
ffmpegPath := filepath.Join(root, "tools", "ffmpeg")
|
||||||
|
mustMkdir(t, filepath.Dir(ffprobePath))
|
||||||
|
mustWriteFile(t, ffprobePath, "#!/bin/sh\nprintf '%s\\n' '{\"format\":{\"tags\":{\"artist\":\"AAC Artist\",\"title\":\"AAC Title\"}}}'\n")
|
||||||
|
mustWriteFile(t, ffmpegPath, "#!/bin/sh\nfor last; do :; done\nprintf 'dataset-bmu' > \"$last\"\n")
|
||||||
|
if err := os.Chmod(ffprobePath, 0o755); err != nil {
|
||||||
|
t.Fatalf("chmod ffprobe: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(ffmpegPath, 0o755); err != nil {
|
||||||
|
t.Fatalf("chmod ffmpeg: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), `
|
||||||
|
module:
|
||||||
|
name: Test Assets
|
||||||
|
resref: testassets
|
||||||
|
paths:
|
||||||
|
assets: assets
|
||||||
|
build: build
|
||||||
|
music:
|
||||||
|
defaults:
|
||||||
|
convert_extensions:
|
||||||
|
- .aac
|
||||||
|
tools:
|
||||||
|
ffmpeg: tools/ffmpeg
|
||||||
|
ffprobe: tools/ffprobe
|
||||||
|
datasets:
|
||||||
|
westgate_audio:
|
||||||
|
source: audio/westgate
|
||||||
|
output: generated/music
|
||||||
|
prefix: wg_
|
||||||
|
haks:
|
||||||
|
- name: music
|
||||||
|
priority: 1
|
||||||
|
max_bytes: 1048576
|
||||||
|
split: false
|
||||||
|
include:
|
||||||
|
- generated/music/**
|
||||||
|
`)
|
||||||
|
mustWriteFile(t, filepath.Join(root, "assets", "audio", "westgate", "Theme Song.aac"), "source-aac")
|
||||||
|
|
||||||
|
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 result.CreditsSummary.TrackCount != 1 {
|
||||||
|
t.Fatalf("expected one music track discovered through YAML convert_extensions, got %#v", result.CreditsSummary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPlanHAKsDoesNotTranscodeMusicSources(t *testing.T) {
|
func TestPlanHAKsDoesNotTranscodeMusicSources(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mustMkdir(t, filepath.Join(root, "assets", "audio", "westgate"))
|
mustMkdir(t, filepath.Join(root, "assets", "audio", "westgate"))
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/music"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -249,7 +251,7 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
|
|||||||
musicMaxStemLength := 16
|
musicMaxStemLength := 16
|
||||||
musicNamingScheme := DefaultMusicNamingScheme
|
musicNamingScheme := DefaultMusicNamingScheme
|
||||||
musicOutputExtension := DefaultMusicOutputExtension
|
musicOutputExtension := DefaultMusicOutputExtension
|
||||||
musicConvertExtensions := []string{".mp3", ".ogg"}
|
musicConvertExtensions := music.DefaultConvertExtensions()
|
||||||
if d := p.Config.Music.Defaults; d != nil {
|
if d := p.Config.Music.Defaults; d != nil {
|
||||||
if d.StageRoot != "" {
|
if d.StageRoot != "" {
|
||||||
musicStageRoot = expandPathTemplate(d.StageRoot, paths)
|
musicStageRoot = expandPathTemplate(d.StageRoot, paths)
|
||||||
@@ -318,7 +320,6 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
|
|||||||
ConvertExtensions: dsConvertExtensions,
|
ConvertExtensions: dsConvertExtensions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
effective := EffectiveConfig{
|
effective := EffectiveConfig{
|
||||||
ConfigSource: p.ConfigSource,
|
ConfigSource: p.ConfigSource,
|
||||||
Module: p.Config.Module,
|
Module: p.Config.Module,
|
||||||
@@ -428,7 +429,7 @@ func markMissingDefaults(provenance ConfigProvenance) {
|
|||||||
"music.max_stem_length": "16",
|
"music.max_stem_length": "16",
|
||||||
"music.naming_scheme": DefaultMusicNamingScheme,
|
"music.naming_scheme": DefaultMusicNamingScheme,
|
||||||
"music.output_extension": DefaultMusicOutputExtension,
|
"music.output_extension": DefaultMusicOutputExtension,
|
||||||
"music.convert_extensions": ".mp3, .ogg",
|
"music.convert_extensions": strings.Join(music.DefaultConvertExtensions(), ", "),
|
||||||
"topdata.build": DefaultTopDataBuild,
|
"topdata.build": DefaultTopDataBuild,
|
||||||
"topdata.compiled_2da_dir": DefaultTopDataCompiled2DADir,
|
"topdata.compiled_2da_dir": DefaultTopDataCompiled2DADir,
|
||||||
"topdata.compiled_tlk": DefaultTopDataCompiledTLK,
|
"topdata.compiled_tlk": DefaultTopDataCompiledTLK,
|
||||||
|
|||||||
@@ -602,10 +602,46 @@ func (p *Project) Scan() error {
|
|||||||
assetDir := p.AssetsDir()
|
assetDir := p.AssetsDir()
|
||||||
var assetFiles []string
|
var assetFiles []string
|
||||||
if assetDir != "" && filepath.Clean(assetDir) != filepath.Clean(p.Root) {
|
if assetDir != "" && filepath.Clean(assetDir) != filepath.Clean(p.Root) {
|
||||||
|
musicSourceExts := make(map[string]struct{})
|
||||||
|
for _, ext := range effective.Music.ConvertExtensions {
|
||||||
|
musicSourceExts[ext] = struct{}{}
|
||||||
|
}
|
||||||
|
musicSourceRoots := make(map[string]map[string]struct{})
|
||||||
|
for _, ds := range effective.Music.Datasets {
|
||||||
|
if strings.TrimSpace(ds.Source) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exts := musicSourceRoots[ds.Source]
|
||||||
|
if exts == nil {
|
||||||
|
exts = make(map[string]struct{})
|
||||||
|
musicSourceRoots[ds.Source] = exts
|
||||||
|
}
|
||||||
|
for _, ext := range ds.ConvertExtensions {
|
||||||
|
exts[ext] = struct{}{}
|
||||||
|
}
|
||||||
|
for ext := range musicSourceExts {
|
||||||
|
exts[ext] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
var err error
|
var err error
|
||||||
assetFiles, _, err = scanDir(assetDir, func(path string) bool {
|
assetFiles, _, err = scanDir(assetDir, func(path string) bool {
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
rel, err := filepath.Rel(assetDir, path)
|
||||||
return slices.Contains(effective.Inventory.AssetExtensions, ext)
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
ext := strings.ToLower(filepath.Ext(rel))
|
||||||
|
if slices.Contains(effective.Inventory.AssetExtensions, ext) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for root, exts := range musicSourceRoots {
|
||||||
|
if !pathIsUnder(root, rel) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, ok := exts[ext]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
return false
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
@@ -1393,6 +1429,15 @@ func validateOutputFileName(field, name, expectedExt string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pathIsUnder(root, rel string) bool {
|
||||||
|
root = strings.Trim(strings.TrimSpace(filepath.ToSlash(root)), "/")
|
||||||
|
rel = strings.Trim(strings.TrimSpace(filepath.ToSlash(rel)), "/")
|
||||||
|
if root == "" || rel == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return rel == root || strings.HasPrefix(rel, root+"/")
|
||||||
|
}
|
||||||
|
|
||||||
func validateRelativePath(field, path string) []error {
|
func validateRelativePath(field, path string) []error {
|
||||||
var failures []error
|
var failures []error
|
||||||
trimmed := strings.TrimSpace(path)
|
trimmed := strings.TrimSpace(path)
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ paths:
|
|||||||
if got := strings.Join(effective.Validation.RequiredFields["ifo"], ","); got != "Mod_Name" {
|
if got := strings.Join(effective.Validation.RequiredFields["ifo"], ","); got != "Mod_Name" {
|
||||||
t.Fatalf("expected default IFO required fields, got %#v", effective.Validation.RequiredFields)
|
t.Fatalf("expected default IFO required fields, got %#v", effective.Validation.RequiredFields)
|
||||||
}
|
}
|
||||||
|
if got, want := strings.Join(effective.Music.ConvertExtensions, ","), ".flac,.m4a,.mp3,.ogg,.wav"; got != want {
|
||||||
|
t.Fatalf("expected default music convert extensions %q, got %q", want, got)
|
||||||
|
}
|
||||||
if prov := effective.Provenance["paths.build"]; prov.Source != "toolkit default" {
|
if prov := effective.Provenance["paths.build"]; prov.Source != "toolkit default" {
|
||||||
t.Fatalf("expected paths.build toolkit default provenance, got %#v", prov)
|
t.Fatalf("expected paths.build toolkit default provenance, got %#v", prov)
|
||||||
}
|
}
|
||||||
@@ -120,6 +123,53 @@ validation:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScanUsesYAMLMusicConvertExtensionsWithinConfiguredDatasetRoots(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "assets", "audio", "westgate"), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir westgate audio: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "assets", "audio", "other"), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir other audio: %v", err)
|
||||||
|
}
|
||||||
|
writeProjectFile(t, filepath.Join(root, ConfigFile), `
|
||||||
|
module:
|
||||||
|
name: Test Module
|
||||||
|
resref: testmod
|
||||||
|
paths:
|
||||||
|
source: src
|
||||||
|
assets: assets
|
||||||
|
music:
|
||||||
|
defaults:
|
||||||
|
convert_extensions:
|
||||||
|
- .flac
|
||||||
|
datasets:
|
||||||
|
westgate_audio:
|
||||||
|
source: audio/westgate
|
||||||
|
convert_extensions:
|
||||||
|
- .aac
|
||||||
|
`)
|
||||||
|
writeProjectFile(t, filepath.Join(root, "assets", "audio", "westgate", "theme.aac"), "aac")
|
||||||
|
writeProjectFile(t, filepath.Join(root, "assets", "audio", "westgate", "theme.flac"), "flac")
|
||||||
|
writeProjectFile(t, filepath.Join(root, "assets", "audio", "other", "ignored.aac"), "aac")
|
||||||
|
|
||||||
|
proj, err := Load(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := proj.Scan(); err != nil {
|
||||||
|
t.Fatalf("Scan returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !slices.Contains(proj.Inventory.AssetFiles, "audio/westgate/theme.aac") {
|
||||||
|
t.Fatalf("expected configured dataset AAC file to be discovered, got %#v", proj.Inventory.AssetFiles)
|
||||||
|
}
|
||||||
|
if !slices.Contains(proj.Inventory.AssetFiles, "audio/westgate/theme.flac") {
|
||||||
|
t.Fatalf("expected configured dataset FLAC file to be discovered, got %#v", proj.Inventory.AssetFiles)
|
||||||
|
}
|
||||||
|
if slices.Contains(proj.Inventory.AssetFiles, "audio/other/ignored.aac") {
|
||||||
|
t.Fatalf("did not expect non-dataset AAC file to be discovered, got %#v", proj.Inventory.AssetFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEffectiveConfigHonorsConfiguredOutputAndCacheFields(t *testing.T) {
|
func TestEffectiveConfigHonorsConfiguredOutputAndCacheFields(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeProjectFile(t, filepath.Join(root, ConfigFile), `
|
writeProjectFile(t, filepath.Join(root, ConfigFile), `
|
||||||
|
|||||||
Reference in New Issue
Block a user