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

취소할 수 없는 것, 그리고 아바타가 아닌 재료

~14 min · 취소, 대조, 계보, 재생

Level 0스케치
0 XP0/43 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

취소는 벤더 문 앞에서 멈춰

돌고 있는 이미지 잡은 적어도 멈춰 달라고 부탁할 수는 있어. cancel()이 Ember에 취소를 보내고, 이미 나간 종량 요청은 그래도 청구될 수 있다고 솔직하게 답해. 클립은 그 부탁조차 못 해. 클립이 running이 되면 답은 409야. 문구는 A submitted clip cannot be cancelled로 시작해. 제출된 클립은 취소가 안 되고, 벤더가 끝까지 만들어 청구하고 저장된다는 말이야. Ember 쪽 취소도 같은 말을 해. 제출 전인 클립만 받아줘.

그래서 클립 취소는 공짜인 자리에서만 되게 짜여 있어. 아직 queued나 preparing인 작업은 여기서 취소돼. 보통은 그걸로 끝이야. 아무것도 안 보냈으니까. 근데 예외가 하나 있어. 제출한 답이 전송 오류나 5xx로 사라지면, 워커는 같은 키로 다시 보내려고 클립을 queued로 돌려놔(앞 레슨). submitting에 남은 클립도 재시작하면 똑같이 돼. 그 요청을 Ember가 이미 받았을 수도 있어. 그럴 때 취소는 Avatars의 행만 바꿔. Ember의 클립은 계속 돌아서 청구되고, Avatars는 더는 그 클립을 찾지 않아. 게다가 cancelled에서 다시 하면 다음 시도의 키로 돌아서, 클립 값을 한 번 더 낼 수도 있어. submitting이나 uncertain인 작업은 대조부터 하라며 거절해. 뭘 취소해야 하는지 아무도 모르니까. 검토 창은 아빠가 버튼을 누르기 전에 미리 말해 둬. 제출된 클립은 취소할 수 없다고. 워커를 끌 때도 이 규칙을 지켜. stop()은 폴링만 끝내고, 이미 허락받은 상류 작업은 절대 취소하지 않아.

기다리기, 그리고 끝마다 문장 하나

Ember가 클립을 대기, 제출 중, 생성 중, 다운로드 중이라고 알리는 동안 워커는 5초마다 GET /api-video/jobs/{id}를 물어. Ember가 알려준 단계를 작업에 옮겨 적고. 물어보다 오류가 나도 그건 읽기일 뿐이라, 워커는 기다렸다가 또 물어. 404는 달라. Ember가 그 클립을 더는 모른다는 뜻이야. 그래서 작업은 uncertain이 되고, 다시 시도하면 새 클립 값을 낸다는 경고가 붙어. 폴링이 어떻게 끝나느냐에 따라 남는 문장도 달라.

Ember의 말작업 상태작업이 남기는 말
failedfailed벤더가 댄 이유와, 청구된 건 없다는 Nothing was charged.
cancelledcancelled제출 전에 취소됐다는 말
donecopying, 그리고 done클립을 저장하는 중이라는 말
그 밖의 모든 것uncertainEmber의 Pro Video 탭에서 먼저 정리하고, 여기서 대조하고, 절대 눈 감고 다시 시도하지 말라는 말

Ember가 약속하는 바로는, 실패는 벤더가 거절했거나 만들지 못했다는 뜻이고 그땐 청구가 없어. 테스트의 예는 콘텐츠 검열 거절이야. 작업엔 프롬프트가 검열에서 거절됐고 청구된 건 없다고 남아. 불확실은 돈이 나갔을 수도 있는데 Ember도 모른다는 뜻이야. 다 만든 클립이 다운로드가 안 된 경우가 그래. Avatars는 짐작하지 않아. 정할 수 있는 단 한 곳으로 아빠를 보내.

대조는 묻기만 하고, 다시 보내지 않아

reconcile_clip()은 이미지 검색보다 날카로운 도구를 써. 시도마다 이름이 있거든. 제출을 받아줬다는 답이 있었으면 클립 id로 Ember에 물어. 그게 아니거나 404가 나면 시도의 키로 물어. 창구는 GET /api-video/requests/{key}고, 받아준 그 시도의 키를 다시 계산해서 써. 보내는 건 하나도 없어. Ember의 답에 따라 작업이 움직여.

  • 그 키로 만든 클립이 없어. 벤더에 닿은 게 없다는 뜻이야. 일치 0개로 대조 기록이 남고, 이제 인정하고 다시 시도하면 새 클립 값을 낼 수 있어.
  • 끝났어. 작업은 copying으로 가서 클립을 저장해. 다시 생성하는 건 없어.
  • 아직 진행 중이야. running으로 돌아가고 워커가 다시 기다려.
  • 실패했어. failed, 청구는 없어.
  • Ember에서도 아직 불확실해. 클립 id가 일치로 기록돼. 그래서 Ember가 정리할 때까지 retry()는 계속 거절해.

