Music Pipeline Implementation
This commit is contained in:
@@ -304,6 +304,9 @@ func runBuildHAKs(ctx context) error {
|
|||||||
if result.Manifest != "" {
|
if result.Manifest != "" {
|
||||||
fmt.Fprintf(ctx.stdout, "hak manifest: %s\n", 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 {
|
if len(result.AutogenManifestPaths) > 0 {
|
||||||
fmt.Fprintf(ctx.stdout, "autogen manifests: %d\n", len(result.AutogenManifestPaths))
|
fmt.Fprintf(ctx.stdout, "autogen manifests: %d\n", len(result.AutogenManifestPaths))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ var extensionTypes = map[string]uint16{
|
|||||||
"wav": 0x0004,
|
"wav": 0x0004,
|
||||||
"plt": 0x0006,
|
"plt": 0x0006,
|
||||||
"ini": 0x0007,
|
"ini": 0x0007,
|
||||||
|
"bmu": 0x0008,
|
||||||
"txt": 0x000A,
|
"txt": 0x000A,
|
||||||
"mdl": 0x07D2,
|
"mdl": 0x07D2,
|
||||||
"nss": 0x07D9,
|
"nss": 0x07D9,
|
||||||
@@ -130,6 +131,7 @@ var typeExtensions = map[uint16]string{
|
|||||||
0x0004: "wav",
|
0x0004: "wav",
|
||||||
0x0006: "plt",
|
0x0006: "plt",
|
||||||
0x0007: "ini",
|
0x0007: "ini",
|
||||||
|
0x0008: "bmu",
|
||||||
0x000A: "txt",
|
0x000A: "txt",
|
||||||
0x07D2: "mdl",
|
0x07D2: "mdl",
|
||||||
0x07D9: "nss",
|
0x07D9: "nss",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type BuildResult struct {
|
|||||||
HAKPaths []string
|
HAKPaths []string
|
||||||
Manifest string
|
Manifest string
|
||||||
AutogenManifestPaths []string
|
AutogenManifestPaths []string
|
||||||
|
CreditsArtifactPaths []string
|
||||||
Resources int
|
Resources int
|
||||||
HAKAssets int
|
HAKAssets int
|
||||||
TopPackageHAK string
|
TopPackageHAK string
|
||||||
@@ -192,7 +193,11 @@ func planOrBuildHAKs(p *project.Project, progress ProgressFunc, writeArchives bo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return BuildResult{}, err
|
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 {
|
if err != nil {
|
||||||
return BuildResult{}, err
|
return BuildResult{}, err
|
||||||
}
|
}
|
||||||
@@ -209,6 +214,7 @@ func planOrBuildHAKs(p *project.Project, progress ProgressFunc, writeArchives bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
result := BuildResult{HAKAssets: len(assetResources)}
|
result := BuildResult{HAKAssets: len(assetResources)}
|
||||||
|
result.CreditsArtifactPaths = append(result.CreditsArtifactPaths, musicAssets.Artifacts...)
|
||||||
if err := writeAutogenManifestOutputs(progress, autogenManifests); err != nil {
|
if err := writeAutogenManifestOutputs(progress, autogenManifests); err != nil {
|
||||||
return BuildResult{}, err
|
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
|
var hakResources []assetResource
|
||||||
assetCreatedAt, err := collectGitAssetCreationTimes(p)
|
assetCreatedAt, err := collectGitAssetCreationTimes(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -674,6 +680,11 @@ func collectAssetResources(p *project.Project, requireContent bool, allowed map[
|
|||||||
assetsRelPath = filepath.ToSlash(assetsRelPath)
|
assetsRelPath = filepath.ToSlash(assetsRelPath)
|
||||||
|
|
||||||
for _, rel := range p.Inventory.AssetFiles {
|
for _, rel := range p.Inventory.AssetFiles {
|
||||||
|
if musicAssets != nil {
|
||||||
|
if _, skip := musicAssets.SkipSourceRel[filepath.ToSlash(rel)]; skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(allowed) > 0 {
|
if len(allowed) > 0 {
|
||||||
if _, ok := allowed[filepath.ToSlash(rel)]; !ok {
|
if _, ok := allowed[filepath.ToSlash(rel)]; !ok {
|
||||||
continue
|
continue
|
||||||
@@ -701,6 +712,16 @@ func collectAssetResources(p *project.Project, requireContent bool, allowed map[
|
|||||||
ContentID: resourceInfo.ContentID,
|
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 {
|
slices.SortFunc(hakResources, func(a, b assetResource) int {
|
||||||
return compareResourceKeys(a.Resource, b.Resource)
|
return compareResourceKeys(a.Resource, b.Resource)
|
||||||
@@ -1572,7 +1593,11 @@ func plannedModuleHAKOrder(p *project.Project) ([]string, error) {
|
|||||||
return append([]string(nil), manifest.ModuleHAKs...), nil
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "<br>", "\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", "<br>")
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -2083,7 +2083,7 @@ func TestPlanHAKChunksPutsNewestAssetsInLastChunk(t *testing.T) {
|
|||||||
t.Fatalf("scan: %v", err)
|
t.Fatalf("scan: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
assets, err := collectAssetResources(p, false, nil)
|
assets, err := collectAssetResources(p, false, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("collect asset resources: %v", err)
|
t.Fatalf("collect asset resources: %v", err)
|
||||||
}
|
}
|
||||||
@@ -2579,3 +2579,107 @@ func TestBuildHAKsFailsForDuplicateResourcesInSameHAK(t *testing.T) {
|
|||||||
t.Fatalf("unexpected error: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ var SourceExtensions = []string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var AssetExtensions = []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 {
|
type Project struct {
|
||||||
@@ -32,6 +32,7 @@ type Config struct {
|
|||||||
Module ModuleConfig `json:"module"`
|
Module ModuleConfig `json:"module"`
|
||||||
Paths PathConfig `json:"paths"`
|
Paths PathConfig `json:"paths"`
|
||||||
HAKs []HAKConfig `json:"haks"`
|
HAKs []HAKConfig `json:"haks"`
|
||||||
|
Music MusicConfig `json:"music"`
|
||||||
TopData TopDataConfig `json:"topdata"`
|
TopData TopDataConfig `json:"topdata"`
|
||||||
Extract ExtractConfig `json:"extract"`
|
Extract ExtractConfig `json:"extract"`
|
||||||
Autogen AutogenConfig `json:"autogen"`
|
Autogen AutogenConfig `json:"autogen"`
|
||||||
@@ -59,6 +60,10 @@ type HAKConfig struct {
|
|||||||
Include []string `json:"include"`
|
Include []string `json:"include"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MusicConfig struct {
|
||||||
|
Prefixes map[string]string `json:"prefixes"`
|
||||||
|
}
|
||||||
|
|
||||||
type TopDataConfig struct {
|
type TopDataConfig struct {
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Build string `json:"build"`
|
Build string `json:"build"`
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ func ValidateProject(p *project.Project) Report {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, rel := range p.Inventory.AssetFiles {
|
for _, rel := range p.Inventory.AssetFiles {
|
||||||
if hasUppercaseResourceName(rel) {
|
if hasUppercaseResourceName(rel) && !isMusicSourceAsset(rel) {
|
||||||
report.add(rel, "resource filenames should be lowercase", SeverityWarning)
|
report.add(rel, "resource filenames should be lowercase", SeverityWarning)
|
||||||
}
|
}
|
||||||
base := strings.ToLower(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel)))
|
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
|
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 {
|
func isBuiltinScript(name string) bool {
|
||||||
lower := strings.ToLower(name)
|
lower := strings.ToLower(name)
|
||||||
for _, prefix := range builtinScriptPrefixes {
|
for _, prefix := range builtinScriptPrefixes {
|
||||||
|
|||||||
Reference in New Issue
Block a user