본문 바로가기
C.W.K.
Stream
Lesson 05 of 05 · published

Generate, 그리고 Stamp

~16 min · invariants, cost-control, ember, debugging, swiftui

Level 0빈 캔버스
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

캔버스 옆의 두 번째 엔진

2026-10-04, 아빠가 정했어. Ariandel도 캔버스에서 Cinder의 기능을 쓰자고. 가족 이미지 엔진 cwkEmber가 아빠 드로잉을 참고 삼아 그림을 만들어 주는 Generate 패널이야. 앱은 Ember를 테일넷 위 주소로 직접 불러. 중간에 Ariandel 엔진도 없고 토큰도 없어(Ember는 테일넷에서 온 호출을 믿어). 카탈로그, 경로, 자격 증명, 청구는 전부 Ember 몫이야. Ariandel은 Ember가 준 답을 그려 줄 뿐이고, 모델이나 제공자 목록을 따로 들고 있지 않아. 따로 쥔 목록은 크기 하나야. 패널에도 보여. 원본 너비 줄 512~1,024픽셀(Cinder의 줄)이고, 카탈로그에 크기가 없는 Pro 제공자를 위해 1K, 2K, 4K를 대비책으로 둬. Ember는 누가 부르는지도 알아. 다만 요청마다 방식이 달라. 로컬 생성과 Auto 미리보기엔 그림 id와 함께 client_meta.caller = "ariandel"가 붙어. Pro의 프롬프트 단계와 제출엔 source_metadata.source_app = "ariandel"가 붙고, 업스케일엔 ariandel 태그만 붙어. 평범한 Describe 호출이나 카탈로그 읽기엔 아무것도 안 붙어.

패널은 평평하게 합친 캔버스(내 룩이 보는 바로 그 프레임)나 선택 영역의 경계 상자에서 출발해서, 흰 바탕 JPEG로 보내. 거기서부터 할 수 있는 게 많아. ControlNet을 곁들인 img2img, 한 번에 한 장에서 네 장, 모델과 기계를 바꿔 가며 뽑는 버전, Describe(Ember가 드로잉을 보고 프롬프트를 써 줘), 붓질을 따라오는 Auto 미리보기, 업스케일, 그리고 Ember API 생성기로 가는 Pro Generate까지. 그림 바로 옆에 놓기엔 힘이 꽤 세. 규칙 둘이 그 힘을 선 안쪽에 묶어 둬.

불변식 13

문장은 이래. Ember의 그림은 아빠의 Stamp를 거쳐야만 레이어에 닿고, 돈이 들 수 있는 요청은 무엇 하나 몰래 나가지 않는다.

앞쪽 절반은 그림이 어디 사느냐 얘기야. 후보는 작품 곁, 앱 컨테이너의 Generations/<id>/에 머물러. 작품 패키지 안엔 절대 안 들어가. 아빠가 지우거나, 최근 몇 장(기본 열 장)만 남기고 정리할 때까지 거기 있어. 후보가 그림의 일부가 되는 건 아빠가 Stamp를 누를 때뿐이야. 그러면 후보는 자기가 만들어진 영역 위에 새 레이어로 깔려. 비율은 그대로 둔 채 그 영역 안에 맞춰 가운데 놓고, Transform으로 열어 줘. 자리는 아빠가 직접 잡아. Stamp는 아빠의 탭이지 내 탭이 아니야. 내가 Generate를 도울 땐 지난 레슨 그대로야. 캔버스 룩으로 후보를 봐. subject: candidates는 최신 여덟 장에 번호를 붙인 시트를, subject: candidate는 한 장을 원본 옆에 놓고 모델, 프롬프트, 시드까지 보여 줘. 방향을 잡아 주는 게 도움이 될 것 같으면 프롬프트 블록을 내밀어. 아빠의 Insert가 패널 프롬프트를 채우고, Revert로 되돌릴 수 있어. 생성은 절대 안 해. Generate는 아빠가 눌러. 브로커는 내가 보는 게 뭔지까지 알려 줘. Ember의 그림은 "references for his own hand, not his painting"이라고. 아빠 그림이 아니라 아빠 손을 위한 참고 자료라는 뜻이야.

돈 드는 요청은 몰래 안 나가

로컬 생성은 가족 기계에서 돌아. Pro Generate는 쓴 만큼 청구하는 API 생성기로 갈 수 있어서 두 단계를 거쳐. 먼저 Ember가 캔버스로 프롬프트를 준비하고 아빠가 읽어. 아직 생성기엔 아무것도 안 갔어. 그다음 submitPro가 Ember 자동 경로한테 어느 제공자를 쓸 건지 묻고, 그 답을 고정해. 그 제공자의 청구 방식이 subscription, credits, included 중 하나가 아니면, 아빠가 바로 그 제공자를 확인해 주기 전엔 아무것도 안 보내. 알림 문구도 쉬운 말이야. "Spend on <provider>? … billed as <billing>, for this request. Nothing has been sent." 확인은 제공자 이름에 묶여 있어서, 경로가 다른 제공자를 고르면 다시 물어.

