Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow choosing different background/audio files for individual difficulties #30860

Open
wants to merge 23 commits into
base: master
Choose a base branch
from

Conversation

frenzibyte
Copy link
Member

@frenzibyte frenzibyte commented Nov 24, 2024

RFC. I was going to start by following what the title of the issue thread states, which is to just not delete files that are used by other difficulties. However, the underlying discussion thread asks for more than that, and such a change does not really make much sense if the general code still assumes all beatmaps should use one background/audio file, so I took it one step further and changed the whole logic to properly allow difficulties to have their own background/audio.

Preview:

CleanShot.2024-11-27.at.05.36.41-converted.mp4

@Phippe
Copy link

Phippe commented Nov 25, 2024

I would've assumed that there would be something like a default image for all difficulties. Then each difficulty would have the option to overwrite the default image, but also to change back to it.

This would make the case easier where there are a large number of difficulties, but only a few of them have custom backgrounds. If you wanted to change the background for all the difficulties that share the same default background, you would have to do so for each difficulty separately.

Just my two cents though, I'm not a mapper.

@bdach
Copy link
Collaborator

bdach commented Nov 25, 2024

I would've assumed that there would be something like a default image for all difficulties. Then each difficulty would have the option to overwrite the default image, but also to change back to it.

I would also assume so yes.

This looks half-baked to me. There should be a way to replace the background on all difficulties at once, and it should be the default. Replacing on a single difficulty should be the exception. And maybe there should be a dropdown which allows picking from existing backgrounds from all the difficulties of the set.

@peppy
Copy link
Member

peppy commented Nov 25, 2024

Hmm, I dunno. I thought this PR is providing a pretty good UX until we actually have a concept of set level metadata. It meets existing users' expectations.

Adding a "this beatmap overrides the default" is going to be a pain to implement a good UX for. But I guess I'm all ears?

This would make the case easier where there are a large number of difficulties, but only a few of them have custom backgrounds. If you wanted to change the background for all the difficulties that share the same default background, you would have to do so for each difficulty separately.

This seems very edge case.

@bdach
Copy link
Collaborator

bdach commented Nov 25, 2024

Easiest would be to ask user on file selection whether to use it on all difficulties or just the one. Probably fade out the file picker and have 2 buttons in the same popover to choose.

@peppy
Copy link
Member

peppy commented Nov 25, 2024

Kinda the same thing really, just more explicit.

But it would solve my remaining issue with this PR, which is that it is applying the action to both background and audio at the same time (should really be per resource).

@frenzibyte
Copy link
Member Author

But it would solve my remaining issue with this PR, which is that it is applying the action to both background and audio at the same time (should really be per resource).

It is technically doing it per resource if you check the code. Is the frontend not conveying that well?

I would've assumed that there would be something like a default image for all difficulties. Then each difficulty would have the option to overwrite the default image, but also to change back to it.

I would also assume so yes.

This looks half-baked to me. There should be a way to replace the background on all difficulties at once, and it should be the default. Replacing on a single difficulty should be the exception. And maybe there should be a dropdown which allows picking from existing backgrounds from all the difficulties of the set.

The way it was implemented in this PR followed the natural storage of metadata in beatmaps, choosing a background/audio updates the metadata of the current difficulty, and the button changes all other difficulties to update as such.

I've also thought about a dialog after opening the PR but I wasn't sure if it's worth the effort. Now that it's brought up, and the button is already misleading in the idea of "does it update both background and audio or is it per-resource", so I will update the PR as such.

@peppy
Copy link
Member

peppy commented Nov 26, 2024

@frenzibyte to confirm we're all on the same page, I think spaceman is proposing that in the existing file chooser popup, after selecting a file, the file chooser content is replaced with two buttons (in the same popup) which let the mapper select [apply to this difficulty] [apply to all difficulties]. Should only show this if there's more than one difficulty already.

@frenzibyte
Copy link
Member Author

frenzibyte commented Nov 27, 2024

OP updated with preview of the new behaviour. This is ready for review.

Shaved off lots of copypasta so the test actually shows what it's testing.
@peppy peppy self-requested a review November 28, 2024 05:59
@peppy peppy force-pushed the editor-multiple-background-audio-files branch from d85585f to 4d9d5ad Compare November 28, 2024 06:14
{
var popover = new BeatmapFileChooserPopover(handledExtensions, current, chooserPath, beatmapHasMultipleDifficulties);

popover.ApplyToAllDifficulties.ValueChanged += v =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this flow? Why isn't this a BindTo or something more standard?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea was that the popover's bindable is nullable so that whatever the user chooses will always trigger a change in the bindable, but I don't want the general bindable to be nullable as well so there's a value changed event here.

Anyways I can understand why it looks odd, I've rewritten it in 4a1401a

Comment on lines 153 to 154
if (c.NewValue == null)
return;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can this happen?

@@ -72,59 +77,115 @@ private void load()
audioTrackChooser.Current.BindValueChanged(audioTrackChanged);
}

