Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Sources/PalmierPro/Agent/Tools/ToolDefinitions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
)
Expand Down Expand Up @@ -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"],
],
Expand All @@ -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."],
]
)
),
Expand Down
9 changes: 9 additions & 0 deletions Sources/PalmierPro/Agent/Tools/ToolExecutor+Captions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Foundation
extension ToolExecutor {
private static let addCaptionsAllowedKeys: Set<String> = [
"clipIds", "fontName", "fontSize", "color", "centerX", "centerY", "textCase", "censorProfanity", "language",
"outlineColor", "outlineWidth",
]

func addCaptions(_ editor: EditorViewModel, _ args: [String: Any]) async throws -> ToolResult {
Expand All @@ -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") {
Expand Down
34 changes: 24 additions & 10 deletions Sources/PalmierPro/Agent/Tools/ToolExecutor+Clips.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,15 @@ fileprivate struct SetClipPropertiesInput: DecodableToolArgs {
let fontSize: Double?
let color: String?
let alignment: String?
let outlineColor: String?

static let allowedKeys: Set<String> = [
"clipIds",
"durationFrames", "trimStartFrame", "trimEndFrame", "speed",
"volume", "opacity",
"transform",
"content", "fontName", "fontSize", "color", "alignment",
"outlineColor", "outlineWidth",
]

var hasAnyProperty: Bool {
Expand All @@ -73,6 +75,7 @@ fileprivate struct SetClipPropertiesInput: DecodableToolArgs {
|| transform != nil
|| content != nil || fontName != nil || fontSize != nil
|| color != nil || alignment != nil
|| outlineColor != nil
}
}

Expand Down Expand Up @@ -414,12 +417,13 @@ extension ToolExecutor {

// MARK: set_clip_properties

private static let textOnlyKeys: Set<String> = ["content", "fontName", "fontSize", "color", "alignment"]
private static let textOnlyKeys: Set<String> = ["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 {
Expand All @@ -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] = [:]
Expand All @@ -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()
Expand Down Expand Up @@ -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: ", "))")")
Expand All @@ -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
)
Expand All @@ -532,6 +541,8 @@ extension ToolExecutor {
fontSize: Double?,
color: TextStyle.RGBA?,
alignment: TextStyle.Alignment?,
outlineColor: TextStyle.RGBA?,
outlineWidth: Double?,
clipId: String,
editor: EditorViewModel
) -> [String] {
Expand Down Expand Up @@ -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
}
}
Expand Down
9 changes: 9 additions & 0 deletions Sources/PalmierPro/Agent/Tools/ToolExecutor+Texts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ extension ToolExecutor {
private static let addTextsAllowedKeys: Set<String> = [
"trackIndex", "startFrame", "durationFrames", "content",
"transform", "fontName", "fontSize", "color", "alignment",
"outlineColor", "outlineWidth",
]

private func parseAddTextTransform(
Expand Down Expand Up @@ -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],
Expand Down
36 changes: 36 additions & 0 deletions Sources/PalmierPro/Inspector/Tabs/TextTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ struct TextTab: View {
InspectorSection("Typography") {
fontRow
sizeSlider
outlineRow
outlineWidthRow
}
InspectorSection("Appearance") {
colorRow
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions Sources/PalmierPro/Models/TextLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}
}
16 changes: 15 additions & 1 deletion Sources/PalmierPro/Models/TextStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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.
Expand All @@ -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
}
}

Expand All @@ -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))
Expand Down Expand Up @@ -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
}
}
Expand Down
33 changes: 29 additions & 4 deletions Sources/PalmierPro/Preview/TextLayerController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down