그리고 Pro 제출은 혼자서 절대 다시 안 보내. 이유는 catch 블록 주석이 나보다 잘 말해. 답이 사라졌어도 Ember는 이미 청구했을 수 있다고. 실패한 Pro 작업은 "Nothing was retried."로 끝나고, 다음은 사람이 정해. 공짜고 멱등인 요청이면 재시도 고리도 괜찮아. 성공했을지도 모르는 유료 요청은 둘 다 아니야.

Auto와 그 안전장치

Auto는 패널의 실시간 미리보기야. 켜는 순간 미리보기를 하나 바로 청하고, Auto가 켜진 채로 패널을 열면 1.2초 뒤에 하나 청해. 그다음부턴 확정된 변화마다 하나씩 예약해. 붓질 도중엔 절대 아니고, 1.2초 디바운스를 거쳐. 프롬프트가 아직 없으면 Ember가 먼저 드로잉을 묘사해서, 빈 프롬프트로 나가는 작업은 없어. 안 바뀐 JPEG는 두 번 안 보내. 미리보기마다 리비전 번호가 붙고, 최신보다 오래된 답은 아무리 늦게 와도 버려. Auto 카드는 최신 하나만 남고, Keep을 누르면 평범한 후보가 돼.

이제 그 안전장치가 실제로 어디 서 있는지 봐. "패널이 열려 있고 Auto가 켜져 있을 때만"이라는 말보다 좁거든. noteChange와 scheduleAuto는 미리보기를 예약할 때 두 플래그를 확인해. 기다리는 태스크는 자기가 취소됐는지만 봐. 그런데 Auto를 끄거나 패널을 닫아도 아무것도 취소 안 돼. 두 setter 다 autoTask를 안 건드리거든. 그래서 1.2초를 기다리던 미리보기, 또는 Ember의 Describe를 기다리던 미리보기는 Auto가 꺼진 뒤에도, 패널이 닫힌 뒤에도 나가. 취소는 캔버스를 떠날 때(close())뿐이야. 가족 기계에서 도는 로컬 임시 작업이라, 새는 건 미리보기 한 장 분량의 GPU 시간이지 돈은 아냐. 가져갈 건 그 모양이야. 일을 예약할 때 확인한 가드는 일이 실행될 때의 가드가 아냐.

아빠 iPad가 찾은 버그

2026-10-04, 아빠가 iPad에서 세 가지를 알려 줬어. Auto가 아무것도 안 했고, Describe가 없어서 프롬프트가 빈 채로 나갔고, 어느 기계가 어느 모델을 돌렸는지 알 수 없었어. 커밋 b14f113이 셋 다 고쳤는데, 앞의 둘은 원인이 달라서 따로 봐야 해.

Auto는 첫 붓질에 죽었어. beginStroke랑 setMode가 열린 패널을 전부 닫았거든. 팝오버라면 맞는 동작이야. Generate가 닫히고, 패널 열림 플래그가 false가 되고, 그 뒤론 변화가 올 때마다 Auto 가드에서 곧바로 빠져나갔어. 에러는 하나도 없었어. Auto한텐 섬길 패널이 없었을 뿐이야. 고친 건 속성 하나, CanvasPanel.isDocked야. iPad에서 캔버스 옆에 붙어 앉는 두 패널인 피파와 Generate에서 참이야. 붓질이나 도구 전환은 도킹된 패널을 절대 안 닫아.

작업이 빈 프롬프트로 나갔어. 이 단서는 선 건너편에서 왔어. Ember 자기 로그에 아빠 작업 둘이 빈 프롬프트로, Describe 호출도 없이 들어온 게 찍혀 있었어. 그래서 위의 규칙이 생겼어. 프롬프트가 비었으면 Generate도 Auto도 먼저 드로잉을 묘사해. 어느 증인이 진실을 말했는지 봐. Ariandel 안에서 보면 상태는 멀쩡했어. 영수증은 반대편 엔진이 쥐고 있었어.

제안은 기계가 해도 돼. 확정은 사람만, 그것도 비용을 본 다음에. 작품 곁의 후보는 공짜야. 레이어 속 그림이나 청구서 위 금액은 누른 사람 몫이야.

Code