public bool ChangeBackgroundImage(FileInfo source)
public bool ChangeBackgroundImage(FileInfo source, bool applyToAllDifficulties)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get that the logic was split out in master, but now that this is 100x more complex I'd hope that the complex part was shared between both implementations. Is there a reason you didn't do this while writing it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I felt this is still within the "it's fine" area, and that if I tried to make it shared then I'll end up with an extremely weird method that begs for itself to be inlined.

Diff will be something like this:

diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs
index 1ce944b5a4..a02900a204 100644
--- a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs
+++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs
@@ -82,57 +82,12 @@ public bool ChangeBackgroundImage(FileInfo source, bool applyToAllDifficulties)
             if (!source.Exists)
                 return false;
 
-            var set = working.Value.BeatmapSetInfo;
-
-            if (applyToAllDifficulties)
-            {
-                string newFilename = $@"bg{source.Extension}";
-
-                foreach (var beatmapInSet in set.Beatmaps)
-                {
-                    if (set.GetFile(beatmapInSet.Metadata.BackgroundFile) is RealmNamedFileUsage existingFile)
-                        beatmaps.DeleteFile(set, existingFile);
-
-                    if (beatmapInSet.Metadata.BackgroundFile != newFilename)
-                    {
-                        beatmapInSet.Metadata.BackgroundFile = newFilename;
-
-                        if (!beatmapInSet.Equals(working.Value.BeatmapInfo))
-                            beatmaps.Save(beatmapInSet, beatmaps.GetWorkingBeatmap(beatmapInSet).Beatmap);
-                    }
-                }
-            }
-            else
-            {
-                var beatmap = working.Value.BeatmapInfo;
-
-                string[] filenames = set.Files.Select(f => f.Filename).Where(f =>
-                    f.StartsWith(@"bg", StringComparison.OrdinalIgnoreCase) &&
-                    f.EndsWith(source.Extension, StringComparison.OrdinalIgnoreCase)).ToArray();
-
-                string currentFilename = working.Value.Metadata.BackgroundFile;
-
-                var oldFile = set.GetFile(currentFilename);
-                string? newFilename = null;
-
-                if (oldFile != null && set.Beatmaps.Where(b => !b.Equals(beatmap)).All(b => b.Metadata.BackgroundFile != currentFilename))
-                {
-                    beatmaps.DeleteFile(set, oldFile);
-                    newFilename = currentFilename;
-                }
-
-                newFilename ??= NamingUtils.GetNextBestFilename(filenames, $@"bg{source.Extension}");
-                working.Value.Metadata.BackgroundFile = newFilename;
-            }
-
-            using (var stream = source.OpenRead())
-                beatmaps.AddFile(set, stream, working.Value.Metadata.BackgroundFile);
-
-            editorBeatmap.SaveState();
+            changeResource(source, applyToAllDifficulties, @"bg",
+                metadata => metadata.BackgroundFile,
+                (metadata, name) => metadata.BackgroundFile = name);
 
             headerBackground.UpdateBackground();
             editor?.ApplyToBackground(bg => bg.RefreshBackground());
-
             return true;
         }
 
@@ -141,20 +96,34 @@ public bool ChangeAudioTrack(FileInfo source, bool applyToAllDifficulties)
             if (!source.Exists)
                 return false;
 
