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
This commit is contained in:
songyc macbook 2026-07-17 18:07:19 +09:00
parent 3ec7b43adc
commit c0aef1271c
26 changed files with 205 additions and 2 deletions

View File

@ -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 세트는 이 관행을 따른 것
## 스크린샷 재생성 방법

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 991 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 989 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 983 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 968 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 932 KiB

View 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)