맥 일기 (§6.7): - 사이드바에 일기 탭 개방 (AppTab.sidebarCases 맥 필터 제거) - 맥 노트 도구줄: 텍스트 우선 가로 스크롤 + '그리기' 토글(마우스·트랙패드 필기, drawingPolicy anyInput) - 그리기↔배치 상호 배타, 일기 잠금 설정 맥 노출 — 아이패드 경로는 코드 불변(회귀 차단) CSV 가져오기 (§6.8·프리미엄): - IOS/Core/CSVImport.swift: RFC4180 파서·데이터 행 모양 판별(언어 무관)·계획/반영 분리·초 단위 멱등 중복 처리 - 행동 연결: uuid 우선 → 이름+방식 폴백 → 새로 생성(uuid 보존), '측정 중' 행 제외, 추가 전용 - DataImportView: 파일 선택(복수) → 미리 보기 → 확인 → 저장 1회. 무료 한도 우회 방지 위해 프리미엄 게이트 - 자가 테스트 -csvImportTest 20건 ALL PASS, 진행률 자가 테스트 회귀 ALL PASS 문서·현지화·버전: - 도움말(일기 맥·CSV 복원)·프리미엄 기능 행·설정 문구 갱신, en/ja 42키 번역·stale 10키 정리 (missing 0) - CLAUDE.md §1/§6/§6.7/§6.8/§8/§14, Marketing 설명 ko/en/ja·whats-new-1.1, MARKETING_VERSION 1.1(4타깃) 스크린샷 전면 재제작 (ko/en/ja): - 아이폰 10장·아이패드 10장(신규: 기록 목록/주간 타임테이블/일기 양식/다크/목표)·워치 합성 1장 - 시간대 정합: SIMCTL_CHILD_TZ 부팅 주입(Asia/Bangkok) + -marketingTZ 프로브 검증 절차 확립(README) - frame_screenshots.swift: 문구 5종 추가·일기/동기화 문구 맥 반영·renderWatch 합성 추가 (총 63장) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVeduv1eNdXjk1ay4tSgBg
449 lines
24 KiB
Swift
449 lines
24 KiB
Swift
// App Store 홍보 프레임 합성 도구 (macOS에서 `swift Marketing/tools/frame_screenshots.swift` — 저장소 루트 기준)
|
||
// 원본 스크린샷(Marketing/Screenshots/<기기>/<로케일>/NN-*.png)을 읽어
|
||
// 브랜드 배경 + 헤드라인/서브 문구 + 둥근 모서리 카드로 합성해
|
||
// Marketing/Screenshots/framed-<기기>/<로케일>/ 에 같은 캔버스 크기(ASC 규격 그대로)로 저장한다.
|
||
// 워치는 캔버스가 작아 원본을 그대로 쓰므로 대상이 아니다.
|
||
|
||
import AppKit
|
||
|
||
// MARK: 브랜드 컬러 (Shared/Theme.swift와 동일)
|
||
|
||
func color(_ hex: String, _ alpha: CGFloat = 1) -> NSColor {
|
||
var v: UInt64 = 0
|
||
Scanner(string: hex.replacingOccurrences(of: "#", with: "")).scanHexInt64(&v)
|
||
return NSColor(
|
||
red: CGFloat((v >> 16) & 0xFF) / 255,
|
||
green: CGFloat((v >> 8) & 0xFF) / 255,
|
||
blue: CGFloat(v & 0xFF) / 255, alpha: alpha
|
||
)
|
||
}
|
||
|
||
let backgroundTop = color("#FAFAF6")
|
||
let backgroundBottom = color("#E7F0E9") // 은은한 그린 틴트
|
||
let headlineColor = color("#2F6B4F") // Primary Green
|
||
let sublineColor = color("#5C6B60")
|
||
let accentGold = color("#D9A621") // Accent Yellow
|
||
|
||
// MARK: 문구 정의
|
||
|
||
struct Copy { let headline: [String: String]; let subline: [String: String] }
|
||
|
||
let iphoneCopies: [String: Copy] = [
|
||
"01-main": Copy(
|
||
headline: ["ko": "버튼 한 번으로 기록하는 하루", "en": "Track your day with one tap", "ja": "ワンタップで記録する一日"],
|
||
subline: ["ko": "시간 측정도, 횟수 기록도 탭 한 번", "en": "Time or counts — one tap is all it takes", "ja": "時間も回数もタップひとつ"]),
|
||
"02-goals": Copy(
|
||
headline: ["ko": "목표와 다짐으로 꾸준하게", "en": "Goals and quests keep you going", "ja": "目標とクエストで続けられる"],
|
||
subline: ["ko": "하루·주간·월간 진행률과 연속 달성", "en": "Daily, weekly, monthly progress and streaks", "ja": "日・週・月の達成率と継続日数"]),
|
||
"03-timetable": Copy(
|
||
headline: ["ko": "하루가 한눈에 보여요", "en": "Your day at a glance", "ja": "一日がひと目でわかる"],
|
||
subline: ["ko": "기록이 24시간 타임테이블 위에 그려져요", "en": "Records drawn on a 24-hour timetable", "ja": "記録が24時間のタイムテーブルに描かれる"]),
|
||
"04-stats": Copy(
|
||
headline: ["ko": "통계로 보는 나의 일주일", "en": "See your week in charts", "ja": "統計で見る私の一週間"],
|
||
subline: ["ko": "행동별·꼬리표별 차트와 합계·평균", "en": "Charts, totals and averages by action and tag", "ja": "アクション・タグ別のグラフと合計・平均"]),
|
||
"05-widgets": Copy(
|
||
headline: ["ko": "홈 화면에서 바로 시작", "en": "Start right from your Home Screen", "ja": "ホーム画面からすぐ開始"],
|
||
subline: ["ko": "실행 버튼·실시간 타이머·진행률 위젯", "en": "Run buttons, live timers and progress widgets", "ja": "実行ボタン・ライブタイマー・進捗ウィジェット"]),
|
||
"06-bubble": Copy(
|
||
headline: ["ko": "한 손에 잡히는 버블 메뉴", "en": "A bubble menu made for one hand", "ja": "片手に収まるバブルメニュー"],
|
||
subline: ["ko": "유리 구슬 탭 버튼이 엄지 반경에 펼쳐져요", "en": "Glass tab bubbles fan out under your thumb", "ja": "ガラスのタブが親指の届く範囲に広がる"]),
|
||
"08-dark": Copy(
|
||
headline: ["ko": "밤에는 다크 모드로 편안하게", "en": "Easy on the eyes in Dark Mode", "ja": "夜はダークモードで心地よく"],
|
||
subline: ["ko": "라이트·다크·시스템 설정 따름 중 선택", "en": "Light, dark, or match the system", "ja": "ライト・ダーク・システム連動から選択"]),
|
||
"09-records": Copy(
|
||
headline: ["ko": "기록은 남기고, 바로 고치고", "en": "Every record, easy to fix", "ja": "記録は残して、すぐ直せる"],
|
||
subline: ["ko": "메모와 함께 쌓이는 하루의 기록 — 탭해서 수정", "en": "Notes attached — tap any record to edit", "ja": "メモと一緒に積み重なる記録 — タップで修正"]),
|
||
"10-week": Copy(
|
||
headline: ["ko": "일주일의 흐름이 한눈에", "en": "Your week at a glance", "ja": "一週間の流れをひと目で"],
|
||
subline: ["ko": "24시간 축 위에 그려지는 주간 타임테이블", "en": "Seven days drawn on a 24-hour timetable", "ja": "24時間軸に描かれる週間タイムテーブル"]),
|
||
]
|
||
|
||
let ipadCopies: [String: Copy] = [
|
||
"01-main": Copy(
|
||
headline: ["ko": "아이패드에서 더 넓게", "en": "Bigger on iPad", "ja": "iPadでもっと広く"],
|
||
subline: ["ko": "사이드바와 넓은 그리드로 한 화면에", "en": "Sidebar navigation and a wide grid", "ja": "サイドバーと広いグリッドで一画面に"]),
|
||
"02-diary-calendar": Copy(
|
||
headline: ["ko": "하루를 남기는 일기", "en": "A diary for every day", "ja": "毎日を残す日記"],
|
||
subline: ["ko": "기분 이모지와 사진으로 채워지는 달력 — 맥에서도", "en": "A calendar of moods and photos — on Mac too", "ja": "気分の絵文字と写真で埋まるカレンダー — Macでも"]),
|
||
"03-diary-summary": Copy(
|
||
headline: ["ko": "하루 정리를 한 페이지에", "en": "Your whole day on one page", "ja": "一日のまとめを1ページに"],
|
||
subline: ["ko": "기록·목표·할 일·타임테이블까지", "en": "Records, goals, to-dos and a timetable", "ja": "記録・目標・やること・タイムテーブル"]),
|
||
"04-diary-note": Copy(
|
||
headline: ["ko": "애플 펜슬로 자유롭게", "en": "Free-form with Apple Pencil", "ja": "Apple Pencilで自由に"],
|
||
subline: ["ko": "필기·사진·도형·텍스트 — 맥에서는 마우스와 키보드로", "en": "Ink, photos, shapes and text — mouse and keyboard on Mac", "ja": "手書き・写真・図形・テキスト — Macではマウスとキーボードで"]),
|
||
"08-diary-template": Copy(
|
||
headline: ["ko": "나만의 양식 위에 쓰는 일기", "en": "Write on your own templates", "ja": "自分の用紙の上に書く日記"],
|
||
subline: ["ko": "PDF·사진 속지를 불러와 노트 배경으로", "en": "Import PDF or photo paper as page backgrounds", "ja": "PDF・写真の用紙をノートの背景に"]),
|
||
"09-dark": Copy(
|
||
headline: ["ko": "밤에는 다크 모드로 편안하게", "en": "Easy on the eyes in Dark Mode", "ja": "夜はダークモードで心地よく"],
|
||
subline: ["ko": "라이트·다크·시스템 설정 따름 중 선택", "en": "Light, dark, or match the system", "ja": "ライト・ダーク・システム連動から選択"]),
|
||
"10-goals": Copy(
|
||
headline: ["ko": "목표와 다짐으로 꾸준하게", "en": "Goals and quests keep you going", "ja": "目標とクエストで続けられる"],
|
||
subline: ["ko": "하루·주간·월간 진행률과 연속 달성", "en": "Daily, weekly, monthly progress and streaks", "ja": "日・週・月の達成率と継続日数"]),
|
||
"05-timetable": Copy(
|
||
headline: ["ko": "일주일이 한 화면에", "en": "A whole week on one screen", "ja": "一週間をひと画面で"],
|
||
subline: ["ko": "24시간 축 위에 그려지는 주간 타임테이블", "en": "Your week drawn on a 24-hour timetable", "ja": "24時間軸に描かれる週間タイムテーブル"]),
|
||
"06-widgets": Copy(
|
||
headline: ["ko": "아이패드 홈 화면에도 위젯", "en": "Widgets on your iPad too", "ja": "iPadのホーム画面にもウィジェット"],
|
||
subline: ["ko": "실행·타이머·진행률·통계를 크게", "en": "Run, time, track and chart — bigger", "ja": "実行・タイマー・進捗・統計を大きく"]),
|
||
]
|
||
|
||
/// 07-sync — 합성 전용 슬라이드: 아이폰+아이패드 원본(01-main) 두 장을 한 캔버스에 담아
|
||
/// iCloud 동기화를 표현한다 (원본 스크린샷 파일이 따로 없는 유일한 항목)
|
||
let syncCopy = Copy(
|
||
headline: ["ko": "iCloud로 모든 기기가 하나로", "en": "All your devices, one with iCloud", "ja": "iCloudですべてのデバイスがひとつに"],
|
||
subline: ["ko": "아이폰·아이패드·맥, 어디서 기록해도 이어져요", "en": "Record anywhere — iPhone, iPad or Mac", "ja": "iPhone・iPad・Mac、どこで記録しても続きから"])
|
||
|
||
/// 워치 합성 슬라이드 문구 (canvas 416×496 — 짧게)
|
||
let watchCopy = Copy(
|
||
headline: ["ko": "손목에서 바로 기록", "en": "Track from your wrist", "ja": "手首からすぐ記録"],
|
||
subline: ["ko": "탭 한 번, 아이폰과 자동 동기화", "en": "One tap, synced with iPhone", "ja": "ワンタップ、iPhoneと自動同期"])
|
||
|
||
struct Device {
|
||
let folder: String // Screenshots/<folder>/<locale>/…
|
||
let canvas: NSSize // ASC 규격 (px)
|
||
let headSize: CGFloat
|
||
let subSize: CGFloat
|
||
let topPadding: CGFloat
|
||
let cardWidthRatio: CGFloat
|
||
let cornerRadius: CGFloat
|
||
let copies: [String: Copy]
|
||
}
|
||
|
||
let devices = [
|
||
Device(folder: "iphone-6.9", canvas: NSSize(width: 1320, height: 2868),
|
||
headSize: 82, subSize: 44, topPadding: 132, cardWidthRatio: 0.84, cornerRadius: 64, copies: iphoneCopies),
|
||
Device(folder: "ipad-13", canvas: NSSize(width: 2064, height: 2752),
|
||
headSize: 96, subSize: 50, topPadding: 150, cardWidthRatio: 0.80, cornerRadius: 44, copies: ipadCopies),
|
||
]
|
||
let locales = ["ko", "en", "ja"]
|
||
|
||
// MARK: 그리기
|
||
|
||
/// maxWidth에 맞을 때까지 폰트를 줄여 가운데 정렬 속성 문자열을 만든다
|
||
func fitted(_ text: String, weight: NSFont.Weight, size: CGFloat, color: NSColor, maxWidth: CGFloat) -> NSAttributedString {
|
||
var fontSize = size
|
||
while fontSize > 20 {
|
||
let para = NSMutableParagraphStyle(); para.alignment = .center
|
||
let attr = NSAttributedString(string: text, attributes: [
|
||
.font: NSFont.systemFont(ofSize: fontSize, weight: weight),
|
||
.foregroundColor: color, .paragraphStyle: para,
|
||
])
|
||
if attr.size().width <= maxWidth { return attr }
|
||
fontSize -= 2
|
||
}
|
||
let para = NSMutableParagraphStyle(); para.alignment = .center
|
||
return NSAttributedString(string: text, attributes: [
|
||
.font: NSFont.systemFont(ofSize: 20, weight: weight),
|
||
.foregroundColor: color, .paragraphStyle: para,
|
||
])
|
||
}
|
||
|
||
func render(device: Device, shot: String, locale: String, copy: Copy) -> Bool {
|
||
let fm = FileManager.default
|
||
let inPath = "Marketing/Screenshots/\(device.folder)/\(locale)/\(shot).png"
|
||
let outDir = "Marketing/Screenshots/framed-\(device.folder)/\(locale)"
|
||
let outPath = "\(outDir)/\(shot).png"
|
||
guard let shotImage = NSImage(contentsOfFile: inPath) else {
|
||
print("!! 원본 없음: \(inPath)"); return false
|
||
}
|
||
try? fm.createDirectory(atPath: outDir, withIntermediateDirectories: true)
|
||
|
||
let W = device.canvas.width, H = device.canvas.height
|
||
guard let rep = NSBitmapImageRep(
|
||
bitmapDataPlanes: nil, pixelsWide: Int(W), pixelsHigh: Int(H),
|
||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
|
||
) else { return false }
|
||
rep.size = NSSize(width: W, height: H) // 1pt = 1px
|
||
|
||
NSGraphicsContext.saveGraphicsState()
|
||
guard let ctx = NSGraphicsContext(bitmapImageRep: rep) else { return false }
|
||
NSGraphicsContext.current = ctx
|
||
|
||
// 배경 그라데이션 (위 웜 화이트 → 아래 옅은 그린)
|
||
NSGradient(colors: [backgroundBottom, backgroundTop])?
|
||
.draw(in: NSRect(x: 0, y: 0, width: W, height: H), angle: 90)
|
||
|
||
// 문구 (상단, 가운데 정렬) — 좌표계는 좌하단 원점이므로 위에서부터 계산해 변환
|
||
let maxTextWidth = W - 140
|
||
let head = fitted(copy.headline[locale] ?? "", weight: .bold, size: device.headSize,
|
||
color: headlineColor, maxWidth: maxTextWidth)
|
||
let sub = fitted(copy.subline[locale] ?? "", weight: .medium, size: device.subSize,
|
||
color: sublineColor, maxWidth: maxTextWidth)
|
||
var cursorTop = device.topPadding
|
||
let headRect = NSRect(x: 70, y: H - cursorTop - head.size().height, width: maxTextWidth, height: head.size().height)
|
||
head.draw(in: headRect)
|
||
cursorTop += head.size().height + 26
|
||
// 골드 포인트 바
|
||
let barWidth: CGFloat = 120
|
||
accentGold.setFill()
|
||
NSBezierPath(roundedRect: NSRect(x: (W - barWidth) / 2, y: H - cursorTop - 8, width: barWidth, height: 8),
|
||
xRadius: 4, yRadius: 4).fill()
|
||
cursorTop += 8 + 26
|
||
let subRect = NSRect(x: 70, y: H - cursorTop - sub.size().height, width: maxTextWidth, height: sub.size().height)
|
||
sub.draw(in: subRect)
|
||
cursorTop += sub.size().height + 64
|
||
|
||
// 스크린샷 카드 (둥근 모서리 + 그림자 + 헤어라인 테두리)
|
||
let cardWidth = W * device.cardWidthRatio
|
||
let cardHeight = cardWidth * (shotImage.size.height / shotImage.size.width)
|
||
let cardX = (W - cardWidth) / 2
|
||
let cardY = H - cursorTop - cardHeight
|
||
let cardRect = NSRect(x: cardX, y: cardY, width: cardWidth, height: cardHeight)
|
||
let cardPath = NSBezierPath(roundedRect: cardRect, xRadius: device.cornerRadius, yRadius: device.cornerRadius)
|
||
|
||
NSGraphicsContext.current?.saveGraphicsState()
|
||
let shadow = NSShadow()
|
||
shadow.shadowColor = NSColor.black.withAlphaComponent(0.22)
|
||
shadow.shadowBlurRadius = 46
|
||
shadow.shadowOffset = NSSize(width: 0, height: -18)
|
||
shadow.set()
|
||
NSColor.white.setFill()
|
||
cardPath.fill()
|
||
NSGraphicsContext.current?.restoreGraphicsState()
|
||
|
||
NSGraphicsContext.current?.saveGraphicsState()
|
||
cardPath.addClip()
|
||
shotImage.draw(in: cardRect, from: .zero, operation: .sourceOver, fraction: 1)
|
||
NSGraphicsContext.current?.restoreGraphicsState()
|
||
|
||
color("#2F6B4F", 0.14).setStroke()
|
||
cardPath.lineWidth = 3
|
||
cardPath.stroke()
|
||
|
||
NSGraphicsContext.restoreGraphicsState()
|
||
|
||
guard let data = rep.representation(using: .png, properties: [:]) else { return false }
|
||
do {
|
||
try data.write(to: URL(fileURLWithPath: outPath))
|
||
print("OK \(outPath)")
|
||
return true
|
||
} catch {
|
||
print("!! 저장 실패 \(outPath): \(error)")
|
||
return false
|
||
}
|
||
}
|
||
|
||
// MARK: 07-sync 듀오 합성 (아이폰+아이패드 01-main 두 장 → iCloud 동기화 슬라이드)
|
||
|
||
/// 둥근 모서리 카드 하나 (그림자 + 헤어라인 테두리) — render()의 카드 그리기와 동일 규칙
|
||
func drawCard(image: NSImage, rect: NSRect, radius: CGFloat) {
|
||
let path = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
|
||
NSGraphicsContext.current?.saveGraphicsState()
|
||
let shadow = NSShadow()
|
||
shadow.shadowColor = NSColor.black.withAlphaComponent(0.22)
|
||
shadow.shadowBlurRadius = 46
|
||
shadow.shadowOffset = NSSize(width: 0, height: -18)
|
||
shadow.set()
|
||
NSColor.white.setFill()
|
||
path.fill()
|
||
NSGraphicsContext.current?.restoreGraphicsState()
|
||
NSGraphicsContext.current?.saveGraphicsState()
|
||
path.addClip()
|
||
image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
|
||
NSGraphicsContext.current?.restoreGraphicsState()
|
||
color("#2F6B4F", 0.14).setStroke()
|
||
path.lineWidth = 3
|
||
path.stroke()
|
||
}
|
||
|
||
/// 두 카드 사이의 동기화 배지 (흰 원반 + 골드 순환 화살표). 심볼을 못 얻으면 조용히 생략.
|
||
func drawSyncBadge(center: NSPoint, radius: CGFloat) {
|
||
let circleRect = NSRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2)
|
||
NSGraphicsContext.current?.saveGraphicsState()
|
||
let shadow = NSShadow()
|
||
shadow.shadowColor = NSColor.black.withAlphaComponent(0.18)
|
||
shadow.shadowBlurRadius = 24
|
||
shadow.shadowOffset = NSSize(width: 0, height: -8)
|
||
shadow.set()
|
||
NSColor.white.setFill()
|
||
NSBezierPath(ovalIn: circleRect).fill()
|
||
NSGraphicsContext.current?.restoreGraphicsState()
|
||
color("#2F6B4F", 0.14).setStroke()
|
||
let ring = NSBezierPath(ovalIn: circleRect)
|
||
ring.lineWidth = 3
|
||
ring.stroke()
|
||
guard let symbol = NSImage(systemSymbolName: "arrow.triangle.2.circlepath", accessibilityDescription: nil)?
|
||
.withSymbolConfiguration(.init(pointSize: radius, weight: .semibold)) else { return }
|
||
let tinted = NSImage(size: symbol.size, flipped: false) { rect in
|
||
symbol.draw(in: rect)
|
||
accentGold.set()
|
||
rect.fill(using: .sourceAtop)
|
||
return true
|
||
}
|
||
tinted.draw(in: NSRect(x: center.x - symbol.size.width / 2, y: center.y - symbol.size.height / 2,
|
||
width: symbol.size.width, height: symbol.size.height))
|
||
}
|
||
|
||
func renderSync(device: Device, locale: String) -> Bool {
|
||
let fm = FileManager.default
|
||
guard let phoneShot = NSImage(contentsOfFile: "Marketing/Screenshots/iphone-6.9/\(locale)/01-main.png"),
|
||
let padShot = NSImage(contentsOfFile: "Marketing/Screenshots/ipad-13/\(locale)/01-main.png") else {
|
||
print("!! 원본 없음(07-sync 소스 01-main): \(locale)"); return false
|
||
}
|
||
let outDir = "Marketing/Screenshots/framed-\(device.folder)/\(locale)"
|
||
let outPath = "\(outDir)/07-sync.png"
|
||
try? fm.createDirectory(atPath: outDir, withIntermediateDirectories: true)
|
||
|
||
let W = device.canvas.width, H = device.canvas.height
|
||
guard let rep = NSBitmapImageRep(
|
||
bitmapDataPlanes: nil, pixelsWide: Int(W), pixelsHigh: Int(H),
|
||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
|
||
) else { return false }
|
||
rep.size = NSSize(width: W, height: H)
|
||
|
||
NSGraphicsContext.saveGraphicsState()
|
||
guard let ctx = NSGraphicsContext(bitmapImageRep: rep) else { return false }
|
||
NSGraphicsContext.current = ctx
|
||
|
||
NSGradient(colors: [backgroundBottom, backgroundTop])?
|
||
.draw(in: NSRect(x: 0, y: 0, width: W, height: H), angle: 90)
|
||
|
||
// 문구 (render()와 동일 규칙)
|
||
let maxTextWidth = W - 140
|
||
let head = fitted(syncCopy.headline[locale] ?? "", weight: .bold, size: device.headSize,
|
||
color: headlineColor, maxWidth: maxTextWidth)
|
||
let sub = fitted(syncCopy.subline[locale] ?? "", weight: .medium, size: device.subSize,
|
||
color: sublineColor, maxWidth: maxTextWidth)
|
||
var cursorTop = device.topPadding
|
||
head.draw(in: NSRect(x: 70, y: H - cursorTop - head.size().height, width: maxTextWidth, height: head.size().height))
|
||
cursorTop += head.size().height + 26
|
||
let barWidth: CGFloat = 120
|
||
accentGold.setFill()
|
||
NSBezierPath(roundedRect: NSRect(x: (W - barWidth) / 2, y: H - cursorTop - 8, width: barWidth, height: 8),
|
||
xRadius: 4, yRadius: 4).fill()
|
||
cursorTop += 8 + 26
|
||
sub.draw(in: NSRect(x: 70, y: H - cursorTop - sub.size().height, width: maxTextWidth, height: sub.size().height))
|
||
cursorTop += sub.size().height + 64
|
||
|
||
// 카드 배치: 아이패드(뒤, 우상단) + 아이폰(앞, 좌하단) 대각 오버랩
|
||
let phoneCanvas = device.folder.hasPrefix("iphone")
|
||
let padW = W * (phoneCanvas ? 0.72 : 0.64)
|
||
let padH = padW * (padShot.size.height / padShot.size.width)
|
||
let padX = W - padW - (phoneCanvas ? 56 : 80)
|
||
let padYTop = cursorTop + 30
|
||
let phoneW = W * (phoneCanvas ? 0.52 : 0.37)
|
||
let phoneH = phoneW * (phoneShot.size.height / phoneShot.size.width)
|
||
let phoneX: CGFloat = phoneCanvas ? 56 : 100
|
||
let phoneYTop = padYTop + (phoneCanvas ? 560 : 430)
|
||
|
||
let padRect = NSRect(x: padX, y: H - padYTop - padH, width: padW, height: padH)
|
||
let phoneRect = NSRect(x: phoneX, y: H - phoneYTop - phoneH, width: phoneW, height: phoneH)
|
||
drawCard(image: padShot, rect: padRect, radius: phoneCanvas ? 36 : 44)
|
||
drawCard(image: phoneShot, rect: phoneRect, radius: phoneCanvas ? 56 : 48)
|
||
drawSyncBadge(
|
||
center: NSPoint(x: (padRect.midX + phoneRect.midX) / 2, y: (padRect.midY + phoneRect.midY) / 2),
|
||
radius: phoneCanvas ? 64 : 80
|
||
)
|
||
|
||
NSGraphicsContext.restoreGraphicsState()
|
||
|
||
guard let data = rep.representation(using: .png, properties: [:]) else { return false }
|
||
do {
|
||
try data.write(to: URL(fileURLWithPath: outPath))
|
||
print("OK \(outPath)")
|
||
return true
|
||
} catch {
|
||
print("!! 저장 실패 \(outPath): \(error)")
|
||
return false
|
||
}
|
||
}
|
||
|
||
// MARK: 워치 합성 (canvas 416×496 — 원본 워치 스크린샷 + 짧은 문구)
|
||
|
||
func renderWatch(locale: String) -> Bool {
|
||
let fm = FileManager.default
|
||
guard let shot = NSImage(contentsOfFile: "Marketing/Screenshots/watch/\(locale)/01-main.png") else {
|
||
print("!! 원본 없음(watch): \(locale)"); return false
|
||
}
|
||
let outDir = "Marketing/Screenshots/framed-watch/\(locale)"
|
||
let outPath = "\(outDir)/01-main.png"
|
||
try? fm.createDirectory(atPath: outDir, withIntermediateDirectories: true)
|
||
|
||
let W: CGFloat = 416, H: CGFloat = 496
|
||
guard let rep = NSBitmapImageRep(
|
||
bitmapDataPlanes: nil, pixelsWide: Int(W), pixelsHigh: Int(H),
|
||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
||
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
|
||
) else { return false }
|
||
rep.size = NSSize(width: W, height: H)
|
||
|
||
NSGraphicsContext.saveGraphicsState()
|
||
guard let ctx = NSGraphicsContext(bitmapImageRep: rep) else { return false }
|
||
NSGraphicsContext.current = ctx
|
||
|
||
NSGradient(colors: [backgroundBottom, backgroundTop])?
|
||
.draw(in: NSRect(x: 0, y: 0, width: W, height: H), angle: 90)
|
||
|
||
let maxTextWidth = W - 32
|
||
let head = fitted(watchCopy.headline[locale] ?? "", weight: .bold, size: 32,
|
||
color: headlineColor, maxWidth: maxTextWidth)
|
||
let sub = fitted(watchCopy.subline[locale] ?? "", weight: .medium, size: 17,
|
||
color: sublineColor, maxWidth: maxTextWidth)
|
||
var cursorTop: CGFloat = 26
|
||
head.draw(in: NSRect(x: 16, y: H - cursorTop - head.size().height, width: maxTextWidth, height: head.size().height))
|
||
cursorTop += head.size().height + 10
|
||
accentGold.setFill()
|
||
NSBezierPath(roundedRect: NSRect(x: (W - 56) / 2, y: H - cursorTop - 4, width: 56, height: 4),
|
||
xRadius: 2, yRadius: 2).fill()
|
||
cursorTop += 4 + 10
|
||
sub.draw(in: NSRect(x: 16, y: H - cursorTop - sub.size().height, width: maxTextWidth, height: sub.size().height))
|
||
cursorTop += sub.size().height + 16
|
||
|
||
// 워치 카드 — 실제 워치 화면처럼 모서리를 크게 굴린다
|
||
let cardWidth = W * 0.56
|
||
let cardHeight = cardWidth * (shot.size.height / shot.size.width)
|
||
let cardRect = NSRect(x: (W - cardWidth) / 2, y: H - cursorTop - cardHeight,
|
||
width: cardWidth, height: cardHeight)
|
||
let path = NSBezierPath(roundedRect: cardRect, xRadius: 52, yRadius: 52)
|
||
NSGraphicsContext.current?.saveGraphicsState()
|
||
let shadow = NSShadow()
|
||
shadow.shadowColor = NSColor.black.withAlphaComponent(0.28)
|
||
shadow.shadowBlurRadius = 18
|
||
shadow.shadowOffset = NSSize(width: 0, height: -7)
|
||
shadow.set()
|
||
NSColor.black.setFill()
|
||
path.fill()
|
||
NSGraphicsContext.current?.restoreGraphicsState()
|
||
NSGraphicsContext.current?.saveGraphicsState()
|
||
path.addClip()
|
||
shot.draw(in: cardRect, from: .zero, operation: .sourceOver, fraction: 1)
|
||
NSGraphicsContext.current?.restoreGraphicsState()
|
||
color("#2F6B4F", 0.18).setStroke()
|
||
path.lineWidth = 2
|
||
path.stroke()
|
||
|
||
NSGraphicsContext.restoreGraphicsState()
|
||
|
||
guard let data = rep.representation(using: .png, properties: [:]) else { return false }
|
||
do {
|
||
try data.write(to: URL(fileURLWithPath: outPath))
|
||
print("OK \(outPath)")
|
||
return true
|
||
} catch {
|
||
print("!! 저장 실패 \(outPath): \(error)")
|
||
return false
|
||
}
|
||
}
|
||
|
||
var failures = 0
|
||
for device in devices {
|
||
for (shot, copy) in device.copies.sorted(by: { $0.key < $1.key }) {
|
||
for locale in locales {
|
||
if !render(device: device, shot: shot, locale: locale, copy: copy) { failures += 1 }
|
||
}
|
||
}
|
||
}
|
||
for device in devices {
|
||
for locale in locales {
|
||
if !renderSync(device: device, locale: locale) { failures += 1 }
|
||
}
|
||
}
|
||
for locale in locales {
|
||
if !renderWatch(locale: locale) { failures += 1 }
|
||
}
|
||
print(failures == 0 ? "== 전부 완료 ==" : "== 실패 \(failures)건 ==")
|
||
exit(failures == 0 ? 0 : 1)
|