+            changeResource(source, applyToAllDifficulties, @"audio",
+                metadata => metadata.AudioFile,
+                (metadata, name) => metadata.AudioFile = name);
+
+            music.ReloadCurrentTrack();
+            return true;
+        }
+
+        private void changeResource(FileInfo source, bool applyToAllDifficulties, string baseFilename, Func<BeatmapMetadata, string> readFilename, Action<BeatmapMetadata, string> writeFilename)
+        {
             var set = working.Value.BeatmapSetInfo;
 
+            string newFilename = string.Empty;
+
             if (applyToAllDifficulties)
             {
-                string newFilename = $@"audio{source.Extension}";
+                newFilename = $"{baseFilename}{source.Extension}";
 
                 foreach (var beatmapInSet in set.Beatmaps)
                 {
-                    if (set.GetFile(beatmapInSet.Metadata.AudioFile) is RealmNamedFileUsage existingFile)
+                    string filenameInBeatmap = readFilename(beatmapInSet.Metadata);
+
+                    if (set.GetFile(filenameInBeatmap) is RealmNamedFileUsage existingFile)
                         beatmaps.DeleteFile(set, existingFile);
 
-                    if (beatmapInSet.Metadata.AudioFile != newFilename)
+                    if (filenameInBeatmap != newFilename)
                     {
-                        beatmapInSet.Metadata.AudioFile = newFilename;
+                        writeFilename(beatmapInSet.Metadata, newFilename);
 
                         if (!beatmapInSet.Equals(working.Value.BeatmapInfo))
                             beatmaps.Save(beatmapInSet, beatmaps.GetWorkingBeatmap(beatmapInSet).Beatmap);
@@ -166,31 +135,29 @@ public bool ChangeAudioTrack(FileInfo source, bool applyToAllDifficulties)
                 var beatmap = working.Value.BeatmapInfo;
 
                 string[] filenames = set.Files.Select(f => f.Filename).Where(f =>
-                    f.StartsWith(@"audio", StringComparison.OrdinalIgnoreCase) &&
+                    f.StartsWith(baseFilename, StringComparison.OrdinalIgnoreCase) &&
                     f.EndsWith(source.Extension, StringComparison.OrdinalIgnoreCase)).ToArray();
 
-                string currentFilename = working.Value.Metadata.AudioFile;
+                string currentFilename = readFilename(working.Value.Metadata);
 
                 var oldFile = set.GetFile(currentFilename);
-                string? newFilename = null;
 
-                if (oldFile != null && set.Beatmaps.Where(b => !b.Equals(beatmap)).All(b => b.Metadata.AudioFile != currentFilename))
+                if (oldFile != null && set.Beatmaps.Where(b => !b.Equals(beatmap)).All(b => readFilename(b.Metadata) != currentFilename))
                 {
                     beatmaps.DeleteFile(set, oldFile);
                     newFilename = currentFilename;
                 }
 
-                newFilename ??= NamingUtils.GetNextBestFilename(filenames, $@"audio{source.Extension}");
-                working.Value.Metadata.AudioFile = newFilename;
+                if (string.IsNullOrEmpty(newFilename))
+                    newFilename = NamingUtils.GetNextBestFilename(filenames, $@"{baseFilename}{source.Extension}");
+
+                writeFilename(working.Value.Metadata, newFilename);
             }
 
             using (var stream = source.OpenRead())
-                beatmaps.AddFile(set, stream, working.Value.Metadata.AudioFile);
+                beatmaps.AddFile(set, stream, newFilename);
 
             editorBeatmap.SaveState();
-            music.ReloadCurrentTrack();
-
-            return true;
         }
 
         private void backgroundChanged(ValueChangedEvent<FileInfo?> file)

I'm shaving a good amount of code duplication so I personally wouldn't mind pushing it in, but I just want to make sure this doesn't look over-engineered to you.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's fine as a starting point, I'll review after you push.

@frenzibyte frenzibyte force-pushed the editor-multiple-background-audio-files branch from a9197fa to 489d7a3 Compare November 28, 2024 23:37
@frenzibyte
Copy link
Member Author

frenzibyte commented Nov 28, 2024

In addition to the above comments, I've also looked back to how I was ensuring changes to metadata of other difficulties is being saved and I realised my logic was unnecessarily complicated.

Instead, I've updated the metadata of the other difficulties from beatmap.Value.BeatmapSetInfo (which is the detached beatmap set of the current difficulty), and I've forced a Save call after changing each resource for two reasons:

  1. EditorChangeHandler cannot be aware of changes to metadata of other difficulties, therefore if the user changes resource on a specific difficulty, then switches to another difficulty without saving, the user will not be prompted to save, and when they change the resource on the other difficulty, the state of the previous difficulty will be stale.
  2. If the user presses "Forget all changes" on a save dialog after changing a resource, then the difficulty/beatmap will become corrupted if the metadata was pointing to an old file that has been removed by the change logic.

Changing resources should be a rare operation anyway, so I don't think triggering a save will make anybody sad.

{
Caption = GameplaySettingsStrings.BackgroundHeader,
PlaceholderText = EditorSetupStrings.ClickToSelectBackground,
},
audioTrackChooser = new FormFileSelector(".mp3", ".ogg")
audioTrackChooser = new FormBeatmapFileSelector(beatmapHasMultipleDifficulties, ".mp3", ".ogg")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know that we should be encouraging usage of different tracks in a single beatmap set? Yes I know it happens in practice, but I don't know that we should be exposing that in the client.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still of the thought that we should leave such things up to the ranking criteria. I'd rather have a defined flow for this, because if we don't, users will end up documenting weird and potentially incorrect ways of doing it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well in that case the same thing that applies in #30860 (comment) applies here, because the audio file is also encoded to .osu.

Comment on lines 110 to 121
if (applyToAllDifficulties)
{
newFilename = $"{baseFilename}{source.Extension}";

using (var stream = source.OpenRead())
foreach (var beatmap in set.Beatmaps)
{
if (set.GetFile(readFilename(beatmap.Metadata)) is RealmNamedFileUsage otherExistingFile)
beatmaps.DeleteFile(set, otherExistingFile);

writeFilename(beatmap.Metadata, newFilename);
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic is missing a completely key step.

Recall that a beatmap's background filename is contained in the .osu file for the beatmap. None of this actually mutates the other .osu files. Which means that this breaks immediately on export and re-import; only the difficulty that was active at the point of replacing all backgrounds will have the backgrounds, and the other difficulties will point to dead assets that are no longer there because they were deleted at the point of replace. (I have tested this.)

In other words this logic needs to re-encode every single beatmap in the set to work correctly.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ouch, good catch. Since I'm already triggering a save at the end of the change method I might as well perform a BeatmapManager.Save() of each difficulty in here to ensure it's re-encoded.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be addressed in 8e0f6fc, I don't like the part where I'm re-fetching each difficulty's skin in order to preserve it but can't see any other way around that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Adding a new background/audio file in the editor may remove file which is used in other difficulties
4 participants