Remove external music generation handoff
This commit is contained in:
@@ -1,141 +0,0 @@
|
||||
import MusicAssistantCore
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
struct SunoHandoffSheet: View {
|
||||
let payload: SunoHandoffPayload
|
||||
let onComplete: (SunoFieldFillReport) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var report: SunoFieldFillReport?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Send to Suno")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
|
||||
Divider()
|
||||
|
||||
SunoCreateWebView(payload: payload) { report in
|
||||
self.report = report
|
||||
onComplete(report)
|
||||
}
|
||||
|
||||
if let report {
|
||||
HStack {
|
||||
Label(statusText(for: report), systemImage: statusIcon(for: report))
|
||||
.font(.caption)
|
||||
.foregroundStyle(statusColor(for: report))
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 900, minHeight: 650)
|
||||
}
|
||||
|
||||
private func statusText(for report: SunoFieldFillReport) -> String {
|
||||
report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
|
||||
? "Some fields need manual copying."
|
||||
: "Suno fields are ready."
|
||||
}
|
||||
|
||||
private func statusIcon(for report: SunoFieldFillReport) -> String {
|
||||
report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
|
||||
? "exclamationmark.triangle.fill"
|
||||
: "checkmark.circle.fill"
|
||||
}
|
||||
|
||||
private func statusColor(for report: SunoFieldFillReport) -> Color {
|
||||
report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
|
||||
? .orange
|
||||
: .green
|
||||
}
|
||||
}
|
||||
|
||||
private struct SunoCreateWebView: NSViewRepresentable {
|
||||
let payload: SunoHandoffPayload
|
||||
let onReport: (SunoFieldFillReport) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(payload: payload, onReport: onReport)
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> WKWebView {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.websiteDataStore = .default()
|
||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
webView.navigationDelegate = context.coordinator
|
||||
webView.load(URLRequest(url: SunoHandoffDestination.createPageURL))
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateNSView(_ webView: WKWebView, context: Context) {}
|
||||
|
||||
final class Coordinator: NSObject, WKNavigationDelegate {
|
||||
let payload: SunoHandoffPayload
|
||||
let onReport: (SunoFieldFillReport) -> Void
|
||||
|
||||
init(payload: SunoHandoffPayload, onReport: @escaping (SunoFieldFillReport) -> Void) {
|
||||
self.payload = payload
|
||||
self.onReport = onReport
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
webView.evaluateJavaScript(fillScript()) { value, _ in
|
||||
guard let json = value as? String,
|
||||
let data = json.data(using: .utf8),
|
||||
let values = try? JSONSerialization.jsonObject(with: data) as? [String: Bool] else {
|
||||
self.onReport(.copyRequired(for: SunoHandoffField.allCases, reason: "Suno fields could not be inspected."))
|
||||
return
|
||||
}
|
||||
|
||||
let results: [SunoHandoffField: SunoFieldFillResult] = Dictionary(
|
||||
uniqueKeysWithValues: SunoHandoffField.allCases.map { field in
|
||||
(field, values[field.rawValue] == true
|
||||
? SunoFieldFillResult.filled
|
||||
: SunoFieldFillResult.copyRequired("The \(field.displayName) field was not found."))
|
||||
}
|
||||
)
|
||||
self.onReport(SunoFieldFillReport(results: results))
|
||||
}
|
||||
}
|
||||
|
||||
private func fillScript() -> String {
|
||||
let payloadData = try! JSONEncoder().encode(payload)
|
||||
let payloadJSON = String(data: payloadData, encoding: .utf8)!
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "'", with: "\\'")
|
||||
|
||||
return """
|
||||
(() => {
|
||||
const payload = JSON.parse('\(payloadJSON)');
|
||||
const fields = { title: false, lyrics: false, stylePrompt: false };
|
||||
const custom = [...document.querySelectorAll('button,[role="button"]')].find(e => /custom/i.test(e.innerText));
|
||||
if (custom) custom.click();
|
||||
const candidates = [...document.querySelectorAll('input,textarea,[contenteditable="true"]')];
|
||||
const label = e => [e.getAttribute('aria-label'), e.getAttribute('placeholder'), e.name, e.id].filter(Boolean).join(' ');
|
||||
const set = (terms, value, key) => {
|
||||
const element = candidates.find(e => terms.some(term => label(e).toLowerCase().includes(term)));
|
||||
if (!element || !value) return;
|
||||
if (element.isContentEditable) element.textContent = value; else element.value = value;
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
fields[key] = true;
|
||||
};
|
||||
set(['title'], payload.title, 'title');
|
||||
set(['lyric'], payload.lyrics, 'lyrics');
|
||||
set(['style','prompt'], payload.stylePrompt, 'stylePrompt');
|
||||
return JSON.stringify(fields);
|
||||
})();
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import AppKit
|
||||
import MusicAssistantCore
|
||||
import SwiftUI
|
||||
|
||||
@@ -7,8 +6,6 @@ struct FinalReviewView: View {
|
||||
|
||||
@State private var newFieldKey = ""
|
||||
@State private var newFieldValue = ""
|
||||
@State private var handoffStatus: HandoffStatus?
|
||||
@State private var sunoPayload: SunoHandoffPayload?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@@ -34,7 +31,7 @@ struct FinalReviewView: View {
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Text("Suno fields")
|
||||
Text("Final song output")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
@@ -46,31 +43,11 @@ struct FinalReviewView: View {
|
||||
}
|
||||
.disabled(project.sunoOutput != nil)
|
||||
|
||||
Button(action: sendToSuno) {
|
||||
Label("Send to Suno", systemImage: "arrow.up.forward.app")
|
||||
}
|
||||
}
|
||||
|
||||
if let handoffStatus {
|
||||
Label(handoffStatus.title, systemImage: handoffStatus.systemImage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(handoffStatus.foregroundStyle)
|
||||
}
|
||||
|
||||
if let fieldsToCopy, let output = project.sunoOutput {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(fieldsToCopy) { field in
|
||||
Button("Copy \(field.displayName)") {
|
||||
copy(field, from: output)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextField("Suno lyrics", text: sunoLyricsBinding, axis: .vertical)
|
||||
TextField("Lyrics", text: sunoLyricsBinding, axis: .vertical)
|
||||
.lineLimit(4...10)
|
||||
TextField("Suno style prompt", text: sunoStylePromptBinding, axis: .vertical)
|
||||
TextField("Style prompt", text: sunoStylePromptBinding, axis: .vertical)
|
||||
.lineLimit(3...8)
|
||||
|
||||
if !additionalFieldKeys.isEmpty {
|
||||
@@ -110,16 +87,6 @@ struct FinalReviewView: View {
|
||||
.help("Add field")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { sunoPayload != nil },
|
||||
set: { if !$0 { sunoPayload = nil } }
|
||||
)) {
|
||||
if let sunoPayload {
|
||||
SunoHandoffSheet(payload: sunoPayload) { report in
|
||||
handoffStatus = .fieldFill(report)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var additionalFieldKeys: [String] {
|
||||
@@ -200,95 +167,6 @@ struct FinalReviewView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func sendToSuno() {
|
||||
ensureSunoOutput()
|
||||
|
||||
guard let output = project.sunoOutput else { return }
|
||||
|
||||
do {
|
||||
try CompiledSunoOutputValidator().validate(
|
||||
CompiledSunoOutput(lyricsText: output.lyricsText, stylePrompt: output.stylePrompt)
|
||||
)
|
||||
} catch let validationError as LocalizedError {
|
||||
handoffStatus = .failed(validationError.errorDescription ?? "Unable to prepare the Suno handoff.")
|
||||
return
|
||||
} catch {
|
||||
handoffStatus = .failed("Unable to prepare the Suno handoff.")
|
||||
return
|
||||
}
|
||||
|
||||
sunoPayload = SunoHandoffPayload(
|
||||
title: project.title,
|
||||
lyrics: output.lyricsText,
|
||||
stylePrompt: output.stylePrompt
|
||||
)
|
||||
}
|
||||
|
||||
private var fieldsToCopy: [SunoHandoffField]? {
|
||||
guard case let .fieldFill(report) = handoffStatus else { return nil }
|
||||
let fields = SunoHandoffField.allCases.filter {
|
||||
if case .copyRequired = report.results[$0] { return true }
|
||||
return false
|
||||
}
|
||||
return fields.isEmpty ? nil : fields
|
||||
}
|
||||
|
||||
private func copy(_ field: SunoHandoffField, from output: SunoOutput) {
|
||||
let value: String
|
||||
switch field {
|
||||
case .title:
|
||||
value = project.title
|
||||
case .lyrics:
|
||||
value = output.lyricsText
|
||||
case .stylePrompt:
|
||||
value = output.stylePrompt
|
||||
}
|
||||
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(value, forType: .string)
|
||||
}
|
||||
}
|
||||
|
||||
private enum HandoffStatus: Equatable {
|
||||
case fieldFill(SunoFieldFillReport)
|
||||
case failed(String)
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case let .fieldFill(report):
|
||||
let fieldsToCopy = report.results.compactMap { field, result in
|
||||
if case .copyRequired = result { return field.displayName }
|
||||
return nil
|
||||
}
|
||||
return fieldsToCopy.isEmpty
|
||||
? "Suno fields filled in your browser."
|
||||
: "Copy these fields into Suno: \(fieldsToCopy.joined(separator: ", "))."
|
||||
case let .failed(message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case let .fieldFill(report):
|
||||
return report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
|
||||
? "exclamationmark.triangle.fill"
|
||||
: "checkmark.circle.fill"
|
||||
case .failed:
|
||||
return "exclamationmark.triangle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var foregroundStyle: Color {
|
||||
switch self {
|
||||
case let .fieldFill(report):
|
||||
return report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
|
||||
? .orange
|
||||
: .green
|
||||
case .failed:
|
||||
return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@@ -6,6 +6,6 @@ public struct ProductBoundarySummary: Equatable, Sendable {
|
||||
}
|
||||
|
||||
public static let initial = ProductBoundarySummary(
|
||||
description: "Create and refine song project concepts, then prepare lyrics and style instructions for Suno handoff without starting generation."
|
||||
description: "Create and refine song project concepts, then prepare final lyrics and style instructions."
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public protocol SunoHandoffService: Sendable {
|
||||
func handOff(_ output: CompiledSunoOutput) async throws
|
||||
}
|
||||
|
||||
public enum SunoHandoffDestination {
|
||||
public static let createPageURL = URL(string: "https://suno.com/create")!
|
||||
}
|
||||
|
||||
public struct SunoHandoffPayload: Codable, Equatable, Sendable {
|
||||
public let title: String
|
||||
public let lyrics: String
|
||||
public let stylePrompt: String
|
||||
|
||||
public init(title: String, lyrics: String, stylePrompt: String) {
|
||||
self.title = title
|
||||
self.lyrics = lyrics
|
||||
self.stylePrompt = stylePrompt
|
||||
}
|
||||
|
||||
public func value(for field: SunoHandoffField) -> String {
|
||||
switch field {
|
||||
case .title:
|
||||
return title
|
||||
case .lyrics:
|
||||
return lyrics
|
||||
case .stylePrompt:
|
||||
return stylePrompt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum SunoHandoffField: String, CaseIterable, Equatable, Identifiable, Sendable {
|
||||
case title
|
||||
case lyrics
|
||||
case stylePrompt
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .title:
|
||||
return "Title"
|
||||
case .lyrics:
|
||||
return "Lyrics"
|
||||
case .stylePrompt:
|
||||
return "Style prompt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum SunoFieldFillResult: Equatable, Sendable {
|
||||
case filled
|
||||
case copyRequired(String)
|
||||
}
|
||||
|
||||
public struct SunoFieldFillReport: Equatable, Sendable {
|
||||
public let results: [SunoHandoffField: SunoFieldFillResult]
|
||||
|
||||
public init(results: [SunoHandoffField: SunoFieldFillResult]) {
|
||||
self.results = results
|
||||
}
|
||||
|
||||
public static func copyRequired(for fields: [SunoHandoffField], reason: String) -> SunoFieldFillReport {
|
||||
SunoFieldFillReport(results: Dictionary(uniqueKeysWithValues: fields.map { ($0, .copyRequired(reason)) }))
|
||||
}
|
||||
}
|
||||
|
||||
public protocol SunoFieldFilling: Sendable {
|
||||
func fill(_ payload: SunoHandoffPayload) async -> SunoFieldFillReport
|
||||
}
|
||||
@@ -10,7 +10,7 @@ public enum SunoOutputValidationError: Error, Equatable, LocalizedError, Sendabl
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingContent:
|
||||
return "Add lyrics or a style prompt before sending the song to Suno."
|
||||
return "Add lyrics or a style prompt before finalizing the song output."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user