From 1fa8c7076d4b1e5c5d9c68b8c77dafdfc189e3c6 Mon Sep 17 00:00:00 2001 From: Emmanuel Orozco Date: Fri, 26 Jun 2026 09:02:50 +0200 Subject: [PATCH 1/4] Add text outline model with stroke color, width, and layout padding Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- Sources/PalmierPro/Models/TextLayout.swift | 5 +++-- Sources/PalmierPro/Models/TextStyle.swift | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/Sources/PalmierPro/Models/TextLayout.swift b/Sources/PalmierPro/Models/TextLayout.swift index 243bdabbc..d1b4c9262 100644 --- a/Sources/PalmierPro/Models/TextLayout.swift +++ b/Sources/PalmierPro/Models/TextLayout.swift @@ -26,9 +26,10 @@ enum TextLayout { // +4px slack absorbs canvas→preview scale rounding. let slack: CGFloat = 4 let shadowPad = style.shadow.enabled ? shadowPadding * 2 : 0 + let outlinePad = style.outline.enabled ? CGFloat(style.outline.width) * canvasScale * 2 : 0 return CGSize( - width: max(1, ceil(bounding.width) + shadowPad + slack), - height: max(1, ceil(bounding.height) + slack) + width: max(1, ceil(bounding.width) + shadowPad + outlinePad + slack), + height: max(1, ceil(bounding.height) + outlinePad + slack) ) } } diff --git a/Sources/PalmierPro/Models/TextStyle.swift b/Sources/PalmierPro/Models/TextStyle.swift index 7a8c6cb84..adb808895 100644 --- a/Sources/PalmierPro/Models/TextStyle.swift +++ b/Sources/PalmierPro/Models/TextStyle.swift @@ -7,6 +7,7 @@ struct TextStyle: Codable, Sendable, Equatable { var fontScale: Double = 1.0 var color: RGBA = RGBA() var alignment: Alignment = .center + var outline: Outline = Outline() var shadow: Shadow = Shadow() var background: Fill = Fill(enabled: false, color: RGBA(r: 0, g: 0, b: 0, a: 0.6)) var border: Fill = Fill(enabled: false, color: RGBA(r: 0, g: 0, b: 0, a: 1)) @@ -24,6 +25,13 @@ struct TextStyle: Codable, Sendable, Equatable { var a: Double = 1 } + struct Outline: Codable, Sendable, Equatable { + var enabled: Bool = false + var color: RGBA = RGBA(r: 0, g: 0, b: 0, a: 1) + /// Stroke width in canvas points; scaled at render time. + var width: Double = 3 + } + struct Shadow: Codable, Sendable, Equatable { var enabled: Bool = true /// Alpha doubles as opacity; layer.shadowOpacity stays at 1. @@ -41,7 +49,7 @@ struct TextStyle: Codable, Sendable, Equatable { } private enum CodingKeys: String, CodingKey { - case fontName, fontSize, fontScale, color, alignment, shadow, background, border + case fontName, fontSize, fontScale, color, alignment, outline, shadow, background, border } } @@ -55,6 +63,7 @@ extension TextStyle { fontScale: (try? c.decode(Double.self, forKey: .fontScale)) ?? 1.0, color: (try? c.decode(RGBA.self, forKey: .color)) ?? RGBA(), alignment: (try? c.decode(Alignment.self, forKey: .alignment)) ?? .center, + outline: (try? c.decode(Outline.self, forKey: .outline)) ?? Outline(), shadow: (try? c.decode(Shadow.self, forKey: .shadow)) ?? Shadow(), background: (try? c.decode(Fill.self, forKey: .background)) ?? Fill(enabled: false, color: RGBA(r: 0, g: 0, b: 0, a: 0.6)), border: (try? c.decode(Fill.self, forKey: .border)) ?? Fill(enabled: false, color: RGBA(r: 0, g: 0, b: 0, a: 1)) @@ -141,6 +150,11 @@ extension TextStyle { .paragraphStyle: paragraphStyle, ] if includeColor { attrs[.foregroundColor] = nsColor } + if outline.enabled, size > 0 { + let strokePercent = -(outline.width / Double(size)) * 100 + attrs[.strokeColor] = outline.color.nsColor + attrs[.strokeWidth] = NSNumber(value: strokePercent) + } return attrs } } From 5e39ae07757b230e549ddfac7caab49ecf8d78c2 Mon Sep 17 00:00:00 2001 From: Emmanuel Orozco Date: Fri, 26 Jun 2026 09:02:56 +0200 Subject: [PATCH 2/4] Pre-render outlined text as CGImage for export compatibility Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../Preview/TextLayerController.swift | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/Sources/PalmierPro/Preview/TextLayerController.swift b/Sources/PalmierPro/Preview/TextLayerController.swift index 78479a5a2..7e89775cf 100644 --- a/Sources/PalmierPro/Preview/TextLayerController.swift +++ b/Sources/PalmierPro/Preview/TextLayerController.swift @@ -163,10 +163,35 @@ final class TextLayerController { ) let fontSize = CGFloat(style.fontSize * style.fontScale) * scale - layer.string = NSAttributedString( - string: content, - attributes: style.attributes(size: fontSize) - ) + let attrs = style.attributes(size: fontSize) + let attrStr = NSAttributedString(string: content, attributes: attrs) + + if style.outline.enabled { + let layerSize = layer.bounds.size + let ctScale = layer.contentsScale + let pxW = Int(layerSize.width * ctScale) + let pxH = Int(layerSize.height * ctScale) + if pxW > 0, pxH > 0, + let ctx = CGContext( + data: nil, width: pxW, height: pxH, + bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) { + ctx.scaleBy(x: ctScale, y: ctScale) + ctx.translateBy(x: 0, y: layerSize.height) + ctx.scaleBy(x: 1, y: -1) + attrStr.draw(in: CGRect(origin: .zero, size: layerSize)) + layer.string = nil + layer.contents = ctx.makeImage() + } else { + layer.contents = nil + layer.string = attrStr + } + } else { + layer.contents = nil + layer.string = attrStr + } layer.alignmentMode = style.alignment.caTextAlignmentMode layer.backgroundColor = style.background.enabled ? style.background.color.nsColor.cgColor : nil From cfa96714547d6a67c6992bc49e8799c5dd3bb154 Mon Sep 17 00:00:00 2001 From: Emmanuel Orozco Date: Fri, 26 Jun 2026 09:03:03 +0200 Subject: [PATCH 3/4] Add outline controls to text inspector typography section Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../PalmierPro/Inspector/Tabs/TextTab.swift | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Sources/PalmierPro/Inspector/Tabs/TextTab.swift b/Sources/PalmierPro/Inspector/Tabs/TextTab.swift index e00aa9cdb..315801cfe 100644 --- a/Sources/PalmierPro/Inspector/Tabs/TextTab.swift +++ b/Sources/PalmierPro/Inspector/Tabs/TextTab.swift @@ -12,6 +12,8 @@ struct TextTab: View { InspectorSection("Typography") { fontRow sizeSlider + outlineRow + outlineWidthRow } InspectorSection("Appearance") { colorRow @@ -144,6 +146,40 @@ struct TextTab: View { } } + private var outlineRow: some View { + toggleColorRow( + icon: "a.magnify", + label: "Outline", + enabled: style.outline.enabled, + color: style.outline.color.swiftUIColor, + debounceKey: "outlineColor", + setEnabled: { $0.outline.enabled = $1 }, + setColor: { $0.outline.color = $1 } + ) + } + + @ViewBuilder + private var outlineWidthRow: some View { + if style.outline.enabled { + InspectorRow(icon: "lineweight", label: "Outline Width") { + ScrubbableNumberField( + value: style.outline.width, + range: 0.5...20, + format: "%.1f", + valueSuffix: " pt", + fieldWidth: 50, + onChanged: { newVal in + editor.applyTextStyle(clipId: clip.id) { $0.outline.width = newVal } + editor.fitTextClipToContent(clipId: clip.id) + } + ) { newVal in + editor.commitTextStyle(clipId: clip.id) { $0.outline.width = newVal } + editor.fitTextClipToContent(clipId: clip.id) + } + } + } + } + private var backgroundRow: some View { toggleColorRow( icon: "rectangle.fill", From b8d298a07f906bf7740967a56f9bd5d26dd265d6 Mon Sep 17 00:00:00 2001 From: Emmanuel Orozco Date: Fri, 26 Jun 2026 09:03:11 +0200 Subject: [PATCH 4/4] Expose text outline via MCP and agent tools Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../Agent/Tools/ToolDefinitions.swift | 6 ++++ .../Agent/Tools/ToolExecutor+Captions.swift | 9 +++++ .../Agent/Tools/ToolExecutor+Clips.swift | 34 +++++++++++++------ .../Agent/Tools/ToolExecutor+Texts.swift | 9 +++++ 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/Sources/PalmierPro/Agent/Tools/ToolDefinitions.swift b/Sources/PalmierPro/Agent/Tools/ToolDefinitions.swift index 58c1b48fe..724e7d779 100644 --- a/Sources/PalmierPro/Agent/Tools/ToolDefinitions.swift +++ b/Sources/PalmierPro/Agent/Tools/ToolDefinitions.swift @@ -249,6 +249,8 @@ enum ToolDefinitions { "fontSize": ["type": "number", "description": "Text clips only. Font size in canvas points."], "color": ["type": "string", "description": "Text clips only. Hex '#RRGGBB' or '#RRGGBBAA'."], "alignment": ["type": "string", "enum": ["left", "center", "right"], "description": "Text clips only."], + "outlineColor": ["type": "string", "description": "Text clips only. Hex '#RRGGBB' or '#RRGGBBAA'. Enables per-character outline when set."], + "outlineWidth": ["type": "number", "description": "Text clips only. Outline width in canvas points (default 3). Enables outline when set."], ], required: ["clipIds"] ) @@ -368,6 +370,8 @@ enum ToolDefinitions { "fontSize": ["type": "number", "description": "Font size in canvas points (default 96). On a 1080p canvas ~50 is a caption, ~120 is a title."], "color": ["type": "string", "description": "Hex '#RRGGBB' or '#RRGGBBAA' (default '#FFFFFF')"], "alignment": ["type": "string", "enum": ["left", "center", "right"], "description": "Text alignment (default 'center')"], + "outlineColor": ["type": "string", "description": "Hex '#RRGGBB' or '#RRGGBBAA'. Enables a per-character text outline/stroke when set (default disabled)."], + "outlineWidth": ["type": "number", "description": "Outline width in canvas points (default 3). Enables outline when set."], ], "required": ["startFrame", "durationFrames", "content"], ], @@ -390,6 +394,8 @@ enum ToolDefinitions { "centerY": ["type": "number", "description": "Optional vertical center 0–1 (default 0.9, near the bottom)."], "textCase": ["type": "string", "enum": ["auto", "upper", "lower"], "description": "Optional letter case (default auto)."], "censorProfanity": ["type": "boolean", "description": "Optional. Mask profanity (default false)."], + "outlineColor": ["type": "string", "description": "Optional. Hex '#RRGGBB' or '#RRGGBBAA'. Enables per-character outline on captions when set."], + "outlineWidth": ["type": "number", "description": "Optional. Outline width in canvas points (default 3). Enables outline when set."], ] ) ), diff --git a/Sources/PalmierPro/Agent/Tools/ToolExecutor+Captions.swift b/Sources/PalmierPro/Agent/Tools/ToolExecutor+Captions.swift index ba24de34f..dfdda0a5c 100644 --- a/Sources/PalmierPro/Agent/Tools/ToolExecutor+Captions.swift +++ b/Sources/PalmierPro/Agent/Tools/ToolExecutor+Captions.swift @@ -4,6 +4,7 @@ import Foundation extension ToolExecutor { private static let addCaptionsAllowedKeys: Set = [ "clipIds", "fontName", "fontSize", "color", "centerX", "centerY", "textCase", "censorProfanity", "language", + "outlineColor", "outlineWidth", ] func addCaptions(_ editor: EditorViewModel, _ args: [String: Any]) async throws -> ToolResult { @@ -15,6 +16,14 @@ extension ToolExecutor { if let f = args.string("fontName") { style.fontName = f } if let s = args.double("fontSize") { style.fontSize = s } if let c = try parseColorHex(args.string("color"), path: "add_captions") { style.color = c } + if let oc = try parseColorHex(args.string("outlineColor"), path: "add_captions") { + style.outline.enabled = true + style.outline.color = oc + } + if let ow = args.double("outlineWidth") { + style.outline.enabled = true + style.outline.width = ow + } var locale: Locale? if let lang = args.string("language") { diff --git a/Sources/PalmierPro/Agent/Tools/ToolExecutor+Clips.swift b/Sources/PalmierPro/Agent/Tools/ToolExecutor+Clips.swift index 6935fd03f..9271a6dca 100644 --- a/Sources/PalmierPro/Agent/Tools/ToolExecutor+Clips.swift +++ b/Sources/PalmierPro/Agent/Tools/ToolExecutor+Clips.swift @@ -58,6 +58,7 @@ fileprivate struct SetClipPropertiesInput: DecodableToolArgs { let fontSize: Double? let color: String? let alignment: String? + let outlineColor: String? static let allowedKeys: Set = [ "clipIds", @@ -65,6 +66,7 @@ fileprivate struct SetClipPropertiesInput: DecodableToolArgs { "volume", "opacity", "transform", "content", "fontName", "fontSize", "color", "alignment", + "outlineColor", "outlineWidth", ] var hasAnyProperty: Bool { @@ -73,6 +75,7 @@ fileprivate struct SetClipPropertiesInput: DecodableToolArgs { || transform != nil || content != nil || fontName != nil || fontSize != nil || color != nil || alignment != nil + || outlineColor != nil } } @@ -414,12 +417,13 @@ extension ToolExecutor { // MARK: set_clip_properties - private static let textOnlyKeys: Set = ["content", "fontName", "fontSize", "color", "alignment"] + private static let textOnlyKeys: Set = ["content", "fontName", "fontSize", "color", "alignment", "outlineColor", "outlineWidth"] func setClipProperties(_ editor: EditorViewModel, _ args: [String: Any]) throws -> ToolResult { + let outlineWidth = args.double("outlineWidth") let input: SetClipPropertiesInput = try decodeToolArgs(args, path: "set_clip_properties") guard !input.clipIds.isEmpty else { throw ToolError("Missing or empty 'clipIds' array") } - guard input.hasAnyProperty else { + guard input.hasAnyProperty || outlineWidth != nil else { throw ToolError("set_clip_properties needs at least one property to apply") } if let df = input.durationFrames, df < 1 { @@ -442,6 +446,7 @@ extension ToolExecutor { } let color = try parseColorHex(input.color, path: "set_clip_properties") let alignment = try parseAlignment(input.alignment, path: "set_clip_properties") + let outlineColor = try parseColorHex(input.outlineColor, path: "set_clip_properties") // Resolve clipIds + collect types so we can reject text-only fields on non-text clips. var clipTypes: [String: ClipType] = [:] @@ -450,11 +455,13 @@ extension ToolExecutor { clipTypes[id] = editor.timeline.tracks[loc.trackIndex].clips[loc.clipIndex].mediaType } let textOnlyUsed = [ - input.content != nil ? "content" : nil, - input.fontName != nil ? "fontName" : nil, - input.fontSize != nil ? "fontSize" : nil, - input.color != nil ? "color" : nil, - input.alignment != nil ? "alignment" : nil, + input.content != nil ? "content" : nil, + input.fontName != nil ? "fontName" : nil, + input.fontSize != nil ? "fontSize" : nil, + input.color != nil ? "color" : nil, + input.alignment != nil ? "alignment" : nil, + input.outlineColor != nil ? "outlineColor" : nil, + outlineWidth != nil ? "outlineWidth" : nil, ].compactMap { $0 } if !textOnlyUsed.isEmpty { let nonText = clipTypes.filter { $0.value != .text }.map { $0.key }.sorted() @@ -489,11 +496,12 @@ extension ToolExecutor { fontSize: isText ? input.fontSize : nil, color: isText ? color : nil, alignment: isText ? alignment : nil, + outlineColor: isText ? outlineColor : nil, + outlineWidth: isText ? outlineWidth : nil, clipId: id, editor: editor ) - // Match the inspector: refit bbox after content/font change when caller didn't set a box. - if isText && input.transform == nil && (input.content != nil || input.fontName != nil || input.fontSize != nil) { + if isText && input.transform == nil && (input.content != nil || input.fontName != nil || input.fontSize != nil || outlineWidth != nil) { editor.fitTextClipToContent(clipId: id) } summaries.append("\(id)\(changed.isEmpty ? " (no-op)" : ": \(changed.joined(separator: ", "))")") @@ -508,6 +516,7 @@ extension ToolExecutor { speed: partnerIsText ? nil : input.speed, volume: nil, opacity: nil, transform: nil, content: nil, fontName: nil, fontSize: nil, color: nil, alignment: nil, + outlineColor: nil, outlineWidth: nil, clipId: partnerId, editor: editor ) @@ -532,6 +541,8 @@ extension ToolExecutor { fontSize: Double?, color: TextStyle.RGBA?, alignment: TextStyle.Alignment?, + outlineColor: TextStyle.RGBA?, + outlineWidth: Double?, clipId: String, editor: EditorViewModel ) -> [String] { @@ -572,13 +583,16 @@ extension ToolExecutor { clip.transform = next changed.append("transform") } - if content != nil || fontName != nil || fontSize != nil || color != nil || alignment != nil { + if content != nil || fontName != nil || fontSize != nil || color != nil || alignment != nil + || outlineColor != nil || outlineWidth != nil { if let c = content { clip.textContent = c; changed.append("content") } var style = clip.textStyle ?? TextStyle() if let f = fontName { style.fontName = f; changed.append("fontName") } if let s = fontSize { style.fontSize = s; changed.append("fontSize") } if let c = color { style.color = c; changed.append("color") } if let a = alignment { style.alignment = a; changed.append("alignment") } + if let oc = outlineColor { style.outline.enabled = true; style.outline.color = oc; changed.append("outlineColor") } + if let ow = outlineWidth { style.outline.enabled = true; style.outline.width = ow; changed.append("outlineWidth") } clip.textStyle = style } } diff --git a/Sources/PalmierPro/Agent/Tools/ToolExecutor+Texts.swift b/Sources/PalmierPro/Agent/Tools/ToolExecutor+Texts.swift index 3b97f28e1..0d031d2b4 100644 --- a/Sources/PalmierPro/Agent/Tools/ToolExecutor+Texts.swift +++ b/Sources/PalmierPro/Agent/Tools/ToolExecutor+Texts.swift @@ -13,6 +13,7 @@ extension ToolExecutor { private static let addTextsAllowedKeys: Set = [ "trackIndex", "startFrame", "durationFrames", "content", "transform", "fontName", "fontSize", "color", "alignment", + "outlineColor", "outlineWidth", ] private func parseAddTextTransform( @@ -81,6 +82,14 @@ extension ToolExecutor { if let s = entry.double("fontSize") { style.fontSize = s } if let c = try parseColorHex(entry.string("color"), path: path) { style.color = c } if let a = try parseAlignment(entry.string("alignment"), path: path) { style.alignment = a } + if let oc = try parseColorHex(entry.string("outlineColor"), path: path) { + style.outline.enabled = true + style.outline.color = oc + } + if let ow = entry.double("outlineWidth") { + style.outline.enabled = true + style.outline.width = ow + } let transform = try parseAddTextTransform( entry["transform"] as? [String: Any],