docs(marketing): framed promo screenshots (24) + compositor tool
- 홍보 문구 합성판 추가 — 브랜드 배경(웜 화이트→그린 그라데이션) + 헤드라인/서브 문구(ko/en/ja, 화면별 8종) + 골드 포인트 바 + 둥근 모서리 카드(그림자·헤어라인)로 합성. 캔버스는 ASC 규격 그대로(1320×2868 / 2064×2752)라 framed 세트를 바로 업로드 가능 — 원본 세트는 예비·재합성 소스로 유지 - Marketing/tools/frame_screenshots.swift — AppKit 기반 합성 도구(재현 가능): 문구·색 상단 정의, 텍스트는 폭 맞춤 자동 축소, 원본 재촬영 후 재실행만 하면 됨. 워치는 캔버스가 작아 원본 유지(관행) - README: framed 우선 업로드 안내 + 문구 오버레이 규정 메모 + iPad 상태바 날짜(시뮬 시스템 언어) 알려진 티 기록 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
@ -12,14 +12,21 @@ AppStore/
|
||||
whats-new-1.0.txt 버전 1.0 '새로운 기능'
|
||||
subscription-info.txt 인앱 상품 3종 메타데이터 + 자동 갱신 고지 + 등록 체크리스트
|
||||
Screenshots/
|
||||
iphone-6.9/{ko,en,ja}/ 아이폰 6.9" 슬롯용 (1320×2868px — iPhone 17 Pro Max 시뮬레이터)
|
||||
ipad-13/{ko,en,ja}/ 아이패드 13" 슬롯용 (2064×2752px — iPad Pro 13"(M5) 시뮬레이터)
|
||||
iphone-6.9/{ko,en,ja}/ 아이폰 6.9" 원본 (1320×2868px — iPhone 17 Pro Max 시뮬레이터)
|
||||
ipad-13/{ko,en,ja}/ 아이패드 13" 원본 (2064×2752px — iPad Pro 13"(M5) 시뮬레이터)
|
||||
watch/{ko,en,ja}/ 애플워치 슬롯용 (416×496px — Series 11 46mm. 워치 앱이 있는 앱은 워치 스크린샷 필수)
|
||||
framed-iphone-6.9/… ★ 업로드 권장: 홍보 문구+브랜드 배경 합성판 (같은 캔버스 크기라 ASC에 그대로)
|
||||
framed-ipad-13/… ★ 업로드 권장: 〃 (원본은 예비/재합성 소스로 보관)
|
||||
tools/
|
||||
frame_screenshots.swift 합성 도구 — `swift Marketing/tools/frame_screenshots.swift` (저장소 루트에서).
|
||||
문구·색은 파일 상단에서 수정, 원본을 다시 찍으면 이 도구만 재실행하면 됨
|
||||
```
|
||||
|
||||
- 스크린샷: 아이폰 4장 = 모음 / 목표 / 타임테이블 / 주간 통계, 아이패드 4장 = 모음(사이드바) / 일기 달력 / 하루 정리 / 펜슬 노트, 워치 1장 = 앱 첫 화면(측정 중·즐겨찾기·꼬리표)
|
||||
- ASC는 6.9″(아이폰)·13″(아이패드)·워치 한 세트씩 필수 — 나머지 크기는 자동 축소 적용
|
||||
- 상태바는 9:41·배터리 100%로 오버라이드된 상태로 촬영 (워치 시뮬레이터는 오버라이드 미지원 — 실제 시각 표시)
|
||||
- 알려진 티: iPad 상태바의 날짜 문자열은 시뮬레이터 **시스템** 언어(한국어)를 따름 — 앱 런치 인자로는 못 바꾸는 영역. 아이폰은 시간만 표시라 무관. 완벽을 원하면 시뮬 시스템 언어 변경 후 재촬영
|
||||
- 문구 오버레이 규정: Apple은 스크린샷에 마케팅 문구·배경 합성을 허용(실제 앱 화면이 반영되어 있으면 됨) — framed 세트는 이 관행을 따른 것
|
||||
|
||||
## 스크린샷 재생성 방법
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 597 KiB |
|
After Width: | Height: | Size: 991 KiB |
|
After Width: | Height: | Size: 883 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 591 KiB |
|
After Width: | Height: | Size: 989 KiB |
|
After Width: | Height: | Size: 877 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 569 KiB |
|
After Width: | Height: | Size: 938 KiB |
|
After Width: | Height: | Size: 854 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 912 KiB |
|
After Width: | Height: | Size: 690 KiB |
|
After Width: | Height: | Size: 983 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 869 KiB |
|
After Width: | Height: | Size: 693 KiB |
|
After Width: | Height: | Size: 968 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 807 KiB |
|
After Width: | Height: | Size: 666 KiB |
|
After Width: | Height: | Size: 932 KiB |
196
myApp/HaruDanim/Marketing/tools/frame_screenshots.swift
Normal file
@ -0,0 +1,196 @@
|
||||
// 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": "アクション・タグ別のグラフと合計・平均"]),
|
||||
]
|
||||
|
||||
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 that fills with your moods", "ja": "気分の絵文字で埋まるカレンダー"]),
|
||||
"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 your own templates", "ja": "手書き・写真・図形・自分の用紙まで"]),
|
||||
]
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
print(failures == 0 ? "== 전부 완료 ==" : "== 실패 \(failures)건 ==")
|
||||
exit(failures == 0 ? 0 : 1)
|
||||