테스트는 제일 어려운 경우를 돌려봐. 다운로드가 실패해서 Ember가 클립을 불확실로 쥐고 있어. 대조 전에 다시 시도하면 거절이야. 대조한 뒤에도 거절이야. 일치가 남아 있으니까. Ember가 다운로드를 다시 받아 오면, 대조가 작업을 copying으로 옮기고 워커가 마무리해. 그동안 Ember가 받은 요청은 여전히 딱 하나야.

아바타는 못 되는 재료

Ember가 클립이 끝났다고 알리면 finish_clip()이 MP4와 포스터 프레임을 받아 와서 save_clip()에 넘겨. 저장소는 4번부터 7번 바이트가 ftyp인지 봐. MP4 파일이 맨 앞에 두는 상자야. 이어서 SHA-256으로 이름을 붙이고 딱 한 번만 써. 이미지 원본을 쓰는 방식이랑 똑같아. 포스터는 다시 만들 수 있는 파생물이라 썸네일 옆에 둬. 포스터를 못 받아 와도 클립은 저장돼. 에셋엔 media: "video", 길이와 픽셀 크기, 설정, 추정치, 청구액, Ember의 클립 id가 붙어. 클립을 만든 피사체와 레퍼런스도 같이 붙고.

모든 클립의 집은 Ember인데 Avatars가 사본을 따로 두는 이유가 바로 이 혈통이야. 혈통이 있어야 Super Soul 보호가 피사체와 레퍼런스를 따라 클립까지 가. 자세한 건 트랙 8에서 다뤄. 그리고 클립은 아바타가 하는 일 전부에서 벽으로 막혀 있어. 레퍼런스 프로필도, 이미지 계획도, 클립 자신의 레퍼런스와 프레임도, export 프리셋도 전부 클립을 거절해. 아키텍처 문서는 클립을 아빠 채널에 쓸 재료라고 불러.

청구액은 나중에 다시 읽어

벤더 답 어디에도 가격이 없으니 청구액은 늦게 와. Ember는 20초, 60초, 180초, 600초를 차례로 기다리며 벤더 사용량 통계를 읽어. 다 합치면 14분쯤이야. Avatars는 배치 상세를 읽을 때마다, 사본에 청구액이 아직 없는 클립을 Ember에 한 번 더 물어. 다만 저장하고 30분까지만. 그 뒤론 주석 말대로, Ember가 찾기를 그만둔 지 오래니 추정치가 그대로 남아. 스튜디오는 청구액을 알면 그걸 보여주고, 모르면 대략 몇 크레딧이라고 보여줘. 처음부터 끝까지 재 본 클립은 하나야. 벽난로 옆 피파, Veo 3.1 Fast, 1280×720, 8초. 88초가 걸렸고 4,848 크레딧이 청구됐어. 추정치랑 딱 같았지.

사는 곳에서 재생해

클립은 서비스 호스트에 살아. 그래서 재생도 거기서 해. POST /api/assets/{id}/play는 클립이 아니면 거절하고, 저장소에서 파일을 찾아 open -b com.cwk.ashenreel로 열어. Ashen Reel의 번들 id야. 부르는 쪽은 에셋만 지목하고, 경로는 절대 안 줘. 답에는 그 맥의 플릿 라벨이 들어 있어. 그래서 라이트박스의 Open in Ashen Reel 버튼이 클립이 어디서 열렸는지 말해 줄 수 있어. Ashen Reel Quest는 같은 규칙을 플레이어 쪽에서 가르쳐. 넘겨줄 땐 정체만 싣고 경로는 싣지 않는다고. 아이폰과 아이패드에서 바로 재생하는 얘기는 트랙 9 몫이야.

원칙: 되돌릴 수 없는 일이면 시작 전에 말하고, 시작한 뒤엔 아닌 척하지 말고, 아는 쪽에 계속 물어.

Code

cwkAvatars, backend/worker.py: 제출 전엔 여기서 취소하고, 그 뒤엔 거절해 (원문 그대로, 243-254행)·python
    async def cancel(self, id):
        task = await self.studio.io("get", id, "task")
        if task["state"] in {"queued", "preparing"}:
            return await self.studio.io("update", id, expected=task["version"], state="cancelled", phase="Cancelled before submission")
        if task["state"] in {"submitting", "uncertain"}:
            raise StudioError("Reconcile this task first; its upstream outcome is not known.", 409)
        if task["state"] != "running":
            raise StudioError("This task is no longer cancellable.", 409)
        if is_clip(task):
            raise StudioError("A submitted clip cannot be cancelled: the vendor finishes it, charges it, and it is saved.", 409)
        await self.studio.upstream.json(self.studio.upstream.settings.ember, f"/api/v1/api-generators/jobs/{task['job_id']}/cancel", method="POST", body={})
        return {"requested": True, "message": "Cancellation requested. A metered request already sent may still be billed by the provider."}
