v0.2.0: linked axes, clickable checkpoint marks, full variant form, exports, run queue
- ChartXState shared between the main chart and all secondary charts: zooming/panning up top moves every chart, and the hover crosshair is synchronized - secondary headers show the value AT the cursor - Checkpoint annotations on the loss chart (green dashed rules with a drive glyph); clicking one selects that checkpoint in the panel below - New Run form now covers EVERY Forge config field: attention/positions (rope theta/global/scaling, NoPE, head_dim, bias, softcap), norm placement, QAT, the full MoE section (V3 sigmoid/noaux/scaling/shared/ first-k-dense), advanced optimizer (Muon, WSD, betas, forge_save/dtype) - Export menu: chart as 2x PNG (ImageRenderer), metrics as CSV - Run queue: starting a run while one is live persists it as "queued"; the supervisor launches the oldest queued run when the active one exits - trainings never overlap, nothing is lost Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 7 changed files with +254 and −18
modified
ForgeStudio/Charts/TrainingChartView.swift
+66 −5
@@ -8,18 +8,40 @@ | ||
| 8 | 8 | import Charts |
| 9 | 9 | import SwiftUI |
| 10 | 10 | |
| 11 | +// Shared X-window + crosshair state: the main chart drives it, secondary | |
| 12 | +// charts observe it, so zooming/hovering is synchronized everywhere. | |
| 13 | +@Observable | |
| 14 | +final class ChartXState { | |
| 15 | + var visibleLength: Double? // nil = fit all | |
| 16 | + var scrollX: Double = 0 | |
| 17 | + var hoverX: Double? | |
| 18 | +} | |
| 19 | + | |
| 11 | 20 | struct TrainingChartView: View { |
| 12 | 21 | let snapshot: MetricsStore.Snapshot? |
| 13 | 22 | let isLive: Bool |
| 14 | 23 | @Binding var smoothing: Double |
| 15 | 24 | @Binding var logScale: Bool |
| 25 | + var xState: ChartXState | |
| 26 | + var checkpointSteps: [Int] = [] | |
| 27 | + var onCheckpointTap: ((Int) -> Void)? | |
| 16 | 28 | |
| 17 | − @State private var visibleLength: Double? // nil = fit all | |
| 18 | − @State private var scrollX: Double = 0 | |
| 19 | 29 | @State private var followLive = true |
| 20 | − @State private var hoverX: Double? | |
| 21 | 30 | @State private var magnifyBase: Double? |
| 22 | 31 | |
| 32 | + private var visibleLength: Double? { | |
| 33 | + get { xState.visibleLength } | |
| 34 | + nonmutating set { xState.visibleLength = newValue } | |
| 35 | + } | |
| 36 | + private var scrollX: Double { | |
| 37 | + get { xState.scrollX } | |
| 38 | + nonmutating set { xState.scrollX = newValue } | |
| 39 | + } | |
| 40 | + private var hoverX: Double? { | |
| 41 | + get { xState.hoverX } | |
| 42 | + nonmutating set { xState.hoverX = newValue } | |
| 43 | + } | |
| 44 | + | |
| 23 | 45 | private var maxX: Double { snapshot?.train.last?.x ?? 1 } |
| 24 | 46 | private var minX: Double { snapshot?.train.first?.x ?? 0 } |
| 25 | 47 | |
@@ -93,6 +115,16 @@ struct TrainingChartView: View { | ||
| 93 | 115 | .foregroundStyle(.orange) |
| 94 | 116 | } |
| 95 | 117 | } |
| 118 | + ForEach(checkpointSteps, id: \.self) { step in | |
| 119 | + RuleMark(x: .value("ckpt", Double(step))) | |
| 120 | + .foregroundStyle(.green.opacity(0.25)) | |
| 121 | + .lineStyle(.init(lineWidth: 1, dash: [2, 4])) | |
| 122 | + .annotation(position: .top, alignment: .leading) { | |
| 123 | + Image(systemName: "externaldrive.fill") | |
| 124 | + .font(.system(size: 7)) | |
| 125 | + .foregroundStyle(.green.opacity(0.6)) | |
| 126 | + } | |
| 127 | + } | |
| 96 | 128 | if let x = hoverX { |
| 97 | 129 | RuleMark(x: .value("hover", x)) |
| 98 | 130 | .foregroundStyle(.secondary.opacity(0.5)) |
@@ -100,7 +132,10 @@ struct TrainingChartView: View { | ||
| 100 | 132 | } |
| 101 | 133 | } |
| 102 | 134 | .modifier(LogScaleModifier(enabled: logScale)) |
| 103 | − .modifier(ScrollModifier(visibleLength: visibleLength, scrollX: $scrollX)) | |
| 135 | + .modifier(ScrollModifier( | |
| 136 | + visibleLength: visibleLength, | |
| 137 | + scrollX: Binding(get: { xState.scrollX }, | |
| 138 | + set: { xState.scrollX = $0 }))) | |
| 104 | 139 | .chartLegend(.visible) |
| 105 | 140 | .chartOverlay { proxy in |
| 106 | 141 | GeometryReader { geo in |
@@ -120,9 +155,19 @@ struct TrainingChartView: View { | ||
| 120 | 155 | visibleLength = nil |
| 121 | 156 | followLive = true |
| 122 | 157 | } |
| 158 | + .onTapGesture(count: 1) { | |
| 159 | + // Click near a checkpoint rule selects it (M4: | |
| 160 | + // "clickable → jumps to that checkpoint"). | |
| 161 | + guard let x = hoverX, | |
| 162 | + let nearest = checkpointSteps | |
| 163 | + .min(by: { abs(Double($0) - x) < abs(Double($1) - x) }), | |
| 164 | + abs(Double(nearest) - x) < max(4, (maxX - minX) * 0.01) | |
| 165 | + else { return } | |
| 166 | + onCheckpointTap?(nearest) | |
| 167 | + } | |
| 123 | 168 | } |
| 124 | 169 | } |
| 125 | − .onChange(of: scrollX) { old, new in | |
| 170 | + .onChange(of: xState.scrollX) { old, new in | |
| 126 | 171 | // A user-initiated pan while live breaks auto-follow. |
| 127 | 172 | if isLive, followLive, let len = visibleLength, |
| 128 | 173 | abs(new - (maxX - len)) > len * 0.05 { |
@@ -210,6 +255,22 @@ private struct LogScaleModifier: ViewModifier { | ||
| 210 | 255 | } |
| 211 | 256 | } |
| 212 | 257 | |
| 258 | +// Read-only link to the main chart's X window (secondary charts). | |
| 259 | +struct LinkedScrollModifier: ViewModifier { | |
| 260 | + var xState: ChartXState | |
| 261 | + func body(content: Content) -> some View { | |
| 262 | + if let len = xState.visibleLength { | |
| 263 | + content | |
| 264 | + .chartScrollableAxes(.horizontal) | |
| 265 | + .chartXVisibleDomain(length: len) | |
| 266 | + .chartScrollPosition(x: Binding(get: { xState.scrollX }, | |
| 267 | + set: { _ in })) // main chart drives | |
| 268 | + } else { | |
| 269 | + content | |
| 270 | + } | |
| 271 | + } | |
| 272 | +} | |
| 273 | + | |
| 213 | 274 | private struct ScrollModifier: ViewModifier { |
| 214 | 275 | let visibleLength: Double? |
| 215 | 276 | @Binding var scrollX: Double |
modified
ForgeStudio/Services/RunSupervisor.swift
+35 −5
@@ -43,12 +43,14 @@ final class RunSupervisor { | ||
| 43 | 43 | store.upsert(run) |
| 44 | 44 | } |
| 45 | 45 | |
| 46 | + /// Public entry: creates the run immediately. If a training is live the | |
| 47 | + /// run stays `queued` (persisted) and launches automatically when the | |
| 48 | + /// active one exits — trainings never overlap. | |
| 46 | 49 | func start(config: ForgeConfig, datasetPath: String, name: String, |
| 47 | 50 | resumeFrom: String? = nil) async throws { |
| 48 | − guard activeRunID == nil else { throw SupervisorError.busy } | |
| 49 | 51 | let errors = config.validationErrors |
| 50 | 52 | guard errors.isEmpty else { throw SupervisorError.invalidConfig(errors) } |
| 51 | − guard let forge = ForgeBinaryLocator.savedBinaryURL else { | |
| 53 | + guard ForgeBinaryLocator.savedBinaryURL != nil else { | |
| 52 | 54 | throw SupervisorError.forgeNotConfigured |
| 53 | 55 | } |
| 54 | 56 | |
@@ -58,15 +60,42 @@ final class RunSupervisor { | ||
| 58 | 60 | .appendingPathComponent("runs/\(name)-\(stamp)") |
| 59 | 61 | try FileManager.default.createDirectory(at: outDir, |
| 60 | 62 | withIntermediateDirectories: true) |
| 61 | − var run = Run(name: name, createdAt: .now, config: config, | |
| 63 | + let run = Run(name: name, createdAt: .now, config: config, | |
| 62 | 64 | datasetPath: datasetPath, outDirectory: outDir.path, |
| 63 | 65 | resumeCheckpoint: resumeFrom) |
| 64 | 66 | try config.exportJSON().write(to: URL(fileURLWithPath: run.configPath)) |
| 65 | 67 | store.upsert(run) |
| 68 | + if activeRunID == nil { | |
| 69 | + try await launch(run) | |
| 70 | + } // else: stays queued; launchNextQueued() picks it up on exit | |
| 71 | + } | |
| 72 | + | |
| 73 | + private func launchNextQueued() { | |
| 74 | + guard activeRunID == nil, | |
| 75 | + let next = store.runs.filter({ $0.state == .queued }) | |
| 76 | + .min(by: { $0.createdAt < $1.createdAt }) else { return } | |
| 77 | + Task { | |
| 78 | + do { | |
| 79 | + try await launch(next) | |
| 80 | + } catch { | |
| 81 | + var r = next | |
| 82 | + r.failureReason = error.localizedDescription | |
| 83 | + transition(&r, to: .launching) | |
| 84 | + transition(&r, to: .failed) | |
| 85 | + } | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + private func launch(_ queued: Run) async throws { | |
| 90 | + guard activeRunID == nil else { throw SupervisorError.busy } | |
| 91 | + guard let forge = ForgeBinaryLocator.savedBinaryURL else { | |
| 92 | + throw SupervisorError.forgeNotConfigured | |
| 93 | + } | |
| 94 | + var run = queued | |
| 66 | 95 | |
| 67 | − var args = ["train", "--config", run.configPath, "--data", datasetPath, | |
| 96 | + var args = ["train", "--config", run.configPath, "--data", run.datasetPath, | |
| 68 | 97 | "--out", run.outDirectory] |
| 69 | − if let resume = resumeFrom { args += ["--resume", resume] } | |
| 98 | + if let resume = run.resumeCheckpoint { args += ["--resume", resume] } | |
| 70 | 99 | |
| 71 | 100 | transition(&run, to: .launching) |
| 72 | 101 | activeRunID = run.id |
@@ -145,6 +174,7 @@ final class RunSupervisor { | ||
| 145 | 174 | self.activeRunID = nil |
| 146 | 175 | self.runner = nil |
| 147 | 176 | Self.notify(run: r) |
| 177 | + self.launchNextQueued() | |
| 148 | 178 | } |
| 149 | 179 | } |
| 150 | 180 | } |
modified
ForgeStudio/Views/CheckpointsPanel.swift
+2 −2
@@ -6,9 +6,9 @@ import SwiftUI | ||
| 6 | 6 | |
| 7 | 7 | struct CheckpointsPanel: View { |
| 8 | 8 | let run: Run |
| 9 | + @Binding var selected: String? | |
| 9 | 10 | @Environment(AppModel.self) private var app |
| 10 | 11 | @State private var checkpoints: [Checkpoint] = [] |
| 11 | − @State private var selected: String? | |
| 12 | 12 | @State private var prompt = "Once upon a time" |
| 13 | 13 | @State private var temp = 0.8 |
| 14 | 14 | @State private var topK = 40 |
@@ -98,7 +98,7 @@ struct CheckpointsPanel: View { | ||
| 98 | 98 | valLossAtStep: nil) |
| 99 | 99 | } |
| 100 | 100 | .sorted { $0.step > $1.step } |
| 101 | − selected = checkpoints.first?.path | |
| 101 | + if selected == nil { selected = checkpoints.first?.path } | |
| 102 | 102 | } |
| 103 | 103 | |
| 104 | 104 | private var tokenizerPath: String? { |
modified
ForgeStudio/Views/NewRunSheet.swift
+67 −0
@@ -67,6 +67,52 @@ struct NewRunSheet: View { | ||
| 67 | 67 | Toggle("qk_norm", isOn: $config.model.qkNorm) |
| 68 | 68 | intField("sliding_window (0 = full)", $config.model.slidingWindow) |
| 69 | 69 | } |
| 70 | + Section("Attention & positions") { | |
| 71 | + Toggle("use_rope", isOn: $config.model.useRope) | |
| 72 | + doubleField("rope_theta", $config.model.ropeTheta) | |
| 73 | + doubleField("rope_theta_global (0 = idem)", $config.model.ropeThetaGlobal) | |
| 74 | + intField("sliding_global_every (Gemma3: 6)", $config.model.slidingGlobalEvery) | |
| 75 | + intField("nope_every (SmolLM3: 4)", $config.model.nopeEvery) | |
| 76 | + intField("head_dim (0 = auto)", $config.model.headDimOverride) | |
| 77 | + Toggle("attention_bias (Qwen2.5)", isOn: $config.model.attentionBias) | |
| 78 | + doubleField("attn_softcap (Gemma2: 50)", $config.model.attnSoftcap) | |
| 79 | + doubleField("rope_scale_factor (llama3: 32, 0 = off)", | |
| 80 | + $config.model.ropeScaleFactor) | |
| 81 | + if config.model.ropeScaleFactor > 0 { | |
| 82 | + doubleField("rope_scale_low", $config.model.ropeScaleLow) | |
| 83 | + doubleField("rope_scale_high", $config.model.ropeScaleHigh) | |
| 84 | + intField("rope_scale_orig_ctx", $config.model.ropeScaleOrigCtx) | |
| 85 | + } | |
| 86 | + } | |
| 87 | + Section("Normalisation & sorties") { | |
| 88 | + Picker("norm_placement", selection: $config.model.normPlacement) { | |
| 89 | + ForEach(["pre", "post", "sandwich"], id: \.self) { Text($0) } | |
| 90 | + } | |
| 91 | + doubleField("norm_eps", $config.model.normEps) | |
| 92 | + doubleField("final_softcap (Gemma2: 30)", $config.model.finalSoftcap) | |
| 93 | + Toggle("scale_embeddings (Gemma)", isOn: $config.model.scaleEmbeddings) | |
| 94 | + Picker("quant (QAT)", selection: $config.model.quant) { | |
| 95 | + ForEach(["none", "int8", "ternary"], id: \.self) { Text($0) } | |
| 96 | + } | |
| 97 | + } | |
| 98 | + Section("Mixture of Experts") { | |
| 99 | + intField("n_experts (0 = dense)", $config.model.nExperts) | |
| 100 | + if config.model.nExperts > 0 { | |
| 101 | + intField("moe_top_k", $config.model.moeTopK) | |
| 102 | + intField("n_shared_experts (V3: 1)", $config.model.nSharedExperts) | |
| 103 | + Picker("moe_scoring", selection: $config.model.moeScoring) { | |
| 104 | + ForEach(["softmax", "sigmoid"], id: \.self) { Text($0) } | |
| 105 | + } | |
| 106 | + Toggle("moe_norm_topk", isOn: $config.model.moeNormTopk) | |
| 107 | + doubleField("routed_scaling_factor (V3: 2.5)", | |
| 108 | + $config.model.routedScalingFactor) | |
| 109 | + intField("moe_d_ff (0 = d_ff)", $config.model.moeDFf) | |
| 110 | + intField("first_k_dense (V3: 3)", $config.model.firstKDense) | |
| 111 | + doubleField("moe_bias_gamma (noaux, V3: 0.001)", | |
| 112 | + $config.model.moeBiasGamma) | |
| 113 | + doubleField("moe_aux_weight", $config.model.moeAuxWeight) | |
| 114 | + } | |
| 115 | + } | |
| 70 | 116 | Section("Entraînement") { |
| 71 | 117 | doubleField("lr", $config.train.lr) |
| 72 | 118 | intField("max_steps", $config.train.maxSteps) |
@@ -82,6 +128,27 @@ struct NewRunSheet: View { | ||
| 82 | 128 | intField("checkpoint_every", $config.train.checkpointEvery) |
| 83 | 129 | intField("eval_every", $config.train.evalEvery) |
| 84 | 130 | } |
| 131 | + Section("Optimiseur avancé") { | |
| 132 | + if config.train.optimizer == "muon" { | |
| 133 | + doubleField("muon_lr", $config.train.muonLr) | |
| 134 | + doubleField("muon_momentum", $config.train.muonMomentum) | |
| 135 | + } | |
| 136 | + if config.train.schedule == "wsd" { | |
| 137 | + doubleField("wsd_decay_frac", $config.train.wsdDecayFrac) | |
| 138 | + } | |
| 139 | + doubleField("min_lr_ratio", $config.train.minLrRatio) | |
| 140 | + doubleField("beta1", $config.train.beta1) | |
| 141 | + doubleField("beta2", $config.train.beta2) | |
| 142 | + doubleField("weight_decay", $config.train.weightDecay) | |
| 143 | + doubleField("grad_clip (0 = off)", $config.train.gradClip) | |
| 144 | + intField("eval_batches", $config.train.evalBatches) | |
| 145 | + intField("seed", $config.train.seed) | |
| 146 | + Toggle("forge_save (.forge à chaque checkpoint)", | |
| 147 | + isOn: $config.train.forgeSave) | |
| 148 | + Picker("forge_dtype", selection: $config.train.forgeDtype) { | |
| 149 | + ForEach(["f32", "f16", "bf16"], id: \.self) { Text($0) } | |
| 150 | + } | |
| 151 | + } | |
| 85 | 152 | } |
| 86 | 153 | .formStyle(.grouped) |
| 87 | 154 | .frame(minWidth: 380) |
modified
ForgeStudio/Views/RunDetailView.swift
+82 −4
@@ -14,9 +14,25 @@ struct RunDetailView: View { | ||
| 14 | 14 | @State private var logScale = true |
| 15 | 15 | @State private var showConsole = false |
| 16 | 16 | @State private var historicStore: MetricsStore? |
| 17 | + @State private var xState = ChartXState() | |
| 18 | + @State private var checkpointSteps: [Int] = [] | |
| 19 | + @State private var selectedCheckpoint: String? | |
| 17 | 20 | |
| 18 | 21 | private var isLive: Bool { app.supervisor.activeRunID == run.id } |
| 19 | 22 | |
| 23 | + private func scanCheckpoints() { | |
| 24 | + let dir = URL(fileURLWithPath: run.outDirectory) | |
| 25 | + checkpointSteps = ((try? FileManager.default.contentsOfDirectory( | |
| 26 | + at: dir, includingPropertiesForKeys: nil)) ?? []) | |
| 27 | + .compactMap { url -> Int? in | |
| 28 | + let n = url.lastPathComponent | |
| 29 | + guard n.hasPrefix("ckpt_"), n.hasSuffix(".bin"), | |
| 30 | + let s = Int(n.dropFirst(5).dropLast(4)) else { return nil } | |
| 31 | + return s | |
| 32 | + } | |
| 33 | + .sorted() | |
| 34 | + } | |
| 35 | + | |
| 20 | 36 | var body: some View { |
| 21 | 37 | VStack(spacing: 0) { |
| 22 | 38 | StatusStrip(run: run, snapshot: snapshot) |
@@ -24,7 +40,13 @@ struct RunDetailView: View { | ||
| 24 | 40 | ScrollView { |
| 25 | 41 | VStack(alignment: .leading, spacing: 16) { |
| 26 | 42 | TrainingChartView(snapshot: snapshot, isLive: isLive, |
| 27 | − smoothing: $smoothing, logScale: $logScale) | |
| 43 | + smoothing: $smoothing, logScale: $logScale, | |
| 44 | + xState: xState, | |
| 45 | + checkpointSteps: checkpointSteps, | |
| 46 | + onCheckpointTap: { step in | |
| 47 | + selectedCheckpoint = run.outDirectory | |
| 48 | + + String(format: "/ckpt_%06d.bin", step) | |
| 49 | + }) | |
| 28 | 50 | HStack(spacing: 24) { |
| 29 | 51 | secondaryChart(title: "Learning rate", |
| 30 | 52 | series: snapshot?.lr ?? [], format: "%.2e") |
@@ -35,7 +57,7 @@ struct RunDetailView: View { | ||
| 35 | 57 | threshold: run.config.train.gradClip) |
| 36 | 58 | } |
| 37 | 59 | if !run.state.isActive { |
| 38 | − CheckpointsPanel(run: run) | |
| 60 | + CheckpointsPanel(run: run, selected: $selectedCheckpoint) | |
| 39 | 61 | } |
| 40 | 62 | DisclosureGroup("Console (\(app.supervisor.consoleLines.count) lignes)", |
| 41 | 63 | isExpanded: $showConsole) { |
@@ -47,6 +69,15 @@ struct RunDetailView: View { | ||
| 47 | 69 | } |
| 48 | 70 | } |
| 49 | 71 | .toolbar { |
| 72 | + ToolbarItem { | |
| 73 | + Menu { | |
| 74 | + Button("Chart en PNG (2×)…") { exportPNG() } | |
| 75 | + Button("Métriques en CSV…") { exportCSV() } | |
| 76 | + } label: { | |
| 77 | + Label("Exporter", systemImage: "square.and.arrow.up") | |
| 78 | + } | |
| 79 | + .disabled(snapshot == nil) | |
| 80 | + } | |
| 50 | 81 | if isLive { |
| 51 | 82 | ToolbarItem { |
| 52 | 83 | Button(role: .destructive) { |
@@ -58,17 +89,27 @@ struct RunDetailView: View { | ||
| 58 | 89 | } |
| 59 | 90 | } |
| 60 | 91 | } |
| 61 | − .task(id: run.id) { await refreshLoop() } | |
| 92 | + .task(id: run.id) { | |
| 93 | + scanCheckpoints() | |
| 94 | + await refreshLoop() | |
| 95 | + } | |
| 62 | 96 | .navigationTitle(run.name) |
| 63 | 97 | } |
| 64 | 98 | |
| 99 | + // Secondary charts share the main chart's X window and crosshair | |
| 100 | + // (linked axes): zooming/hovering up top moves everything. | |
| 65 | 101 | private func secondaryChart(title: String, series: [Downsampler.XY], |
| 66 | 102 | format: String, threshold: Double? = nil) -> some View { |
| 67 | 103 | VStack(alignment: .leading, spacing: 4) { |
| 68 | 104 | HStack { |
| 69 | 105 | Text(title).font(.caption.weight(.semibold)) |
| 70 | 106 | Spacer() |
| 71 | − if let last = series.last { | |
| 107 | + if let h = xState.hoverX, | |
| 108 | + let near = series.min(by: { abs($0.x - h) < abs($1.x - h) }) { | |
| 109 | + Text(String(format: format, near.y)) | |
| 110 | + .font(.caption.monospacedDigit()) | |
| 111 | + .foregroundStyle(.teal) | |
| 112 | + } else if let last = series.last { | |
| 72 | 113 | Text(String(format: format, last.y)) |
| 73 | 114 | .font(.caption.monospacedDigit()) |
| 74 | 115 | .foregroundStyle(.secondary) |
@@ -84,12 +125,49 @@ struct RunDetailView: View { | ||
| 84 | 125 | .lineStyle(.init(lineWidth: 1, dash: [3, 3])) |
| 85 | 126 | .foregroundStyle(.red.opacity(0.5)) |
| 86 | 127 | } |
| 128 | + if let h = xState.hoverX { | |
| 129 | + RuleMark(x: .value("hover", h)) | |
| 130 | + .foregroundStyle(.secondary.opacity(0.5)) | |
| 131 | + .lineStyle(.init(lineWidth: 1)) | |
| 132 | + } | |
| 87 | 133 | } |
| 88 | 134 | .chartXAxis(.hidden) |
| 135 | + .modifier(LinkedScrollModifier(xState: xState)) | |
| 89 | 136 | .frame(height: 110) |
| 90 | 137 | } |
| 91 | 138 | } |
| 92 | 139 | |
| 140 | + private func exportPNG() { | |
| 141 | + let chart = TrainingChartView(snapshot: snapshot, isLive: false, | |
| 142 | + smoothing: $smoothing, logScale: $logScale, | |
| 143 | + xState: ChartXState(), | |
| 144 | + checkpointSteps: checkpointSteps) | |
| 145 | + .frame(width: 1200, height: 500) | |
| 146 | + .padding() | |
| 147 | + .background(Color(nsColor: .windowBackgroundColor)) | |
| 148 | + let renderer = ImageRenderer(content: chart) | |
| 149 | + renderer.scale = 2.0 | |
| 150 | + guard let image = renderer.nsImage, | |
| 151 | + let tiff = image.tiffRepresentation, | |
| 152 | + let png = NSBitmapImageRep(data: tiff)? | |
| 153 | + .representation(using: .png, properties: [:]) else { return } | |
| 154 | + let panel = NSSavePanel() | |
| 155 | + panel.nameFieldStringValue = "\(run.name)-loss.png" | |
| 156 | + if panel.runModal() == .OK, let url = panel.url { | |
| 157 | + try? png.write(to: url) | |
| 158 | + } | |
| 159 | + } | |
| 160 | + | |
| 161 | + private func exportCSV() { | |
| 162 | + let panel = NSSavePanel() | |
| 163 | + panel.nameFieldStringValue = "\(run.name)-metrics.csv" | |
| 164 | + if panel.runModal() == .OK, let url = panel.url { | |
| 165 | + try? FileManager.default.removeItem(at: url) | |
| 166 | + try? FileManager.default.copyItem( | |
| 167 | + at: URL(fileURLWithPath: run.logCSVPath), to: url) | |
| 168 | + } | |
| 169 | + } | |
| 170 | + | |
| 93 | 171 | private func refreshLoop() async { |
| 94 | 172 | if isLive { |
| 95 | 173 | while !Task.isCancelled && app.supervisor.activeRunID == run.id { |
modified
scripts/notarize.sh
+1 −1
@@ -9,7 +9,7 @@ cd "$(dirname "$0")/.." | ||
| 9 | 9 | IDENTITY="Developer ID Application: Simon-Pierre Boucher (3YM54G49SN)" |
| 10 | 10 | KEYCHAIN_PROFILE="MacLustr-Notarize" |
| 11 | 11 | APP_DIR="dist/ForgeStudio.app" |
| 12 | −DMG_NAME="dist/ForgeStudio-0.1.0.dmg" | |
| 12 | +DMG_NAME="dist/ForgeStudio-0.2.0.dmg" | |
| 13 | 13 | |
| 14 | 14 | ./scripts/package-app.sh release |
| 15 | 15 | |
modified
scripts/package-app.sh
+1 −1
@@ -27,7 +27,7 @@ cat > "$APP_DIR/Contents/Info.plist" <<'PLIST' | ||
| 27 | 27 | <key>CFBundleIdentifier</key><string>ai.spboucher.forge-studio</string> |
| 28 | 28 | <key>CFBundleExecutable</key><string>ForgeStudio</string> |
| 29 | 29 | <key>CFBundleVersion</key><string>1</string> |
| 30 | − <key>CFBundleShortVersionString</key><string>0.1.0</string> | |
| 30 | + <key>CFBundleShortVersionString</key><string>0.2.0</string> | |
| 31 | 31 | <key>CFBundlePackageType</key><string>APPL</string> |
| 32 | 32 | <key>LSMinimumSystemVersion</key><string>14.0</string> |
| 33 | 33 | <key>LSApplicationCategoryType</key><string>public.app-category.developer-tools</string> |
| 34 | 34 | |