525 lines
16 KiB
Go
525 lines
16 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
|
|
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/music"
|
|
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
|
)
|
|
|
|
type preparedMusicAssets struct {
|
|
Generated []assetResource
|
|
SkipSourceRel map[string]struct{}
|
|
Artifacts []string
|
|
TempRoots []string
|
|
Summary CreditsRefreshSummary
|
|
}
|
|
|
|
type MusicBuildOptions struct {
|
|
DatasetIDs []string
|
|
Write bool
|
|
}
|
|
|
|
type musicPrepareOptions struct {
|
|
datasetIDs []string
|
|
write bool
|
|
}
|
|
|
|
type musicSourceGroup struct {
|
|
DatasetID string
|
|
Dataset project.EffectiveMusicDataset
|
|
SourceDir string
|
|
OutputDir string
|
|
Sources []string
|
|
}
|
|
|
|
type musicCreditGroup struct {
|
|
CreditsRoot string
|
|
SourceDir string
|
|
Entries []music.CreditsEntry
|
|
}
|
|
|
|
func musicRootPath(p *project.Project, path string) string {
|
|
if strings.TrimSpace(path) == "" {
|
|
return p.Root
|
|
}
|
|
if filepath.IsAbs(path) {
|
|
return path
|
|
}
|
|
return filepath.Join(p.Root, filepath.FromSlash(path))
|
|
}
|
|
|
|
func effectiveMusicDatasets(p *project.Project, onlyIDs []string) (map[string]project.EffectiveMusicDataset, error) {
|
|
effective := p.EffectiveConfig()
|
|
wanted := map[string]struct{}{}
|
|
for _, id := range onlyIDs {
|
|
id = strings.TrimSpace(id)
|
|
if id != "" {
|
|
wanted[id] = struct{}{}
|
|
}
|
|
}
|
|
datasets := map[string]project.EffectiveMusicDataset{}
|
|
for id, dataset := range effective.Music.Datasets {
|
|
if len(wanted) > 0 {
|
|
if _, ok := wanted[id]; !ok {
|
|
continue
|
|
}
|
|
delete(wanted, id)
|
|
}
|
|
datasets[id] = dataset
|
|
}
|
|
if len(wanted) > 0 {
|
|
unknown := make([]string, 0, len(wanted))
|
|
for id := range wanted {
|
|
unknown = append(unknown, id)
|
|
}
|
|
sort.Strings(unknown)
|
|
return nil, fmt.Errorf("unknown music dataset(s): %s", strings.Join(unknown, ", "))
|
|
}
|
|
if len(datasets) == 0 && len(wanted) == 0 {
|
|
for _, rel := range p.Inventory.AssetFiles {
|
|
ext := strings.ToLower(filepath.Ext(rel))
|
|
if !music.PathIsUnder("envi/music", rel) {
|
|
continue
|
|
}
|
|
if _, ok := extensionSet(effective.Music.ConvertExtensions)[ext]; ok {
|
|
datasets["legacy_envi_music"] = project.EffectiveMusicDataset{
|
|
Source: "envi/music",
|
|
Output: "envi/music",
|
|
StageRoot: effective.Music.StageRoot,
|
|
CreditsRoot: effective.Music.CreditsRoot,
|
|
NamingScheme: effective.Music.NamingScheme,
|
|
OutputExtension: effective.Music.OutputExtension,
|
|
MaxStemLength: effective.Music.MaxStemLength,
|
|
PackageMode: "hak_asset",
|
|
ConvertExtensions: effective.Music.ConvertExtensions,
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return datasets, nil
|
|
}
|
|
|
|
func extensionSet(values []string) map[string]struct{} {
|
|
out := make(map[string]struct{}, len(values))
|
|
for _, value := range values {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if value != "" {
|
|
out[value] = struct{}{}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func datasetForMusicSource(datasets map[string]project.EffectiveMusicDataset, rel, ext string) (string, project.EffectiveMusicDataset, bool) {
|
|
bestID := ""
|
|
var best project.EffectiveMusicDataset
|
|
bestLen := -1
|
|
for id, dataset := range datasets {
|
|
if _, ok := extensionSet(dataset.ConvertExtensions)[ext]; !ok {
|
|
continue
|
|
}
|
|
if !music.PathIsUnder(dataset.Source, rel) {
|
|
continue
|
|
}
|
|
if len(dataset.Source) > bestLen {
|
|
bestID = id
|
|
best = dataset
|
|
bestLen = len(dataset.Source)
|
|
}
|
|
}
|
|
return bestID, best, bestID != ""
|
|
}
|
|
|
|
func plannedMusicAsset(outputRel, sourceRel string) (assetResource, error) {
|
|
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(outputRel)), ".")
|
|
resourceType, ok := erf.HAKResourceTypeForExtension(extension)
|
|
if !ok {
|
|
return assetResource{}, fmt.Errorf("unsupported generated music resource extension %q", filepath.Ext(outputRel))
|
|
}
|
|
name := strings.ToLower(strings.TrimSuffix(filepath.Base(outputRel), filepath.Ext(outputRel)))
|
|
return assetResource{
|
|
Rel: outputRel,
|
|
Resource: erf.Resource{
|
|
Name: name,
|
|
Type: resourceType,
|
|
},
|
|
ContentID: "music-plan:" + filepath.ToSlash(sourceRel),
|
|
}, nil
|
|
}
|
|
|
|
func BuildMusic(p *project.Project, opts MusicBuildOptions) (BuildResult, error) {
|
|
prepared, err := prepareMusicAssetsWithOptions(p, musicPrepareOptions{datasetIDs: opts.DatasetIDs, write: opts.Write})
|
|
if err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
if err := cleanupPreparedMusicAssets(prepared); err != nil {
|
|
return BuildResult{}, err
|
|
}
|
|
return BuildResult{
|
|
CreditsArtifactPaths: prepared.Artifacts,
|
|
CreditsSummary: prepared.Summary,
|
|
HAKAssets: len(prepared.Generated),
|
|
}, nil
|
|
}
|
|
|
|
func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) {
|
|
return prepareMusicAssetsWithOptions(p, musicPrepareOptions{write: true})
|
|
}
|
|
|
|
func prepareMusicAssetsWithOptions(p *project.Project, opts musicPrepareOptions) (*preparedMusicAssets, error) {
|
|
usedNames := make(map[string]struct{})
|
|
groupsByKey := make(map[string]*musicSourceGroup)
|
|
datasets, err := effectiveMusicDatasets(p, opts.datasetIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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 datasetID, dataset, ok := datasetForMusicSource(datasets, rel, ext); ok {
|
|
sourceDir := filepath.ToSlash(filepath.Dir(rel))
|
|
relDir, err := filepath.Rel(filepath.FromSlash(dataset.Source), filepath.FromSlash(sourceDir))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve music source directory %s: %w", sourceDir, err)
|
|
}
|
|
relDir = filepath.ToSlash(relDir)
|
|
outputDir := dataset.Output
|
|
if relDir != "." && relDir != "" {
|
|
outputDir = filepath.ToSlash(filepath.Join(outputDir, filepath.FromSlash(relDir)))
|
|
}
|
|
key := datasetID + "\x00" + sourceDir
|
|
group := groupsByKey[key]
|
|
if group == nil {
|
|
group = &musicSourceGroup{
|
|
DatasetID: datasetID,
|
|
Dataset: dataset,
|
|
SourceDir: sourceDir,
|
|
OutputDir: outputDir,
|
|
}
|
|
groupsByKey[key] = group
|
|
}
|
|
group.Sources = append(group.Sources, rel)
|
|
continue
|
|
}
|
|
if name != "" {
|
|
usedNames[name] = struct{}{}
|
|
}
|
|
}
|
|
|
|
result := &preparedMusicAssets{
|
|
SkipSourceRel: make(map[string]struct{}),
|
|
}
|
|
if len(groupsByKey) == 0 {
|
|
if !opts.write {
|
|
return result, nil
|
|
}
|
|
writeResult, err := writeCreditsArtifacts(p, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.Artifacts = writeResult.Artifacts
|
|
result.Summary = writeResult.Summary
|
|
return result, nil
|
|
}
|
|
|
|
groups := make([]*musicSourceGroup, 0, len(groupsByKey))
|
|
for _, group := range groupsByKey {
|
|
sort.Strings(group.Sources)
|
|
groups = append(groups, group)
|
|
}
|
|
sort.Slice(groups, func(i, j int) bool {
|
|
if groups[i].SourceDir != groups[j].SourceDir {
|
|
return groups[i].SourceDir < groups[j].SourceDir
|
|
}
|
|
return groups[i].DatasetID < groups[j].DatasetID
|
|
})
|
|
|
|
creditGroups := make([]musicCreditGroup, 0, len(groups))
|
|
for _, group := range groups {
|
|
result.Summary.ScannedDirs = append(result.Summary.ScannedDirs, group.SourceDir)
|
|
result.Summary.TrackCount += len(group.Sources)
|
|
}
|
|
ffmpegPath := ""
|
|
ffprobePath := ""
|
|
if opts.write {
|
|
var err error
|
|
ffmpegPath, err = music.ResolveFFmpegWithConfig(p.MusicFFmpegPath())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ffprobePath, err = music.ResolveFFprobeWithConfig(p.MusicFFprobePath())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
for _, group := range groups {
|
|
stageRoot := musicRootPath(p, group.Dataset.StageRoot)
|
|
result.TempRoots = append(result.TempRoots, stageRoot)
|
|
prefix := music.SanitizePrefix(group.Dataset.Prefix)
|
|
overlayPath := filepath.Join(p.AssetsDir(), filepath.FromSlash(group.SourceDir), p.EffectiveConfig().Music.CreditsOverlay)
|
|
overlay, err := music.ParseCreditsOverlay(overlayPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse credits overlay %s: %w", overlayPath, err)
|
|
}
|
|
|
|
entries := make([]music.CreditsEntry, 0, len(group.Sources))
|
|
for _, rel := range group.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 := music.ValidateManualOutputFileWithRules(outputFile, group.Dataset.OutputExtension, group.Dataset.MaxStemLength); err != nil {
|
|
return nil, fmt.Errorf("manual credits output for %s: %w", rel, err)
|
|
}
|
|
if err := music.ReserveName(strings.TrimSuffix(outputFile, filepath.Ext(outputFile)), usedNames); err != nil {
|
|
return nil, fmt.Errorf("manual credits output for %s: %w", rel, err)
|
|
}
|
|
default:
|
|
stem, err := music.GenerateStemWithMax(base, prefix, group.Dataset.MaxStemLength, usedNames)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate music name for %s: %w", rel, err)
|
|
}
|
|
outputFile = stem + group.Dataset.OutputExtension
|
|
}
|
|
outputRel := filepath.ToSlash(filepath.Join(group.OutputDir, outputFile))
|
|
result.Summary.Mappings = append(result.Summary.Mappings, MusicFileMapping{
|
|
Source: rel,
|
|
Output: outputRel,
|
|
})
|
|
result.SkipSourceRel[rel] = struct{}{}
|
|
|
|
if !opts.write {
|
|
if group.Dataset.PackageMode == "hak_asset" {
|
|
asset, err := plannedMusicAsset(outputRel, rel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.Generated = append(result.Generated, asset)
|
|
}
|
|
continue
|
|
}
|
|
|
|
metadata, err := music.ProbeMetadata(ffprobePath, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("probe music metadata for %s: %w", rel, err)
|
|
}
|
|
entry := music.CreditsEntry{
|
|
Artist: metadata.Artist,
|
|
Title: music.FirstNonEmpty(metadata.Title, strings.TrimSuffix(base, filepath.Ext(base))),
|
|
OutputFile: outputFile,
|
|
OriginalFile: base,
|
|
Album: metadata.Album,
|
|
Date: metadata.Date,
|
|
Rights: music.FirstNonEmpty(metadata.Rights, music.JoinNonEmpty("; ", metadata.License, metadata.Copyright)),
|
|
Notes: music.FirstNonEmpty(metadata.Notes, metadata.Comment),
|
|
}
|
|
music.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 := music.ConvertSource(ffmpegPath, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel)), stagePath); err != nil {
|
|
return nil, fmt.Errorf("convert music %s: %w", rel, err)
|
|
}
|
|
if group.Dataset.PackageMode == "hak_asset" {
|
|
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,
|
|
})
|
|
}
|
|
entries = append(entries, entry)
|
|
}
|
|
if opts.write {
|
|
if err := music.ValidateOverlayEntries(group.SourceDir, overlay, entries); err != nil {
|
|
return nil, err
|
|
}
|
|
creditGroups = append(creditGroups, musicCreditGroup{
|
|
CreditsRoot: group.Dataset.CreditsRoot,
|
|
SourceDir: group.SourceDir,
|
|
Entries: entries,
|
|
})
|
|
}
|
|
}
|
|
|
|
if !opts.write {
|
|
sort.Slice(result.Generated, func(i, j int) bool {
|
|
return compareResourceKeys(result.Generated[i].Resource, result.Generated[j].Resource) < 0
|
|
})
|
|
return result, nil
|
|
}
|
|
|
|
writeResult, err := writeCreditsArtifacts(p, creditGroups)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
writeResult.Summary.ScannedDirs = append(writeResult.Summary.ScannedDirs, result.Summary.ScannedDirs...)
|
|
writeResult.Summary.TrackCount = result.Summary.TrackCount
|
|
result.Artifacts = writeResult.Artifacts
|
|
result.Summary = writeResult.Summary
|
|
sort.Slice(result.Generated, func(i, j int) bool {
|
|
return compareResourceKeys(result.Generated[i].Resource, result.Generated[j].Resource) < 0
|
|
})
|
|
return result, nil
|
|
}
|
|
|
|
type creditsArtifactWriteResult struct {
|
|
Artifacts []string
|
|
Summary CreditsRefreshSummary
|
|
}
|
|
|
|
func writeCreditsArtifacts(p *project.Project, generated []musicCreditGroup) (creditsArtifactWriteResult, error) {
|
|
defaultCreditsRoot := p.CreditsDir()
|
|
sources := make([]music.CreditsSource, 0)
|
|
desiredByRoot := map[string]map[string][]byte{}
|
|
summary := CreditsRefreshSummary{}
|
|
if generated != nil {
|
|
sort.Slice(generated, func(i, j int) bool {
|
|
if generated[i].SourceDir != generated[j].SourceDir {
|
|
return generated[i].SourceDir < generated[j].SourceDir
|
|
}
|
|
return generated[i].CreditsRoot < generated[j].CreditsRoot
|
|
})
|
|
for _, group := range generated {
|
|
creditsRoot := musicRootPath(p, group.CreditsRoot)
|
|
if desiredByRoot[creditsRoot] == nil {
|
|
desiredByRoot[creditsRoot] = map[string][]byte{}
|
|
}
|
|
entries := append([]music.CreditsEntry(nil), group.Entries...)
|
|
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(group.SourceDir), p.EffectiveConfig().Music.CreditsOverlay)
|
|
desiredByRoot[creditsRoot][outputPath] = []byte(music.RenderCreditsMarkdown(entries))
|
|
summary.GeneratedPaths = append(summary.GeneratedPaths, outputPath)
|
|
for _, entry := range entries {
|
|
summary.Mappings = append(summary.Mappings, MusicFileMapping{
|
|
Source: entry.OriginalFile,
|
|
Output: entry.OutputFile,
|
|
})
|
|
}
|
|
sources = append(sources, music.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 := music.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, music.CreditsSource{
|
|
Path: filepath.ToSlash(rel),
|
|
Kind: "authored",
|
|
Entries: entries,
|
|
})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return creditsArtifactWriteResult{}, err
|
|
}
|
|
|
|
sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path })
|
|
payload, err := json.MarshalIndent(music.CreditsInventory{Sources: sources}, "", " ")
|
|
if err != nil {
|
|
return creditsArtifactWriteResult{}, fmt.Errorf("marshal credits inventory: %w", err)
|
|
}
|
|
payload = append(payload, '\n')
|
|
if len(desiredByRoot) == 0 {
|
|
desiredByRoot[defaultCreditsRoot] = map[string][]byte{}
|
|
}
|
|
artifacts := make([]string, 0)
|
|
changedFiles := 0
|
|
roots := make([]string, 0, len(desiredByRoot))
|
|
for root := range desiredByRoot {
|
|
roots = append(roots, root)
|
|
}
|
|
sort.Strings(roots)
|
|
for _, creditsRoot := range roots {
|
|
desiredFiles := desiredByRoot[creditsRoot]
|
|
inventoryPath := filepath.Join(creditsRoot, "credits.json")
|
|
desiredFiles[inventoryPath] = payload
|
|
if err := os.MkdirAll(creditsRoot, 0o755); err != nil {
|
|
return creditsArtifactWriteResult{}, fmt.Errorf("create credits dir: %w", err)
|
|
}
|
|
changed, err := music.SyncGeneratedCreditsArtifacts(creditsRoot, desiredFiles)
|
|
if err != nil {
|
|
return creditsArtifactWriteResult{}, err
|
|
}
|
|
changedFiles += changed
|
|
for path := range desiredFiles {
|
|
artifacts = append(artifacts, path)
|
|
}
|
|
if summary.InventoryPath == "" {
|
|
summary.InventoryPath = inventoryPath
|
|
}
|
|
}
|
|
sort.Strings(artifacts)
|
|
summary.ArtifactPaths = append(summary.ArtifactPaths, artifacts...)
|
|
summary.ChangedFiles = changedFiles
|
|
return creditsArtifactWriteResult{
|
|
Artifacts: artifacts,
|
|
Summary: summary,
|
|
}, nil
|
|
}
|
|
|
|
func cleanupPreparedMusicAssets(prepared *preparedMusicAssets) error {
|
|
if prepared == nil {
|
|
return nil
|
|
}
|
|
for _, root := range prepared.TempRoots {
|
|
if strings.TrimSpace(root) == "" {
|
|
continue
|
|
}
|
|
if err := os.RemoveAll(root); err != nil {
|
|
return fmt.Errorf("remove temporary music artifact root %s: %w", root, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|