cwkAvatars, backend/worker.py: 클립 id나 시도의 키로 대조하고, 다시 보내지는 않아 (원문 그대로, 284-308행)·python
    async def reconcile_clip(self, task):
        """Ask Ember what became of this clip: by its id when the submit was
        acknowledged, else by the attempt's request id. Nothing is re-sent."""
        io, upstream, id = self.studio.io, self.studio.upstream, task["id"]
        record = None
        if task.get("job_id"):
            try:
                record = await upstream.video_clip(task["job_id"])
            except StudioError as exc:
                if exc.status != 404:
                    raise
        if record is None:
            attempt = {**task, "attempts": task["attempts"][:-1] if task.get("job_id") and task["attempts"] else task["attempts"]}
            record = await upstream.video_request(clip_request_id(attempt))
        if record is None:
            await io("update", id, phase="Reconcile before retry", reconciliation={"checked_at": now(), "matches": []}, error="Ember has no clip for this task, so nothing was submitted to the vendor. A retry makes and pays for a new clip.")
        elif record["state"] == "done":
            await io("update", id, state="copying", phase="Saving the clip", job_id=record["id"], upstream_result=record, error=None)
        elif record["state"] in CLIP_PENDING:
            await io("update", id, state="running", phase=record.get("phase") or "Generating", job_id=record["id"], error=None)
        elif record["state"] == "failed":
            await io("update", id, state="failed", phase="Generation stopped", job_id=record["id"], error=f"{(record.get('error') or {}).get('message') or 'The vendor could not generate this clip.'} Nothing was charged.")
        else:
            await io("update", id, phase="Reconcile before retry", job_id=record["id"], reconciliation={"checked_at": now(), "matches": [record["id"]]}, error=f"{(record.get('error') or {}).get('message') or 'Ember holds this clip as uncertain.'} Resolve it in Ember's Pro Video tab first.")
        return await io("get", id, "task")
cwkAvatars, backend/store.py: 클립도 원본처럼 보관해 (원문 그대로, 189-204행)·python
    def save_clip(self, data: bytes, *, label: str, subjects: list[str], width: int | None, height: int | None, duration: float | None, poster: bytes | None = None, references: list[str] | None = None, **metadata) -> dict:
        """A generated video clip, kept like any original: native bytes,
        content-addressed, with the lineage of the references it was made from.
        Its poster frame is a regenerable derivative beside the thumbnails."""
        if len(data) < 12 or data[4:8] != b"ftyp":
            raise StudioError("The clip is not an MP4 file.")
        digest = hashlib.sha256(data).hexdigest()
        filename = f"{digest}.mp4"
        self.write_original(filename, data, digest)
        if poster:
            target = self.root / "thumbnails" / f"{digest}.poster.jpg"
            if not target.exists():
                temporary = target.with_name(f".{identity()}.tmp")
                temporary.write_bytes(poster)
                os.replace(temporary, target)
        return self.add("asset", label=label[:200], subjects=sorted(set(subjects)), origin="generated", references=list(dict.fromkeys(references or [])), filename=filename, sha256=digest, width=width, height=height, duration=duration, mime="video/mp4", media="video", bytes=len(data), archived=False, **metadata)

External links

Exercise

네 소프트웨어에서 돈이 들고 되돌릴 수 없는 작업을 하나 골라봐. 그 취소를 상태 표로 써 봐. 취소가 공짜고 확실한 곳, 결과를 알 때까지 거절해야 하는 곳, 영영 거절하고 대신 무슨 일이 일어날지 문장으로 알려주는 곳. 이어서 대조 함수를 써. 받은 id나 네가 정한 멱등 키로 상대 쪽을 읽기만 하고, 답마다 다음 상태를 정확히 하나로 옮기게. 마지막으로 결과를 저장할 땐 파일 서명을 확인하고, 그걸 만든 입력도 같이 붙여 둬.
Hint
코드를 쓰기 전에 워커가 있을 수 있는 상태부터 다 적어. 상태와 원격 답의 짝마다 줄 하나씩 줘. 뭐라도 보낼 수 있는 대조는 재시도가 변장한 거야. 서명 확인은 진짜 파일의 앞 열두 바이트쯤을 헥스 뷰어로 열어 보고, 어느 바이트를 믿을지 정해.

Progress

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

댓글 0

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

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