Ariandel Generation.swift에서 줄인 발췌: 고정된 경로, 확인, 그리고 절대 재시도 안 하는 catch·swift
    /// Step two, after Dad reads the prompt. Ember's automatic route is resolved and
    /// pinned here; a tier that bills by use is never sent until Dad confirms that
    /// exact provider (`confirmed`). A submission is never retried by itself.
    func submitPro(_ draft: ProDraft, confirmed: String? = nil) {
        guard let row = proProvider else { return note("Ember's Pro catalog is not loaded.") }
        proDraft = nil
        let aspect = proAspect(for: draft.source)
        let quality = "auto"
        proPhase = .generating("Preparing")
        Task {
            do {
                var provider = row.id, label = row.label, size = settings.proImageSize
                if !proImageSizes.contains(size) { size = row.defaultImageSize ?? proImageSizes.first ?? "auto" }
                if row.isRoute {
                    let route = try await client.proRoute(JSONBody(["image_size": size, "aspect_ratio": aspect,
                                                                    "reference_count": draft.references.count, "quality": quality]))
                    let billing = route.billing ?? proOptions?.provider(route.provider)?.billing ?? "unclassified"
                    if !Self.unmeteredBilling.contains(billing), confirmed != route.provider {
                        proPhase = .idle
                        proConfirmation = ProConfirmation(draft: draft, provider: route.provider,
                                                          label: route.label ?? route.provider, billing: billing)
                        return
                    }
                    // …
            } catch {
                jobs.removeAll { $0.kind == .pro }
                // Never resubmitted: Ember may have billed it even when the answer was lost.
                proPhase = .failed("Pro Generate did not finish: \(error.localizedDescription) Nothing was retried.")
            }
Ariandel CanvasModel.swift에서 줄인 발췌: 도킹된 패널은 붓질에 안 닫혀 (b14f113)·swift
/// Popovers and sheets over the canvas.
enum CanvasPanel: String, Identifiable {
    case brushes, colors, layers, actions, pippa, generate, fill, adjustments, more

    var id: String { rawValue }

    /// Pippa and Generate sit beside the canvas on iPad: painting goes on with them
    /// open (Generate's Auto follows the strokes), so a stroke or a tool never closes them.
    var isDocked: Bool { self == .pippa || self == .generate }
}

// …

    /// Starts a stroke with the tool in hand, or with `fingerTool` for a
    /// finger set to smudge or erase (Procreate's Smudge and Erase "Touch").
    func beginStroke(fingerTool: GestureControls.FingerTool? = nil) -> Bool {
        if panel?.isDocked != true { panel = nil }
        // …
Ariandel Generation.swift에서 줄인 발췌: Auto가 예약되는 곳(켤 때, 패널 열 때, 확정된 변화), 플래그를 확인하는 곳, 그리고 안 바뀐 그림과 리비전 가드·swift
    var settings: GenerationSettings {
        didSet {
            settings.save(to: defaults)
            // Turning Auto on makes a preview at once, as Cinder does.
            if settings.auto, !oldValue.auto { scheduleAuto(delay: .zero) }
        }
    }
    // …
    var isPanelOpen = false { didSet { if isPanelOpen { scheduleAuto() } } }
    // …

    /// The artwork changed (a committed stroke, never mid-stroke): Auto makes a new preview.
    func noteChange() {
        guard settings.auto, isPanelOpen else { return }
        scheduleAuto()
    }

    private func scheduleAuto(delay: Duration = .seconds(1.2)) {
        guard settings.auto, isPanelOpen else { return }
        autoTask?.cancel()
        autoTask = Task { [weak self] in
            try? await Task.sleep(for: delay)
            guard !Task.isCancelled, let self else { return }
            // No prompt yet: Ember describes the drawing first.
            if !self.hasPrompt {
                guard await self.describeNow(), self.hasPrompt else { return }
            }
            self.preview()
        }
    }

    private func preview() {
        guard let model, let source = captureSource() else { return }
        // Cinder's unchanged-preview guard: the same picture is not sent again.
        guard source.jpeg != lastAutoSource else { return }
        lastAutoSource = source.jpeg
        previewRevision += 1
        let revision = previewRevision
        // …

External links

Exercise

네가 하는 작업에서 유료 API를 부르는 기능 하나를 찾아(이미지 생성기, 모델, 문자 발송 같은 거). 요청 하나를 버튼부터 청구서까지 따라가면서 세 군데에 표시해. 뭘 보낼지 사용자가 보는 곳, 얼마가 어느 제공자로 드는지 사용자가 보는 곳, 재시도가 일어날 수 있는 모든 곳. 그다음 정확히 그 제공자에 묶인 확인을 넣고, 유료 호출의 자동 재시도는 없애. 마지막으로 앱의 패널과 시트를 나열하고, 사용자의 주된 동작에도 살아남아야 할 게 뭔지 정해.
Hint
확인은 '예' 한 번이 아니라 식별자에 묶어. 사용자가 승인한 제공자를 저장해 두고, 보내는 순간 라우터가 고른 제공자랑 비교해. 패널은 각각 뭐 하자고 있는지 물어. 질문 하나에 답하는 팝오버는 다음 동작에 닫혀도 돼. 실시간 미리보기나 동반자처럼 작업을 따라오는 도구는 남아 있어야 해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.